fix(security): tier-0 audit blockers (next CVE, role gate, perm traps, key validation, rate limits)

Closes the five highest-risk findings from
docs/audit-comprehensive-2026-05-05.md so the platform is not exposed
while the rest of the audit backlog (1 CRIT + 18 HIGH + 32 MED + 23 LOW)
is worked through:

* CVE-2025-29927 — bump next 15.1.0 → 15.2.9; nginx strips
  X-Middleware-Subrequest at the edge as defense-in-depth.
* Cross-tenant role escalation — POST/PATCH/DELETE on /admin/roles now
  require super-admin (was: any holder of admin.manage_users).  Adds
  shared `requireSuperAdmin(ctx)` helper.
* Silent-403 traps — `documents.edit` and `files.edit` keys added to
  RolePermissions; seeded role values updated; migration 0041 backfills
  the new keys on every existing roles+port_role_overrides JSONB.  File
  routes remap the dead `create` action to `upload` / `manage_folders`.
* Berth-PDF / brochure register endpoints — reject body.storageKey
  unless it matches the namespace the matching presign endpoint issued
  (prevents repointing a tenant's PDF at foreign-port bytes).
* Portal auth rate limits — sign-in 5/15min/(ip,email),
  forgot-password 3/hr/IP, activate/reset/set-password 10/hr/IP.  Adds
  `enforcePublicRateLimit()` for non-`withAuth` routes.

Test status unchanged: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md (CRITICAL, HIGH §§1–4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 18:33:13 +02:00
parent 4723994bdc
commit 312779c0c5
24 changed files with 1489 additions and 126 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,10 @@ proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# Defense-in-depth for CVE-2025-29927: strip the header attackers use to
# skip Next.js middleware. Patched in next>=15.2.3, but neutralizing the
# input at the edge means a future regression cannot reopen the bypass.
proxy_set_header X-Middleware-Subrequest "";
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_send_timeout 60s;

View File

@@ -69,7 +69,7 @@
"lucide-react": "^0.460.0",
"mailparser": "^3.9.4",
"minio": "^8.0.0",
"next": "15.1.0",
"next": "15.2.9",
"next-themes": "^0.4.0",
"nodemailer": "^6.9.0",
"openai": "^6.27.0",
@@ -110,7 +110,7 @@
"drizzle-kit": "^0.30.0",
"esbuild": "^0.25.0",
"eslint": "^9.0.0",
"eslint-config-next": "15.1.0",
"eslint-config-next": "15.2.9",
"eslint-config-prettier": "^9.1.0",
"husky": "^9.1.0",
"lint-staged": "^15.2.0",

107
pnpm-lock.yaml generated
View File

@@ -109,7 +109,7 @@ importers:
version: 7.0.1
better-auth:
specifier: ^1.2.0
version: 1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
version: 1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.2.9(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)))
bullmq:
specifier: ^5.25.0
version: 5.71.0
@@ -153,8 +153,8 @@ importers:
specifier: ^8.0.0
version: 8.0.7
next:
specifier: 15.1.0
version: 15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
specifier: 15.2.9
version: 15.2.9(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes:
specifier: ^0.4.0
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -271,8 +271,8 @@ importers:
specifier: ^9.0.0
version: 9.39.4(jiti@1.21.7)
eslint-config-next:
specifier: 15.1.0
version: 15.1.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
specifier: 15.2.9
version: 15.2.9(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
eslint-config-prettier:
specifier: ^9.1.0
version: 9.1.2(eslint@9.39.4(jiti@1.21.7))
@@ -1494,60 +1494,60 @@ packages:
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
'@next/env@15.1.0':
resolution: {integrity: sha512-UcCO481cROsqJuszPPXJnb7GGuLq617ve4xuAyyNG4VSSocJNtMU5Fsx+Lp6mlN8c7W58aZLc5y6D/2xNmaK+w==}
'@next/env@15.2.9':
resolution: {integrity: sha512-0JJ6OlIb1kZiAbY/Hi5XHb2ZT7B5/l8CyGX3GxtTY8LNl1Inm9EU8PnCtVzUR8N2Si3a1pX02PbKBlDcsHNvUQ==}
'@next/eslint-plugin-next@15.1.0':
resolution: {integrity: sha512-+jPT0h+nelBT6HC9ZCHGc7DgGVy04cv4shYdAe6tKlEbjQUtwU3LzQhzbDHQyY2m6g39m6B0kOFVuLGBrxxbGg==}
'@next/eslint-plugin-next@15.2.9':
resolution: {integrity: sha512-AgCS3+FYsSU4aHcmL+FutRWIJ52x9v/etDT+1ttWXEJILn3yo9ALp9lGgC6REtsj1/uPAsLFUh1uvs4LxW2KvQ==}
'@next/swc-darwin-arm64@15.1.0':
resolution: {integrity: sha512-ZU8d7xxpX14uIaFC3nsr4L++5ZS/AkWDm1PzPO6gD9xWhFkOj2hzSbSIxoncsnlJXB1CbLOfGVN4Zk9tg83PUw==}
'@next/swc-darwin-arm64@15.2.5':
resolution: {integrity: sha512-4OimvVlFTbgzPdA0kh8A1ih6FN9pQkL4nPXGqemEYgk+e7eQhsst/p35siNNqA49eQA6bvKZ1ASsDtu9gtXuog==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.1.0':
resolution: {integrity: sha512-DQ3RiUoW2XC9FcSM4ffpfndq1EsLV0fj0/UY33i7eklW5akPUCo6OX2qkcLXZ3jyPdo4sf2flwAED3AAq3Om2Q==}
'@next/swc-darwin-x64@15.2.5':
resolution: {integrity: sha512-ohzRaE9YbGt1ctE0um+UGYIDkkOxHV44kEcHzLqQigoRLaiMtZzGrA11AJh2Lu0lv51XeiY1ZkUvkThjkVNBMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.1.0':
resolution: {integrity: sha512-M+vhTovRS2F//LMx9KtxbkWk627l5Q7AqXWWWrfIzNIaUFiz2/NkOFkxCFyNyGACi5YbA8aekzCLtbDyfF/v5Q==}
'@next/swc-linux-arm64-gnu@15.2.5':
resolution: {integrity: sha512-FMSdxSUt5bVXqqOoZCc/Seg4LQep9w/fXTazr/EkpXW2Eu4IFI9FD7zBDlID8TJIybmvKk7mhd9s+2XWxz4flA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.1.0':
resolution: {integrity: sha512-Qn6vOuwaTCx3pNwygpSGtdIu0TfS1KiaYLYXLH5zq1scoTXdwYfdZtwvJTpB1WrLgiQE2Ne2kt8MZok3HlFqmg==}
'@next/swc-linux-arm64-musl@15.2.5':
resolution: {integrity: sha512-4ZNKmuEiW5hRKkGp2HWwZ+JrvK4DQLgf8YDaqtZyn7NYdl0cHfatvlnLFSWUayx9yFAUagIgRGRk8pFxS8Qniw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.1.0':
resolution: {integrity: sha512-yeNh9ofMqzOZ5yTOk+2rwncBzucc6a1lyqtg8xZv0rH5znyjxHOWsoUtSq4cUTeeBIiXXX51QOOe+VoCjdXJRw==}
'@next/swc-linux-x64-gnu@15.2.5':
resolution: {integrity: sha512-bE6lHQ9GXIf3gCDE53u2pTl99RPZW5V1GLHSRMJ5l/oB/MT+cohu9uwnCK7QUph2xIOu2a6+27kL0REa/kqwZw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.1.0':
resolution: {integrity: sha512-t9IfNkHQs/uKgPoyEtU912MG6a1j7Had37cSUyLTKx9MnUpjj+ZDKw9OyqTI9OwIIv0wmkr1pkZy+3T5pxhJPg==}
'@next/swc-linux-x64-musl@15.2.5':
resolution: {integrity: sha512-y7EeQuSkQbTAkCEQnJXm1asRUuGSWAchGJ3c+Qtxh8LVjXleZast8Mn/rL7tZOm7o35QeIpIcid6ufG7EVTTcA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.1.0':
resolution: {integrity: sha512-WEAoHyG14t5sTavZa1c6BnOIEukll9iqFRTavqRVPfYmfegOAd5MaZfXgOGG6kGo1RduyGdTHD4+YZQSdsNZXg==}
'@next/swc-win32-arm64-msvc@15.2.5':
resolution: {integrity: sha512-gQMz0yA8/dskZM2Xyiq2FRShxSrsJNha40Ob/M2n2+JGRrZ0JwTVjLdvtN6vCxuq4ByhOd4a9qEf60hApNR2gQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.1.0':
resolution: {integrity: sha512-J1YdKuJv9xcixzXR24Dv+4SaDKc2jj31IVUEMdO5xJivMTXuE6MAdIi4qPjSymHuFG8O5wbfWKnhJUcHHpj5CA==}
'@next/swc-win32-x64-msvc@15.2.5':
resolution: {integrity: sha512-tBDNVUcI7U03+3oMvJ11zrtVin5p0NctiuKmTGyaTIEAVj9Q77xukLXGXRnWxKRIIdFG4OTA2rUVGZDYOwgmAA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -3669,8 +3669,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
eslint-config-next@15.1.0:
resolution: {integrity: sha512-gADO+nKVseGso3DtOrYX9H7TxB/MuX7AUYhMlvQMqLYvUWu4HrOQuU7cC1HW74tHIqkAvXdwgAz3TCbczzSEXw==}
eslint-config-next@15.2.9:
resolution: {integrity: sha512-MWpGYzLdkJ38OF1g1R4wQe9GVvoinCyIeYofITHh5D3FmHuIOgeWAK46M+iUYrIG1cJNX0HPh5fHpjmuC3dnrw==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1'
@@ -4677,10 +4677,9 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.1.0:
resolution: {integrity: sha512-QKhzt6Y8rgLNlj30izdMbxAwjHMFANnLwDwZ+WQh5sMhyt4lEBqDK9QpvWHtIM4rINKPoJ8aiRZKg5ULSybVHw==}
next@15.2.9:
resolution: {integrity: sha512-jXEBIPi+kIkMe5KI4okvGIWvot9hyiDz2fT4OqxxsSeZTA6zhSwrQkJwTE3GmQ1HQlolcQjTNMjHMvc8hhog7g==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@@ -7078,34 +7077,34 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@next/env@15.1.0': {}
'@next/env@15.2.9': {}
'@next/eslint-plugin-next@15.1.0':
'@next/eslint-plugin-next@15.2.9':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.1.0':
'@next/swc-darwin-arm64@15.2.5':
optional: true
'@next/swc-darwin-x64@15.1.0':
'@next/swc-darwin-x64@15.2.5':
optional: true
'@next/swc-linux-arm64-gnu@15.1.0':
'@next/swc-linux-arm64-gnu@15.2.5':
optional: true
'@next/swc-linux-arm64-musl@15.1.0':
'@next/swc-linux-arm64-musl@15.2.5':
optional: true
'@next/swc-linux-x64-gnu@15.1.0':
'@next/swc-linux-x64-gnu@15.2.5':
optional: true
'@next/swc-linux-x64-musl@15.1.0':
'@next/swc-linux-x64-musl@15.2.5':
optional: true
'@next/swc-win32-arm64-msvc@15.1.0':
'@next/swc-win32-arm64-msvc@15.2.5':
optional: true
'@next/swc-win32-x64-msvc@15.1.0':
'@next/swc-win32-x64-msvc@15.2.5':
optional: true
'@noble/ciphers@1.3.0': {}
@@ -8591,7 +8590,7 @@ snapshots:
baseline-browser-mapping@2.10.8: {}
better-auth@1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))):
better-auth@1.5.5(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))(mongodb@7.1.0(socks@2.8.7))(next@15.2.9(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))):
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@3.25.76))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@3.25.76))(jose@6.2.1)(kysely@0.28.11)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4))
@@ -8614,7 +8613,7 @@ snapshots:
drizzle-kit: 0.30.6
drizzle-orm: 0.38.4(@types/react@19.2.14)(kysely@0.28.11)(postgres@3.4.8)(react@19.2.4)
mongodb: 7.1.0(socks@2.8.7)
next: 15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next: 15.2.9(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
vitest: 4.1.0(@types/node@22.19.15)(vite@8.0.0(@types/node@22.19.15)(esbuild@0.25.12)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
@@ -9328,9 +9327,9 @@ snapshots:
escape-string-regexp@4.0.0: {}
eslint-config-next@15.1.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
eslint-config-next@15.2.9(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 15.1.0
'@next/eslint-plugin-next': 15.2.9
'@rushstack/eslint-patch': 1.16.1
'@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/parser': 8.57.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
@@ -10416,9 +10415,9 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
next@15.1.0(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next@15.2.9(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 15.1.0
'@next/env': 15.2.9
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@@ -10428,14 +10427,14 @@ snapshots:
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(react@19.2.4)
optionalDependencies:
'@next/swc-darwin-arm64': 15.1.0
'@next/swc-darwin-x64': 15.1.0
'@next/swc-linux-arm64-gnu': 15.1.0
'@next/swc-linux-arm64-musl': 15.1.0
'@next/swc-linux-x64-gnu': 15.1.0
'@next/swc-linux-x64-musl': 15.1.0
'@next/swc-win32-arm64-msvc': 15.1.0
'@next/swc-win32-x64-msvc': 15.1.0
'@next/swc-darwin-arm64': 15.2.5
'@next/swc-darwin-x64': 15.2.5
'@next/swc-linux-arm64-gnu': 15.2.5
'@next/swc-linux-arm64-musl': 15.2.5
'@next/swc-linux-x64-gnu': 15.2.5
'@next/swc-linux-x64-musl': 15.2.5
'@next/swc-win32-arm64-msvc': 15.2.5
'@next/swc-win32-x64-msvc': 15.2.5
'@playwright/test': 1.58.2
sharp: 0.33.5
transitivePeerDependencies:

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { consumeCrmInvite } from '@/lib/services/crm-invite.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
const bodySchema = z.object({
token: z.string().min(1),
@@ -10,6 +11,10 @@ const bodySchema = z.object({
});
export async function POST(req: NextRequest): Promise<NextResponse> {
// 10/hour/IP — bounds brute-force against the CRM invite token.
const limited = await enforcePublicRateLimit(req, 'portalToken');
if (limited) return limited;
let body: unknown;
try {
body = await req.json();

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { activateAccount } from '@/lib/services/portal-auth.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
const bodySchema = z.object({
token: z.string().min(1),
@@ -10,6 +11,10 @@ const bodySchema = z.object({
});
export async function POST(req: NextRequest): Promise<NextResponse> {
// 10/hour/IP — bounds brute-force against the 32-byte activation token.
const limited = await enforcePublicRateLimit(req, 'portalToken');
if (limited) return limited;
let body: unknown;
try {
body = await req.json();

View File

@@ -3,10 +3,17 @@ import { z } from 'zod';
import { logger } from '@/lib/logger';
import { requestPasswordReset } from '@/lib/services/portal-auth.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
const bodySchema = z.object({ email: z.string().email() });
export async function POST(req: NextRequest): Promise<NextResponse> {
// 3/hour/IP — tightest of the portal limiters because each successful
// call sends an outbound email and timing differences here are the
// primary email-enumeration vector.
const limited = await enforcePublicRateLimit(req, 'portalForgot');
if (limited) return limited;
let body: unknown;
try {
body = await req.json();

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { resetPassword } from '@/lib/services/portal-auth.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
const bodySchema = z.object({
token: z.string().min(1),
@@ -10,6 +11,10 @@ const bodySchema = z.object({
});
export async function POST(req: NextRequest): Promise<NextResponse> {
// 10/hour/IP — bounds brute-force against the 32-byte reset token.
const limited = await enforcePublicRateLimit(req, 'portalToken');
if (limited) return limited;
let body: unknown;
try {
body = await req.json();

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import { errorResponse } from '@/lib/errors';
import { PORTAL_COOKIE } from '@/lib/portal/auth';
import { signIn } from '@/lib/services/portal-auth.service';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
const bodySchema = z.object({
email: z.string().email(),
@@ -17,14 +18,24 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 });
}
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid email or password' }, { status: 400 });
return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 });
}
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
// the limiter is per-account-per-IP, not just per-IP — a NATed network
// shouldn't be able to lock a single victim by burning their bucket.
const limited = await enforcePublicRateLimit(
req,
'portalSignIn',
parsed.data.email.toLowerCase(),
);
if (limited) return limited;
try {
const result = await signIn(parsed.data);
const res = NextResponse.json({ success: true });

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ValidationError } from '@/lib/errors';
import {
generateBrochureStorageKey,
registerBrochureVersion,
@@ -46,11 +46,28 @@ export const GET = withAuth(
}),
);
// Storage keys generated by `generateBrochureStorageKey` look like
// `<portSlug>/brochures/<brochureId>/<uuid>.pdf`. Reject anything else —
// without this, an admin holding manage_settings on port A could ship a
// foreign port's storage key (signed EOI bytes, another port's brochure)
// and have registerBrochureVersion repoint THIS port's brochure version
// at confidential bytes that subsequently serve under brochures.view.
const BROCHURE_KEY_RE =
/^[a-z0-9-]+\/brochures\/[A-Za-z0-9_-]+\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.pdf$/;
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
try {
const id = params.id!;
const input = await parseBody(req, registerBrochureVersionSchema);
if (!BROCHURE_KEY_RE.test(input.storageKey)) {
throw new ValidationError('storageKey is not in the expected brochure path');
}
const segments = input.storageKey.split('/');
// segments: [portSlug, 'brochures', brochureId, '<uuid>.pdf']
if (segments[2] !== id) {
throw new ValidationError('storageKey brochureId does not match route param');
}
const data = await registerBrochureVersion({
portId: ctx.portId,
brochureId: id,

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, requireSuperAdmin } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { getRole, updateRole, deleteRole } from '@/lib/services/roles.service';
import { updateRoleSchema } from '@/lib/validators/roles';
@@ -17,9 +17,10 @@ export const GET = withAuth(
}),
);
export const PATCH = withAuth(
withPermission('admin', 'manage_users', async (req, ctx, params) => {
// Mutations on global roles are super-admin-only — see route.ts header.
export const PATCH = withAuth(async (req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'roles.update');
const body = await parseBody(req, updateRoleSchema);
const data = await updateRole(params.id!, body, {
userId: ctx.userId,
@@ -31,12 +32,11 @@ export const PATCH = withAuth(
} catch (error) {
return errorResponse(error);
}
}),
);
});
export const DELETE = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx, params) => {
export const DELETE = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'roles.delete');
await deleteRole(params.id!, {
userId: ctx.userId,
portId: ctx.portId,
@@ -47,5 +47,4 @@ export const DELETE = withAuth(
} catch (error) {
return errorResponse(error);
}
}),
);
});

View File

@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { withAuth, withPermission, requireSuperAdmin } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { listRoles, createRole } from '@/lib/services/roles.service';
import { createRoleSchema } from '@/lib/validators/roles';
@@ -17,9 +17,13 @@ export const GET = withAuth(
}),
);
export const POST = withAuth(
withPermission('admin', 'manage_users', async (req, ctx) => {
// Roles are global (no port_id) and assignments span every port via
// userPortRoles, so creation must be super-admin-only — a per-port admin
// holding admin.manage_users must never be able to mint a role that lives
// in another tenant.
export const POST = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'roles.create');
const body = await parseBody(req, createRoleSchema);
const data = await createRole(body, {
userId: ctx.userId,
@@ -31,5 +35,4 @@ export const POST = withAuth(
} catch (error) {
return errorResponse(error);
}
}),
);
});

View File

@@ -34,6 +34,17 @@ export const getHandler: RouteHandler = async (_req, ctx, params) => {
}
};
// Defense against the post-upload register endpoint trusting an arbitrary
// storageKey from the body. The companion presign endpoint always issues
// `berths/<berthId>/uploads/<uuid>_<sanitized>` (see ./pdf-upload-url),
// and pdf-upload-url tenant-scopes the berth lookup. Without this regex,
// a rep with berths.edit could ship the storage key of a foreign-port
// PDF (signed EOI, brochure blob, another port's berth) and have the
// service repoint THIS berth's currentPdfVersionId at it — subsequent
// pdf-download serves those bytes under the rep's own permission gate.
const STORAGE_KEY_RE =
/^berths\/[A-Za-z0-9_-]+\/uploads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}_/;
export const postHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = (await req.json()) as Partial<PostBody>;
@@ -46,6 +57,12 @@ export const postHandler: RouteHandler = async (req, ctx, params) => {
if (!body.sha256 || typeof body.sha256 !== 'string') {
throw new ValidationError('sha256 is required');
}
const expectedPrefix = `berths/${params.id!}/uploads/`;
if (!body.storageKey.startsWith(expectedPrefix) || !STORAGE_KEY_RE.test(body.storageKey)) {
throw new ValidationError(
'storageKey must come from the matching presign endpoint for this berth',
);
}
const result = await uploadBerthPdf({
berthId: params.id!,
portId: ctx.portId,

View File

@@ -20,7 +20,7 @@ function sanitizeFolderPath(raw: string): string {
}
export const PATCH = withAuth(
withPermission('files', 'edit', async (req, ctx, params) => {
withPermission('files', 'manage_folders', async (req, ctx, params) => {
try {
const pathSegments = params.path;
const currentPath = Array.isArray(pathSegments)

View File

@@ -12,7 +12,7 @@ const createFolderSchema = z.object({
});
export const POST = withAuth(
withPermission('files', 'create', async (req, ctx) => {
withPermission('files', 'manage_folders', async (req, ctx) => {
try {
const body = await parseBody(req, createFolderSchema);

View File

@@ -6,7 +6,7 @@ import { uploadFile } from '@/lib/services/files';
import { uploadFileSchema } from '@/lib/validators/files';
export const POST = withAuth(
withPermission('files', 'create', async (req, ctx) => {
withPermission('files', 'upload', async (req, ctx) => {
try {
const formData = await req.formData();
const file = formData.get('file') as File | null;

View File

@@ -8,7 +8,7 @@ import { db } from '@/lib/db';
import { portRoleOverrides, ports, userPortRoles, userProfiles } from '@/lib/db/schema';
import { type RolePermissions } from '@/lib/db/schema/users';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import { errorResponse, ForbiddenError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { runWithRequestContext, getRequestContext } from '@/lib/request-context';
import {
@@ -250,6 +250,31 @@ export function withAuth(
};
}
// ─── requireSuperAdmin ───────────────────────────────────────────────────────
/**
* Throws ForbiddenError when the caller is not a super-admin. Use inside
* route handlers (after withAuth) for endpoints that mutate global, cross-
* tenant state — global roles, cross-port migrations, system jobs.
*
* Logs the denied attempt to the audit trail (mirrors withPermission).
*/
export function requireSuperAdmin(ctx: AuthContext, attemptedAction = 'super_admin_only'): void {
if (ctx.isSuperAdmin) return;
logger.warn({ userId: ctx.userId, attemptedAction }, 'Super-admin gate denied');
void createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'permission_denied',
entityType: 'super_admin',
entityId: '',
metadata: { attemptedAction },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
throw new ForbiddenError('Super admin access required');
}
// ─── withPermission ──────────────────────────────────────────────────────────
/**

View File

@@ -1,6 +1,13 @@
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { z, ZodSchema } from 'zod';
import {
checkRateLimit,
rateLimiters,
rateLimitHeaders,
type RateLimiterName,
} from '@/lib/rate-limit';
/**
* Base list query schema shared by all paginated list endpoints.
*/
@@ -22,10 +29,7 @@ export type BaseListQuery = z.infer<typeof baseListQuerySchema>;
* Parses URL search params against a Zod schema.
* Throws a ZodError on validation failure (caught by `errorResponse`).
*/
export function parseQuery<T extends ZodSchema>(
req: NextRequest,
schema: T,
): z.infer<T> {
export function parseQuery<T extends ZodSchema>(req: NextRequest, schema: T): z.infer<T> {
const params = Object.fromEntries(req.nextUrl.searchParams.entries());
return schema.parse(params);
}
@@ -41,3 +45,52 @@ export async function parseBody<T extends ZodSchema>(
const body = await req.json();
return schema.parse(body);
}
/**
* Best-effort client IP from forwarded headers. The trusted proxy is
* nginx (which sets `x-forwarded-for` from `$proxy_add_x_forwarded_for`),
* so the leftmost token is the original client. Falls back to a literal
* `unknown` so the per-IP key still exists when running outside the
* proxy (dev, tests).
*/
export function clientIp(req: NextRequest): string {
const xff = req.headers.get('x-forwarded-for');
if (xff) {
const first = xff.split(',')[0]?.trim();
if (first) return first;
}
return req.headers.get('x-real-ip') ?? 'unknown';
}
/**
* Wraps an unauthenticated route handler with a per-IP (or per-key) rate
* limit. Used for portal/auth endpoints that have no session yet — the
* `withRateLimit` helper in api/helpers.ts is keyed on `ctx.userId` and
* cannot apply here.
*
* If `keySuffix` is provided, it's appended to the IP so a single client
* IP can't exhaust an unrelated user's bucket (e.g. for sign-in we key
* on `${ip}:${email}` so per-account brute force is the bottleneck and
* a noisy NAT IP doesn't deny everyone).
*/
export async function enforcePublicRateLimit(
req: NextRequest,
name: RateLimiterName,
keySuffix?: string,
): Promise<NextResponse | null> {
const config = rateLimiters[name];
const identifier = keySuffix ? `${clientIp(req)}:${keySuffix}` : clientIp(req);
const result = await checkRateLimit(identifier, config);
if (result.allowed) return null;
const retryAfterSec = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: 'Too many requests. Please try again shortly.', retryAfter: retryAfterSec },
{
status: 429,
headers: {
...rateLimitHeaders(result),
'Retry-After': retryAfterSec.toString(),
},
},
);
}

View File

@@ -0,0 +1,58 @@
-- Backfill the new `documents.edit` and `files.edit` permission keys on
-- every existing row in `roles.permissions`. The schema (RolePermissions
-- in src/lib/db/schema/users.ts) added these keys to close the silent-403
-- traps on PATCH /api/v1/documents/[id], /cancel, /remind, /watchers, and
-- PATCH /api/v1/files/[id] — each used a permission key that did not exist
-- in the schema, so withPermission()'s `resourcePerms[action]` returned
-- undefined and 403'd every non-superadmin call.
--
-- Backfill rule:
-- documents.edit ← documents.create (anyone who can create can edit)
-- files.edit ← files.upload (same rationale)
--
-- jsonb_set with create_missing=true (the default) inserts the key only
-- when it's absent, so re-runs are idempotent and the migration is safe
-- against a partial run.
UPDATE roles
SET permissions = jsonb_set(
permissions,
'{documents,edit}',
COALESCE(permissions->'documents'->'create', 'false'::jsonb),
true
)
WHERE permissions->'documents' IS NOT NULL
AND NOT (permissions->'documents' ? 'edit');
UPDATE roles
SET permissions = jsonb_set(
permissions,
'{files,edit}',
COALESCE(permissions->'files'->'upload', 'false'::jsonb),
true
)
WHERE permissions->'files' IS NOT NULL
AND NOT (permissions->'files' ? 'edit');
-- Same backfill on per-port overrides (`port_role_overrides.permissions`)
-- so an override that flipped a sibling permission stays consistent.
UPDATE port_role_overrides
SET permissions = jsonb_set(
permissions,
'{documents,edit}',
COALESCE(permissions->'documents'->'create', 'false'::jsonb),
true
)
WHERE permissions->'documents' IS NOT NULL
AND NOT (permissions->'documents' ? 'edit');
UPDATE port_role_overrides
SET permissions = jsonb_set(
permissions,
'{files,edit}',
COALESCE(permissions->'files'->'upload', 'false'::jsonb),
true
)
WHERE permissions->'files' IS NOT NULL
AND NOT (permissions->'files' ? 'edit');

View File

@@ -288,6 +288,13 @@
"when": 1778300000000,
"tag": "0040_error_events",
"breakpoints": true
},
{
"idx": 41,
"version": "7",
"when": 1778400000000,
"tag": "0041_role_permissions_edit_keys",
"breakpoints": true
}
]
}

View File

@@ -30,6 +30,7 @@ export type RolePermissions = {
documents: {
view: boolean;
create: boolean;
edit: boolean;
send_for_signing: boolean;
upload_signed: boolean;
delete: boolean;
@@ -54,6 +55,7 @@ export type RolePermissions = {
files: {
view: boolean;
upload: boolean;
edit: boolean;
delete: boolean;
manage_folders: boolean;
};

View File

@@ -42,6 +42,7 @@ const ALL_PERMISSIONS: RolePermissions = {
documents: {
view: true,
create: true,
edit: true,
send_for_signing: true,
upload_signed: true,
delete: true,
@@ -63,7 +64,7 @@ const ALL_PERMISSIONS: RolePermissions = {
record_payment: true,
export: true,
},
files: { view: true, upload: true, delete: true, manage_folders: true },
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
email: { view: true, send: true, configure_account: true },
reminders: {
view_own: true,
@@ -116,6 +117,7 @@ const DIRECTOR_PERMISSIONS: RolePermissions = {
documents: {
view: true,
create: true,
edit: true,
send_for_signing: true,
upload_signed: true,
delete: true,
@@ -137,7 +139,7 @@ const DIRECTOR_PERMISSIONS: RolePermissions = {
record_payment: true,
export: true,
},
files: { view: true, upload: true, delete: true, manage_folders: true },
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
email: { view: true, send: true, configure_account: true },
reminders: {
view_own: true,
@@ -190,6 +192,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
documents: {
view: true,
create: true,
edit: true,
send_for_signing: true,
upload_signed: true,
delete: false,
@@ -211,7 +214,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
record_payment: true,
export: true,
},
files: { view: true, upload: true, delete: false, manage_folders: true },
files: { view: true, upload: true, edit: true, delete: false, manage_folders: true },
email: { view: true, send: true, configure_account: true },
reminders: {
view_own: true,
@@ -264,6 +267,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
documents: {
view: true,
create: true,
edit: true,
send_for_signing: true,
upload_signed: true,
delete: false,
@@ -285,7 +289,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
record_payment: true,
export: true,
},
files: { view: true, upload: true, delete: false, manage_folders: false },
files: { view: true, upload: true, edit: false, delete: false, manage_folders: false },
email: { view: true, send: true, configure_account: true },
reminders: {
view_own: true,
@@ -338,6 +342,7 @@ const VIEWER_PERMISSIONS: RolePermissions = {
documents: {
view: true,
create: false,
edit: false,
send_for_signing: false,
upload_signed: false,
delete: false,
@@ -359,7 +364,7 @@ const VIEWER_PERMISSIONS: RolePermissions = {
record_payment: false,
export: false,
},
files: { view: true, upload: false, delete: false, manage_folders: false },
files: { view: true, upload: false, edit: false, delete: false, manage_folders: false },
email: { view: true, send: false, configure_account: false },
reminders: {
view_own: true,

View File

@@ -91,6 +91,17 @@ export const rateLimiters = {
* without dropping data. The shared-secret header gates abuse; this
* limiter is just a defensive backstop in case the secret leaks. */
websiteIntake: { windowMs: 60 * 60 * 1000, max: 500, keyPrefix: 'websiteintake' },
/** Portal sign-in: 5 attempts per 15min per (ip,email) bucket. Defends
* against credential stuffing on /api/portal/auth/sign-in. */
portalSignIn: { windowMs: 15 * 60 * 1000, max: 5, keyPrefix: 'portal:signin' },
/** Portal forgot-password: 3/hour/IP. Tighter than sign-in because it
* triggers an outbound email and is the primary email-enumeration
* vector (timing differences between known/unknown). */
portalForgot: { windowMs: 60 * 60 * 1000, max: 3, keyPrefix: 'portal:forgot' },
/** Portal activate / reset / set-password: 10/hour/IP. Bounds brute-
* force against the 32-byte token (random walk math is in our favour
* but a tight ceiling keeps the search space practically infeasible). */
portalToken: { windowMs: 60 * 60 * 1000, max: 10, keyPrefix: 'portal:token' },
} as const satisfies Record<string, RateLimitConfig>;
export type RateLimiterName = keyof typeof rateLimiters;

View File

@@ -316,6 +316,7 @@ export function makeFullPermissions(): RolePermissions {
documents: {
view: true,
create: true,
edit: true,
send_for_signing: true,
upload_signed: true,
delete: true,
@@ -337,7 +338,7 @@ export function makeFullPermissions(): RolePermissions {
record_payment: true,
export: true,
},
files: { view: true, upload: true, delete: true, manage_folders: true },
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
email: { view: true, send: true, configure_account: true },
reminders: {
view_own: true,
@@ -393,6 +394,7 @@ export function makeViewerPermissions(): RolePermissions {
documents: {
view: true,
create: false,
edit: false,
send_for_signing: false,
upload_signed: false,
delete: false,
@@ -414,7 +416,7 @@ export function makeViewerPermissions(): RolePermissions {
record_payment: false,
export: false,
},
files: { view: true, upload: false, delete: false, manage_folders: false },
files: { view: true, upload: false, edit: false, delete: false, manage_folders: false },
email: { view: true, send: false, configure_account: false },
reminders: {
view_own: true,
@@ -470,6 +472,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
documents: {
view: true,
create: true,
edit: true,
send_for_signing: true,
upload_signed: true,
delete: false,
@@ -491,7 +494,7 @@ export function makeSalesAgentPermissions(): RolePermissions {
record_payment: false,
export: false,
},
files: { view: true, upload: true, delete: false, manage_folders: false },
files: { view: true, upload: true, edit: false, delete: false, manage_folders: false },
email: { view: true, send: true, configure_account: false },
reminders: {
view_own: true,
@@ -547,6 +550,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
documents: {
view: true,
create: true,
edit: true,
send_for_signing: true,
upload_signed: true,
delete: true,
@@ -568,7 +572,7 @@ export function makeSalesManagerPermissions(): RolePermissions {
record_payment: true,
export: true,
},
files: { view: true, upload: true, delete: true, manage_folders: true },
files: { view: true, upload: true, edit: true, delete: true, manage_folders: true },
email: { view: true, send: true, configure_account: false },
reminders: {
view_own: true,