sec: webhook SSRF guard, IMAP-sync owner check, watcher port membership
Three findings from a fourth-pass review: 1. MEDIUM — webhook URL SSRF. The validator only enforced HTTPS+URL parse; it accepted private/loopback/link-local/.internal hosts. The delivery worker fetched arbitrary URLs and persisted up to 1KB of response body into webhook_deliveries.response_body, which is then surfaced via the deliveries listing endpoint — a port admin could register a webhook to an internal HTTPS endpoint, hit the test endpoint to force immediate dispatch, and read the response back. Validator now rejects RFC-1918/loopback/link-local/CGNAT/ULA IPs (v4 + v6) and .internal/.local/.localhost/.lan/.intranet/.corp suffixes; the worker re-resolves the hostname at dispatch time and blocks before fetch (DNS rebinding defense). 21-case unit test covers the matrix. 2. MEDIUM — POST /api/v1/email/accounts/[id]/sync had no owner check. Any user with email:view could enqueue an inbox-sync job for any accountId, which the worker would honour using the foreign user's decrypted IMAP credentials and advance the account's lastSyncAt (data-loss risk on the legitimate owner's next sync). Route now asserts account.userId === ctx.userId before enqueueing, matching the toggle/disconnect endpoints. 3. MEDIUM — addDocumentWatcher (and the wizard / upload watcher inserts) didn't validate the watcher's userId belonged to the document's port. notifyDocumentEvent then emitted a real-time socket toast + email containing the document title to the foreign user. New assertWatchersInPort helper verifies each candidate has a userPortRoles row for the port (super-admin bypass). 818 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import { companies } from '@/lib/db/schema/companies';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { userProfiles, userPortRoles } from '@/lib/db/schema/users';
|
||||
import { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { diffEntity } from '@/lib/entity-diff';
|
||||
@@ -337,6 +338,34 @@ async function assertSubjectFksInPort(
|
||||
await Promise.all(checks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject watchers whose user does not have access to the document's port.
|
||||
* Without this guard, a document watcher row could be created for a user
|
||||
* outside the document's tenant; subsequent notifyDocumentEvent emits a
|
||||
* socket toast + email to that user revealing the document's title.
|
||||
* Super-admins are always allowed (they can watch anything).
|
||||
*/
|
||||
async function assertWatchersInPort(portId: string, userIds: string[]): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
const unique = [...new Set(userIds)];
|
||||
// Super-admins bypass the port check.
|
||||
const profiles = await db.query.userProfiles.findMany({
|
||||
where: inArray(userProfiles.userId, unique),
|
||||
});
|
||||
const superAdmins = new Set(profiles.filter((p) => p.isSuperAdmin).map((p) => p.userId));
|
||||
const needsCheck = unique.filter((u) => !superAdmins.has(u));
|
||||
if (needsCheck.length === 0) return;
|
||||
const roles = await db
|
||||
.select({ userId: userPortRoles.userId })
|
||||
.from(userPortRoles)
|
||||
.where(and(inArray(userPortRoles.userId, needsCheck), eq(userPortRoles.portId, portId)));
|
||||
const allowed = new Set(roles.map((r) => r.userId));
|
||||
const denied = needsCheck.filter((u) => !allowed.has(u));
|
||||
if (denied.length > 0) {
|
||||
throw new ValidationError('One or more watchers do not have access to this port');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Create ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) {
|
||||
@@ -1219,6 +1248,7 @@ export async function addDocumentWatcher(
|
||||
meta: AuditMeta,
|
||||
): Promise<{ userId: string; addedAt: Date }> {
|
||||
await getDocumentById(documentId, portId);
|
||||
await assertWatchersInPort(portId, [userId]);
|
||||
const [row] = await db
|
||||
.insert(documentWatchers)
|
||||
.values({ documentId, userId, addedBy: meta.userId })
|
||||
@@ -1318,6 +1348,7 @@ export async function createFromWizard(
|
||||
if (!doc) throw new Error('Failed to insert document');
|
||||
|
||||
if (data.watchers.length > 0) {
|
||||
await assertWatchersInPort(portId, data.watchers);
|
||||
await db.insert(documentWatchers).values(
|
||||
data.watchers.map((userId) => ({
|
||||
documentId: doc.id,
|
||||
@@ -1414,6 +1445,7 @@ export async function createFromUpload(
|
||||
);
|
||||
|
||||
if (data.watchers.length > 0) {
|
||||
await assertWatchersInPort(portId, data.watchers);
|
||||
await db.insert(documentWatchers).values(
|
||||
data.watchers.map((userId) => ({
|
||||
documentId: doc.id,
|
||||
|
||||
Reference in New Issue
Block a user