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:
Matt Ciaccio
2026-04-29 03:15:39 +02:00
parent 9a5479c2c7
commit 47a1a51832
5 changed files with 260 additions and 20 deletions

View File

@@ -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,