Files
pn-new-crm/src/app/api/v1/email/accounts/[accountId]/sync/route.ts
Matt Ciaccio 47a1a51832 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>
2026-04-29 03:15:39 +02:00

36 lines
1.4 KiB
TypeScript

import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { emailAccounts } from '@/lib/db/schema/email';
import { errorResponse, ForbiddenError, NotFoundError } from '@/lib/errors';
import { getQueue } from '@/lib/queue';
export const POST = withAuth(
withPermission('email', 'view', async (_req, ctx, params) => {
try {
const accountId = params.accountId!;
// Owner check: the sibling toggle/disconnect endpoints already enforce
// account.userId === ctx.userId. Without the same check here, any
// user with `email:view` could force IMAP sync against a foreign
// account, advancing lastSyncAt (data-loss risk on the legitimate
// owner's next sync) and triggering work using the foreign user's
// decrypted credentials.
const account = await db.query.emailAccounts.findFirst({
where: eq(emailAccounts.id, accountId),
});
if (!account) throw new NotFoundError('Email account');
if (account.userId !== ctx.userId) {
throw new ForbiddenError('You do not own this email account');
}
const queue = getQueue('email');
const job = await queue.add('inbox-sync', { accountId });
return NextResponse.json({ data: { jobId: job.id } }, { status: 202 });
} catch (error) {
return errorResponse(error);
}
}),
);