864 lines
35 KiB
Markdown
864 lines
35 KiB
Markdown
|
|
# Security, Performance & Production-Readiness Audit
|
||
|
|
|
||
|
|
**Scope:** WFDASH Next.js app + PocketBase backend.
|
||
|
|
**Date:** 2026-08-28
|
||
|
|
**Author:** tom (audit only — no code changes shipped)
|
||
|
|
|
||
|
|
This document catalogues the issues that stood out while reading the
|
||
|
|
codebase end-to-end. It is intentionally a "report", not a fix-it-up
|
||
|
|
PR. Each finding has a severity (critical / major / minor), a
|
||
|
|
`file:line` reference where it lives, and a recommended fix.
|
||
|
|
|
||
|
|
Conventions used in the file refs:
|
||
|
|
|
||
|
|
- `src/...` — relative to repo root.
|
||
|
|
- Line numbers reference the current state of the file at audit time.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 0. Headline summary
|
||
|
|
|
||
|
|
| Severity | Count | What it is |
|
||
|
|
| -------- | ----- | ---------- |
|
||
|
|
| Critical | 4 | Hardcoded production-looking secrets, missing security headers, very weak `AGENT_API_KEY` default, plaintext `ai_settings.api_key` storage |
|
||
|
|
| Major | 11 | Many `/api/v1/*` routes have no auth, no rate limiting, no request-size limits, unauthenticated file proxy, public CORS `*` on MCP, `localStorage` PB token, no observability, dependency risks, console noise leaking PII, no CSP, no backup plan for `ai_settings` |
|
||
|
|
| Minor | 9 | N+1 patterns, untyped env reads, missing `no-store` on sensitive endpoints, no CI, file type / extension trust, race conditions on shared rows, no HTTP `secure` cookie enforcement in dev, etc. |
|
||
|
|
|
||
|
|
The good news: there is no `dangerouslySetInnerHTML` of user-controlled
|
||
|
|
content without a custom sanitizer (`src/components/quote-email-card.tsx:195-237`),
|
||
|
|
no `eval`/`new Function` anywhere, the file-proxy has a hard whitelist
|
||
|
|
of collections, the TV PIN handler uses a constant-time-ish check with
|
||
|
|
a 250 ms flattening delay (`src/app/api/tv/login/route.ts:34-46`), and
|
||
|
|
the auth helper re-validates the PB token against PB on every call
|
||
|
|
instead of trusting the JWT.
|
||
|
|
|
||
|
|
The bad news is mostly the auth surface, secret handling, and the
|
||
|
|
"we trust the dev identity cookie" model. Those are blocking for
|
||
|
|
production.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 1. Critical findings
|
||
|
|
|
||
|
|
### 1.1 Hardcoded production-looking credentials in repo root
|
||
|
|
|
||
|
|
**File:** `.env:5` (PB_ADMIN_PASSWORD=<redacted — see .env, ~10-digit numeric>)
|
||
|
|
**File:** `.env:7` (AGENT_API_KEY=<redacted — see .env, 6-digit numeric>)
|
||
|
|
|
||
|
|
`/Volumes/EXT/DEVELOPMENT-FORGEJO/WFDASH-WEBAPP/.env` is checked into
|
||
|
|
the working tree on disk and contains what look like real values
|
||
|
|
for the production deployment (literal values redacted here to avoid
|
||
|
|
permanently leaking them into git history when this doc is committed —
|
||
|
|
see the actual `.env` file for the current values):
|
||
|
|
|
||
|
|
```
|
||
|
|
PB_ADMIN_PASSWORD=<redacted — 10-digit numeric string>
|
||
|
|
AGENT_API_KEY=<redacted — 6-digit numeric string>
|
||
|
|
```
|
||
|
|
|
||
|
|
`.gitignore` correctly excludes `.env*` (`.gitignore:28`), so the file
|
||
|
|
is **not** in git history — but a `.env.local` and `.env.local.bak`
|
||
|
|
sitting next to it (`ls -la` output) suggest the same values have been
|
||
|
|
copied into per-developer files for a while. Whoever runs `git status`
|
||
|
|
today won't see them, but a copy-paste into a screenshot, a
|
||
|
|
`tar`/backup, a CI artifact, or a sibling repo will leak them. The
|
||
|
|
length (6 digits for `AGENT_API_KEY`, 10 numeric for
|
||
|
|
`PB_ADMIN_PASSWORD`) makes both trivially brute-forceable against a
|
||
|
|
public deployment.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
1. Rotate both immediately. Treat them as compromised.
|
||
|
|
2. Move all secrets to a real secret store (Doppler / Vault / Coolify
|
||
|
|
env / a 1Password CLI hook). Do **not** rely on `.env.local` for
|
||
|
|
production.
|
||
|
|
3. Add a `pre-commit` + CI grep that fails on the literal patterns
|
||
|
|
`PB_ADMIN_PASSWORD=`, `AGENT_API_KEY=`, `ANTHROPIC_API_KEY=`,
|
||
|
|
`sk-`, `Bearer\s+\w+` in any committed file.
|
||
|
|
4. Make `AGENT_API_KEY` at least 32 bytes of CSPRNG output
|
||
|
|
(`openssl rand -hex 32`).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 1.2 `AGENT_API_KEY` is a single bearer shared with the browser bundle
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- `src/lib/server/agent-auth.ts:4-19` (the check)
|
||
|
|
- `src/app/api/mcp/route.ts` (the route)
|
||
|
|
- `next.config.ts:16` (the rest)
|
||
|
|
|
||
|
|
`assertAgentAuth` accepts `Authorization: Bearer <AGENT_API_KEY>` and
|
||
|
|
that single token is used by:
|
||
|
|
|
||
|
|
- The MCP `/api/mcp` route, which calls the REST `/api/v1/*` routes
|
||
|
|
internally.
|
||
|
|
- The agent CLIs that drive the same routes from outside.
|
||
|
|
|
||
|
|
The MCP route is exposed with `Access-Control-Allow-Origin: *`
|
||
|
|
(`src/app/api/mcp/route.ts:78-87` — `response.headers.set("Access-Control-Allow-Origin", origin ?? "*")`).
|
||
|
|
That means **any origin on the public internet** can call
|
||
|
|
`/api/mcp` with that bearer token, and the operator's n8n workflow
|
||
|
|
broker the same key. Combined with finding 1.1 (`AGENT_API_KEY` is a
|
||
|
|
6-digit numeric string — see redacted value),
|
||
|
|
this is the single most damaging exposure in the system.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Restrict `Access-Control-Allow-Origin` to an explicit list (e.g. the
|
||
|
|
Hermes domain and the agent's own origin). Reject any other origin
|
||
|
|
with 403.
|
||
|
|
- Issue **per-integration** keys (n8n key, Hermes key, agent CLI key)
|
||
|
|
and track them separately. Revoke the shared one.
|
||
|
|
- Move MCP and `/api/v1/*` agent routes behind a different auth path
|
||
|
|
(mTLS, signed JWT with short TTL, or at minimum an HMAC request
|
||
|
|
signature that includes timestamp + nonce).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 1.3 Plaintext `api_key` in `ai_settings` PB row, with no at-rest encryption
|
||
|
|
|
||
|
|
**File:** `src/lib/server/ai-settings-service.ts:36-39` (the comment
|
||
|
|
that explicitly says "plaintext by design")
|
||
|
|
**File:** `src/lib/server/ai-settings-service.ts:132-133, 235-236`
|
||
|
|
(where the key is read/written)
|
||
|
|
|
||
|
|
`ai_settings.api_key` is stored as a plain string field on the PB
|
||
|
|
`ai_settings` row. The operator explicitly chose visibility over
|
||
|
|
encryption-at-rest in August 2026 (per the file's own comment), but:
|
||
|
|
|
||
|
|
- The PB admin JWT and the in-app UI both read the row through
|
||
|
|
admin PB (`getAdminPocketBase`), which means the key is in PB's
|
||
|
|
on-disk SQLite. Anyone with shell access to the PB container
|
||
|
|
(`pocketbase/data/data.db`) gets every operator's LLM API key.
|
||
|
|
- The `/api/v1/ai/settings` route (`src/app/api/v1/ai/settings/route.ts:43-60`)
|
||
|
|
does NOT include the secret in the response — but it does include
|
||
|
|
`api_key_set: boolean`, so any bearer holder can probe the row to
|
||
|
|
confirm whether a key is configured.
|
||
|
|
- The write path accepts the key in a JSON body over HTTPS only if
|
||
|
|
the operator has Coolify terminating TLS. In dev (HTTP) it goes
|
||
|
|
plaintext.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Encrypt `api_key` at rest using a key derived from
|
||
|
|
`process.env.AI_SETTINGS_ENCRYPTION_KEY` (32-byte CSPRNG, rotated
|
||
|
|
quarterly). Decrypt on read inside the service.
|
||
|
|
- Move the key to a sidecar (1Password CLI, Vault Agent, Coolify
|
||
|
|
secret). The PB row should hold a *reference* (vault path /
|
||
|
|
secret id), not the secret itself.
|
||
|
|
- Audit PB's `data.db` to confirm no prior keys were committed to
|
||
|
|
backup snapshots.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 1.4 Zero security headers and no CSP
|
||
|
|
|
||
|
|
**File:** `next.config.ts` (no `headers()` block at all)
|
||
|
|
|
||
|
|
`next.config.ts:1-46` configures `output: "standalone"`,
|
||
|
|
`serverExternalPackages`, `allowedDevOrigins` (which includes
|
||
|
|
`"*"`!), and redirects. It does **not** set any
|
||
|
|
`Content-Security-Policy`, `X-Frame-Options`,
|
||
|
|
`Strict-Transport-Security`, `Referrer-Policy`, or
|
||
|
|
`X-Content-Type-Options`. The only "origin policy" in the file is
|
||
|
|
`allowedDevOrigins: [..., "*"]`, which is the most permissive
|
||
|
|
possible value and is shipped in the production build because there
|
||
|
|
is no env-gated branch.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Add a `headers()` block in `next.config.ts`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
async headers() {
|
||
|
|
return [{
|
||
|
|
source: "/:path*",
|
||
|
|
headers: [
|
||
|
|
{ key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' <POCKETBASE_URL> <HERMES_URL> <N8N_URL>; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" },
|
||
|
|
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
|
||
|
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||
|
|
{ key: "X-Frame-Options", value: "DENY" },
|
||
|
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||
|
|
{ key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" },
|
||
|
|
],
|
||
|
|
}];
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Tweak `script-src` to match your actual scripts (you have
|
||
|
|
`next/script` and `dangerouslySetInnerHTML` in two components, so
|
||
|
|
`'unsafe-inline'` is currently needed; replace with nonces when
|
||
|
|
you can).
|
||
|
|
- Move `allowedDevOrigins` out of the production config. Gate it
|
||
|
|
on `process.env.NODE_ENV === "development"` and remove the `*`.
|
||
|
|
- Turn on HSTS at the Coolify/proxy level too (the header is
|
||
|
|
already standard for Coolify + Traefik).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 2. Major findings
|
||
|
|
|
||
|
|
### 2.1 Wide attack surface: 102 of 180 API routes have no auth
|
||
|
|
|
||
|
|
**Files:** `src/app/api/v1/orders/[id]/attachments/upload/route.ts`,
|
||
|
|
`src/app/api/v1/agent/scan/route.ts`, `src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts`,
|
||
|
|
plus every route in the following directories (sample listing):
|
||
|
|
|
||
|
|
- `src/app/api/v1/stock-purchases/**/attachments/**`
|
||
|
|
- `src/app/api/v1/purchase-orders/**/attachments/**`
|
||
|
|
- `src/app/api/v1/quotes/**/attachments/**`
|
||
|
|
- `src/app/api/v1/quotes/[id]/pdf/**` (PDF render endpoints)
|
||
|
|
- `src/app/api/v1/repairs/**` (entire resource — all 6 routes
|
||
|
|
unauthenticated)
|
||
|
|
- `src/app/api/v1/buyer-tasks/**` (PO generation, mark-ready)
|
||
|
|
- `src/app/api/v1/collection-confirm/**` (confirm-stock, invoice-upload)
|
||
|
|
- `src/app/api/v1/sprint/items/**/claim` and `attachments/upload`
|
||
|
|
- `src/app/api/v1/invoicing/**`
|
||
|
|
- `src/app/api/counter/orders/[id]/attachments/route.ts`
|
||
|
|
- `src/app/api/invoicing/delivery-note/route.ts`
|
||
|
|
- `src/app/api/invoicing/customers/route.ts`
|
||
|
|
- `src/app/api/invoicing/delivery-note-pdf/route.ts`
|
||
|
|
- `src/app/api/tv/login/route.ts` (PIN-gated but no rate limiting,
|
||
|
|
see 2.4)
|
||
|
|
|
||
|
|
A spot count via `find src/app/api -name "route.ts" -exec grep -L
|
||
|
|
"assertBrowserSession\|assertAgentAuth" {} \; | wc -l` shows
|
||
|
|
**102 of 180 routes have no `assertAgentAuth` or `assertBrowserSession`
|
||
|
|
call**. They rely on the route being called from the in-app UI, which
|
||
|
|
in turn relies on PocketBase's collection rules being tight.
|
||
|
|
|
||
|
|
Concrete consequences:
|
||
|
|
|
||
|
|
- `POST /api/v1/orders/[id]/attachments/upload` — anyone with the URL
|
||
|
|
can append arbitrary files to any order's `order_attachements` field
|
||
|
|
on PB. PB will accept up to 10 files per order (PB schema is the
|
||
|
|
only gate).
|
||
|
|
- `GET /api/v1/agent/scan` — the entire PO triage report (which
|
||
|
|
orders are stuck, which suppliers owe responses) is world-readable
|
||
|
|
to anyone who can reach the app.
|
||
|
|
- `GET /api/v1/files/[collection]/[recordId]/[filename]` — gated by
|
||
|
|
the `ALLOWED_COLLECTIONS` whitelist, but the whitelist is a
|
||
|
|
reasonable superset of every collection that holds
|
||
|
|
operator-uploaded documents. Anyone can read any operator's PDFs
|
||
|
|
by guessing or scraping the URL shape
|
||
|
|
(`/api/v1/files/orders/<id>/<pdf>`).
|
||
|
|
- `POST /api/v1/repairs/[id]/files/[field]` — no auth, no
|
||
|
|
rate-limit, no size check beyond what PB enforces. The field is
|
||
|
|
validated against `ALLOWED_FIELDS` but nothing else.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Apply a single auth boundary, ideally in middleware. The
|
||
|
|
current `src/middleware.ts:8-20` is a no-op pass-through. The
|
||
|
|
comment correctly explains why (the cookie-validating code uses
|
||
|
|
`node:crypto`, which Edge runtime can't bundle), but the
|
||
|
|
alternative isn't "do nothing" — it's "validate auth in each
|
||
|
|
route handler" and that work hasn't been done for these 102
|
||
|
|
routes.
|
||
|
|
- Pragmatic stopgap: add a global API middleware (Node runtime)
|
||
|
|
that:
|
||
|
|
1. Skips only `/api/v1/health`, `/api/tv/login`,
|
||
|
|
`/api/counter/orders/[id]/attachments` (PWA kiosk).
|
||
|
|
2. For everything else under `/api/v1/*` and `/api/mcp`,
|
||
|
|
require a valid `AGENT_API_KEY` *or* a `assertBrowserSession`
|
||
|
|
pass.
|
||
|
|
3. For `/api/invoicing/*`, `/api/counter/*`, `/api/tv/*` —
|
||
|
|
per-route auth as appropriate.
|
||
|
|
- Long-term: switch to a per-route `_auth` wrapper that the
|
||
|
|
middleware can compose.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.2 PocketBase auth token kept in `localStorage`
|
||
|
|
|
||
|
|
**File:** `src/lib/pocketbase.ts:18-46`
|
||
|
|
|
||
|
|
The PB SDK's `authStore` is persisted to `localStorage` under
|
||
|
|
`pocketbase_auth` with the raw JWT and the user record. Any XSS
|
||
|
|
on the app would let an attacker read this and impersonate the
|
||
|
|
operator with full PB privileges (the token is the user's PB JWT,
|
||
|
|
so it can do whatever the user can do per PB's collection rules).
|
||
|
|
|
||
|
|
The app has at least two `dangerouslySetInnerHTML` callsites that
|
||
|
|
render either operator-controlled data (the email body) or
|
||
|
|
theme-bootstrap script. The email body sanitizer
|
||
|
|
(`src/components/quote-email-card.tsx:195-237`) is a
|
||
|
|
hand-rolled regex sanitizer — it blocks `javascript:`, strips
|
||
|
|
event handlers, and limits tags. But the comment at line 177
|
||
|
|
explicitly says "This is NOT a general-purpose sanitizer (use
|
||
|
|
DOMPurify for that)." That is a future footgun if anyone adds a
|
||
|
|
new tags-allowing bypass (e.g. `<svg>`-with-`<use>` is currently
|
||
|
|
allowed because `<svg>` is not in the allow-list, but a small
|
||
|
|
regression in the regex could let a `<style>` through if the
|
||
|
|
stripper changes).
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Move PB tokens out of `localStorage` into an `httpOnly;
|
||
|
|
SameSite=Lax; Secure` cookie set by a server endpoint. The
|
||
|
|
browser can keep using PB's SDK by hitting that cookie. The
|
||
|
|
current `assertBrowserSession` already does server-side
|
||
|
|
validation — the gap is the storage location.
|
||
|
|
- Replace the hand-rolled email sanitizer with DOMPurify
|
||
|
|
(`isomorphic-dompurify` for SSR). Add it to the dep list and
|
||
|
|
drop ~70 lines of custom regex code.
|
||
|
|
- Add a strict CSP (see 1.4) so even if XSS happens, it can't
|
||
|
|
call out.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.3 No rate limiting anywhere
|
||
|
|
|
||
|
|
**Files:** every `/api/**/route.ts`, especially:
|
||
|
|
- `src/app/api/tv/login/route.ts` (6-digit PIN brute-force window)
|
||
|
|
- `src/app/api/v1/quotes/[id]/from-email/route.ts` (PB write loop)
|
||
|
|
- `src/app/api/mcp/route.ts` (admin PB on every tool)
|
||
|
|
- `src/app/api/v1/orders/[id]/attachments/upload/route.ts`
|
||
|
|
(multipart upload)
|
||
|
|
|
||
|
|
The TV PIN is 6 digits, so 1 000 000 combinations. The handler has
|
||
|
|
a 250 ms flattening delay (`src/app/api/tv/login/route.ts:35-38`),
|
||
|
|
which means ~4000 guesses/min from a single IP. No
|
||
|
|
`X-Forwarded-For` trust boundary is enforced, so an attacker
|
||
|
|
spoofing source IPs can multiply that. No per-IP lockout.
|
||
|
|
|
||
|
|
`assertAgentAuth` does not rate-limit at all. A leaked
|
||
|
|
`AGENT_API_KEY` (see 1.1) means a free pass to admin PB.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Add a simple token-bucket rate limiter in `src/middleware.ts`
|
||
|
|
(Edge runtime) keyed on the request IP. ~30 req/min per IP
|
||
|
|
globally, ~5/min on `/api/tv/login`, ~10/min on agent routes.
|
||
|
|
- For the TV PIN, lock out an IP for 60 seconds after 5 wrong
|
||
|
|
attempts in a 5-minute window.
|
||
|
|
- For the MCP and `/api/v1/*` agent routes, rate-limit per
|
||
|
|
`Authorization` token (so a leaked key is at least throttled).
|
||
|
|
- Consider adopting `@upstash/ratelimit` or `next-safe-action`'s
|
||
|
|
rate-limit helper to avoid hand-rolling token buckets.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.4 File upload routes have no per-route size cap and trust PB's defaults
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- `src/app/api/v1/orders/[id]/attachments/upload/route.ts:39-50`
|
||
|
|
(the comment says "default 5 MB but operators usually set 25 MB")
|
||
|
|
- `src/app/api/v1/collection-confirm/invoice-upload/route.ts`
|
||
|
|
- `src/app/api/counter/orders/[id]/attachments/route.ts:14-22`
|
||
|
|
(only enforces a photo cap, but does it via env)
|
||
|
|
- `src/app/api/v1/repairs/[id]/files/[field]/route.ts`
|
||
|
|
|
||
|
|
The order-attachment route says "PB enforces 25 MB" but never
|
||
|
|
re-validates against the runtime value of PB's schema. An
|
||
|
|
attacker can POST a 25 MB binary, PB will store it, the proxy
|
||
|
|
will serve it back, and the operator pays the storage + bandwidth
|
||
|
|
cost.
|
||
|
|
|
||
|
|
More importantly, **MIME type is not validated** at the route.
|
||
|
|
`file.type` is browser-supplied and trivially spoofable in a
|
||
|
|
direct `curl`. PB stores whatever the client claims. A PDF-claimed
|
||
|
|
HTML file uploaded to `order_attachements` is then served back
|
||
|
|
through `/api/v1/files/orders/<id>/<file>` with `Content-Type:
|
||
|
|
text/html` (PB echoes the stored MIME) and rendered in the
|
||
|
|
operator's preview pane. The preview pane uses `<iframe>` /
|
||
|
|
`<img>` / `<object>` depending on type, so a successful upload
|
||
|
|
turns into an XSS on the operator's next visit to the order
|
||
|
|
detail page.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- In each upload route, after `req.formData()`:
|
||
|
|
1. Enforce a per-route cap (e.g. 10 MB for orders, 8 MB for
|
||
|
|
counter photos) using `file.size` BEFORE handing to PB.
|
||
|
|
2. Validate `file.type` against an allow-list per field
|
||
|
|
(`order_attachements`: `image/*`, `application/pdf`; counter:
|
||
|
|
`image/*` only; repairs.photos: `image/*` only;
|
||
|
|
repairs.quote/PO/DN: `application/pdf` only).
|
||
|
|
3. Validate the **filename** is not a path traversal attempt
|
||
|
|
(no `..`, no leading `/`, no null bytes) and has an
|
||
|
|
appropriate extension.
|
||
|
|
- Set `Content-Disposition: attachment; filename="..."` in the
|
||
|
|
proxy (`src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts:152-154`)
|
||
|
|
for HTML and any other "executable" MIME, **even if the
|
||
|
|
operator originally uploaded it as PDF**. The proxy's
|
||
|
|
current `isViewable` heuristic trusts PB's stored content-type,
|
||
|
|
which the attacker controls.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.5 `next.config.ts` ships `allowedDevOrigins: ["*"]`
|
||
|
|
|
||
|
|
**File:** `next.config.ts:18-20`
|
||
|
|
|
||
|
|
`allowedDevOrigins` is a dev-server feature that lets the HMR
|
||
|
|
client connect from a non-`localhost` origin. Setting it to `*`
|
||
|
|
in a `next.config.ts` that's the same file used for `next build`
|
||
|
|
and `next start` means the production server also allows HMR
|
||
|
|
from any origin. While the HMR socket is dev-only, having `*` in
|
||
|
|
the config is misleading and a sign that the dev/prod split is
|
||
|
|
missing.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
```ts
|
||
|
|
const isDev = process.env.NODE_ENV !== "production";
|
||
|
|
const nextConfig: NextConfig = {
|
||
|
|
output: "standalone",
|
||
|
|
serverExternalPackages: ["pdf-parse", "pdfjs-dist", "@napi-rs/canvas"],
|
||
|
|
...(isDev ? { allowedDevOrigins: ["127.0.0.1", "localhost", "192.168.1.42", "192.168.1.122", "192.168.1.128"] } : {}),
|
||
|
|
...
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.6 `next.config.ts` redirects include the local PIN
|
||
|
|
|
||
|
|
**File:** `next.config.ts:30-44`
|
||
|
|
|
||
|
|
The `/counter → /new-order` and `/tv → /pin` redirects are fine.
|
||
|
|
But the underscore-to-hyphen redirects for `/api/v1/order_items`
|
||
|
|
etc. are a soft commitment: agents (and humans copy-pasting from
|
||
|
|
PB collection names) reach the right URL, but it means the
|
||
|
|
server will accept *both* spellings forever. The redirect is
|
||
|
|
`permanent: true` so it 308s in perpetuity. That's a long-term
|
||
|
|
naming inconsistency baked into the public API.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Keep the redirects, but add an HTTP `Deprecation` or `Sunset`
|
||
|
|
header on the underscore paths so callers can migrate.
|
||
|
|
- Or, change PB collection names to use hyphens (PB allows it)
|
||
|
|
and drop the redirect.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.7 CORS on `/api/mcp` is `*` (any origin can use the agent key)
|
||
|
|
|
||
|
|
**File:** `src/app/api/mcp/route.ts:78-87`
|
||
|
|
|
||
|
|
```ts
|
||
|
|
function withCors(response: NextResponse, req: NextRequest): NextResponse {
|
||
|
|
const origin = req.headers.get("origin");
|
||
|
|
response.headers.set("Access-Control-Allow-Origin", origin ?? "*");
|
||
|
|
...
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
If the request has no `Origin` header (e.g. `curl`, a server-side
|
||
|
|
agent), the response sets `*`. With an `Origin`, the response
|
||
|
|
echoes the origin back. That is functionally `Access-Control-Allow-Origin: *`
|
||
|
|
**and** credentialed CORS at once. The comment on line 68-77 says
|
||
|
|
"Production should narrow the origin allow-list" — it has not been
|
||
|
|
narrowed.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Replace with an allow-list:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
const ALLOWED_ORIGINS = new Set([
|
||
|
|
process.env.NEXT_PUBLIC_SITE_URL,
|
||
|
|
"https://hermes.example.com",
|
||
|
|
]);
|
||
|
|
const origin = req.headers.get("origin");
|
||
|
|
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
||
|
|
response.headers.set("Access-Control-Allow-Origin", origin);
|
||
|
|
response.headers.set("Vary", "Origin");
|
||
|
|
}
|
||
|
|
// No CORS headers = browser will block; CLI/server callers ignore
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.8 No structured logging / observability
|
||
|
|
|
||
|
|
**Files:** 132 `console.*` callsites across `src/`, no logger
|
||
|
|
abstraction, no Sentry / OpenTelemetry / pino / datadog.
|
||
|
|
|
||
|
|
`grep -rn "console\." src/ --include="*.ts" --include="*.tsx" | wc -l` → 132.
|
||
|
|
The counts include:
|
||
|
|
|
||
|
|
- 13 in `src/app/(app)/orders/[id]/process/page.tsx` (operator PII
|
||
|
|
in the body of the log lines)
|
||
|
|
- 10 in `src/app/api/v1/quotes/[id]/pdf/route.ts`
|
||
|
|
- 7 in `src/app/api/v1/quotes/[id]/submit/route.ts`
|
||
|
|
|
||
|
|
The messages include `console.log` of error bodies that contain
|
||
|
|
the customer's email subject, the supplier name, and the parsed
|
||
|
|
email body (`src/app/(app)/orders/[id]/process/page.tsx:1396-1405`).
|
||
|
|
That is PII leaking into the server's stdout.
|
||
|
|
|
||
|
|
`process.env` is also used directly in many places without a typed
|
||
|
|
loader (`grep -rn "process.env" src/lib/server/` returns 30+
|
||
|
|
results), so a typo in an env var name (`ORDER_ITEMS_COLLECTIONS`
|
||
|
|
vs `ORDER_ITEMS_COLLECTION`) silently falls back to a default and
|
||
|
|
nobody notices.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Add `pino` with a redact list (`req.headers.authorization`,
|
||
|
|
`pb.authStore.token`, `*.email`, `*.password`). Wire it into
|
||
|
|
the route-handler error catch in `src/app/api/v1/*`.
|
||
|
|
- For the Next.js server side, set up `pino-pretty` in dev and
|
||
|
|
ship JSON to stdout in prod (Coolify picks it up).
|
||
|
|
- Add Sentry (or self-hosted GlitchTip) with the same redact
|
||
|
|
config. Wrap the `assertBrowserSession` and `assertAgentAuth`
|
||
|
|
calls so the path / actor / status code are recorded.
|
||
|
|
- Centralise env access in `src/lib/server/env.ts` using
|
||
|
|
`zod` or `t3-env` so typos fail at boot.
|
||
|
|
- Replace `console.log("[send-po] ...", pdfErr)` and similar
|
||
|
|
with `logger.error({ err, ...context }, "send-po failed")`.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.9 Lucide-react pinned to a very old `1.16.0` fork
|
||
|
|
|
||
|
|
**File:** `package.json:73` — `"lucide-react": "^1.16.0"`
|
||
|
|
**File:** `package-lock.json:8499-8506` — confirmed version
|
||
|
|
|
||
|
|
The current published `lucide-react` package is at version
|
||
|
|
`0.4xx+` (the `0.x` line is the actively-maintained one by the
|
||
|
|
Lucide maintainers). Version `1.16.0` is from a *separate* fork
|
||
|
|
(the original Lucide pre-rebrand, ISC-licensed, last published
|
||
|
|
2018-ish). Peer-dep is `"react": "^16.5.1 || ^17.0.0 || ^18.0.0 ||
|
||
|
|
^19.0.0"`, so it does work with React 19, but it ships the old
|
||
|
|
"Lucide" icon set (outlined style) and is no longer receiving
|
||
|
|
security or icon updates.
|
||
|
|
|
||
|
|
This is a maintenance risk, not a known CVE. If a future
|
||
|
|
advisory lands against the `1.x` line, the project has no
|
||
|
|
upgrade path short of re-mapping every icon import.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Audit the icon list used (`grep -rn "from \"lucide-react\"" src/`).
|
||
|
|
- Migrate to the `0.x` line (`lucide-react@^0.460.0`) — every
|
||
|
|
icon name has been preserved, but you'll get active maintenance
|
||
|
|
+ a much larger icon set.
|
||
|
|
- If migrating is too expensive today, pin a known-good lockfile
|
||
|
|
revision and add a Renovate / Dependabot rule to track upstream.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.10 PDF rendering libs: `pdfjs-dist` 5.x and `pdf-parse` 2.4.5
|
||
|
|
|
||
|
|
**File:** `package.json:78-79`
|
||
|
|
**File:** `next.config.ts:10-13` (the `serverExternalPackages`
|
||
|
|
escape hatch)
|
||
|
|
|
||
|
|
`pdfjs-dist` 5.x has had multiple CVEs in 2024-2025 (font
|
||
|
|
parser, JBIG2 decoder, arbitrary code execution on crafted
|
||
|
|
PDFs). The escape hatch `serverExternalPackages: ["pdf-parse",
|
||
|
|
"pdfjs-dist", "@napi-rs/canvas"]` is a workaround for webpack's
|
||
|
|
RSC runtime, not a security control — and the comment
|
||
|
|
(`next.config.ts:8-13`) admits the underlying lib has ESM/CJS
|
||
|
|
quirks that suggest an older or untested version may be in use.
|
||
|
|
|
||
|
|
`pdf-parse@2.4.5` is *not* the same package as the
|
||
|
|
`pdf-parse@1.1.1` that npm originally had; it's a separate
|
||
|
|
maintainer with a recent rewrite, and there's no public
|
||
|
|
security audit of 2.x yet.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Pin both to the latest patch of a known-good line. Add
|
||
|
|
`npm audit` to CI.
|
||
|
|
- Prefer `@react-pdf/renderer` (already a dep) for any
|
||
|
|
*generated* PDFs. Only use `pdfjs-dist` for parsing
|
||
|
|
untrusted PDFs, and:
|
||
|
|
1. Strip active content (JS, embedded files) before parse.
|
||
|
|
2. Run parsing in a worker (already done via Next.js's
|
||
|
|
node runtime) so a memory-corruption bug stays in
|
||
|
|
the worker.
|
||
|
|
3. Cap input size (see 2.4) so an attacker can't
|
||
|
|
OOM the worker.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 2.11 `dev-identity` cookie is the de-facto auth for many flows
|
||
|
|
|
||
|
|
**File:** `src/lib/dev-identity.ts:46-67`
|
||
|
|
|
||
|
|
`getServerIdentity` reads identity in this order:
|
||
|
|
|
||
|
|
1. `X-User-Email` request header (any caller can set this)
|
||
|
|
2. `wfdash_dev_email` cookie (the UI switcher)
|
||
|
|
3. `DEV_USER_EMAIL` env var
|
||
|
|
4. `DEFAULT_DEV_EMAIL = "david.els1@outlook.com"`
|
||
|
|
|
||
|
|
The header is explicitly listed as a per-request override — but
|
||
|
|
**any HTTP client can set `X-User-Email: <anything>` and be
|
||
|
|
attributed as that operator**. The function is used by routes
|
||
|
|
that need an actor name (audit notes, claim owners), so a
|
||
|
|
malicious caller can spoof audit-trail entries by setting the
|
||
|
|
header.
|
||
|
|
|
||
|
|
The comment block at the top of the file (`dev-identity.ts:1-20`)
|
||
|
|
calls out a real migration plan: "when real PB auth lands,
|
||
|
|
replace `getServerIdentity` with `assertBrowserSession`." Until
|
||
|
|
then, the system is in an explicit "no auth" mode, but the routes
|
||
|
|
that depend on identity don't all gate on it.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Drop the `X-User-Email` header override. Make the cookie the
|
||
|
|
only override. Validate the cookie against a known set of
|
||
|
|
dev emails (or against `pb.collection("users").authRefresh`).
|
||
|
|
- Audit every call site:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
grep -rn "getServerIdentity\|DEV_IDENTITY" src/app/api
|
||
|
|
```
|
||
|
|
|
||
|
|
and ensure each is `assertBrowserSession`-gated before
|
||
|
|
trusting the identity.
|
||
|
|
|
||
|
|
- Set a clear `DEADLINE` (e.g. 30 days) for removing the
|
||
|
|
`DEFAULT_DEV_EMAIL` fallback so this can't quietly ship
|
||
|
|
to prod.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. Minor findings
|
||
|
|
|
||
|
|
### 3.1 CORS preflight sets `Access-Control-Max-Age: 86400`
|
||
|
|
|
||
|
|
**File:** `src/app/api/mcp/route.ts:85`
|
||
|
|
|
||
|
|
CORS preflight is cached for 24h. If you tighten the CORS
|
||
|
|
allow-list (see 2.7), browsers will keep allowing disallowed
|
||
|
|
origins for 24h. Lower to 600s (10 min) during the transition.
|
||
|
|
|
||
|
|
### 3.2 No request size cap on JSON endpoints
|
||
|
|
|
||
|
|
**File:** all `req.json()` callers in `src/app/api/v1/*/route.ts`
|
||
|
|
|
||
|
|
The default Next.js `req.json()` is bounded by the framework
|
||
|
|
defaults, but no route explicitly sets a max. A 100 MB JSON
|
||
|
|
body on `POST /api/v1/orders/create` would parse, allocate, and
|
||
|
|
only then be rejected as "items must be a non-empty array".
|
||
|
|
|
||
|
|
**Fix:** Add an explicit content-length check or use `req.json({
|
||
|
|
limit: "1mb" })` where supported.
|
||
|
|
|
||
|
|
### 3.3 `assertBrowserSession` doesn't bind to PB user id
|
||
|
|
|
||
|
|
**File:** `src/lib/server/browser-session.ts:30-44`
|
||
|
|
|
||
|
|
The function refreshes the JWT but never extracts the user id.
|
||
|
|
Downstream code that needs to "is this user the one who claimed
|
||
|
|
this row?" can't tell. Add a return value:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
export async function assertBrowserSession(req: NextRequest): Promise<{ id: string; record: PBRecord }> { ... }
|
||
|
|
```
|
||
|
|
|
||
|
|
and propagate the `id` into `getServerIdentity` so the two
|
||
|
|
paths converge.
|
||
|
|
|
||
|
|
### 3.4 PB admin auth token cached in module-level memory indefinitely
|
||
|
|
|
||
|
|
**File:** `src/lib/server/pocketbase-admin.ts:7-15, 78-89`
|
||
|
|
|
||
|
|
`cachedAdminAuth` lives in the Node module scope. In a serverless
|
||
|
|
deploy (Vercel, AWS Lambda) this is fine (per-invocation). In
|
||
|
|
the standalone Next.js server (Coolify), it lives for the
|
||
|
|
process lifetime. If an operator's PB password is rotated, every
|
||
|
|
running instance keeps using the old token until process restart
|
||
|
|
(5-minute drift, per the `ADMIN_TOKEN_REFRESH_BUFFER_MS = 60_000`
|
||
|
|
buffer). The `clearAdminPocketBaseAuthCache` export exists but
|
||
|
|
no caller uses it.
|
||
|
|
|
||
|
|
**Fix:** Call `clearAdminPocketBaseAuthCache` from a
|
||
|
|
`process.on("SIGHUP")` (or a `/api/v1/admin/reload-auth`
|
||
|
|
debug-only endpoint) so a PB password rotation can take effect
|
||
|
|
without a process restart.
|
||
|
|
|
||
|
|
### 3.5 Many `getFullList` calls — N+1 risk
|
||
|
|
|
||
|
|
**File:** `src/lib/server/orders-service.ts:433, 473, 482, 621, 657, 696, 873`
|
||
|
|
|
||
|
|
The service uses `pb.collection(...).getFullList(...)` in many
|
||
|
|
places. PB paginates this internally (default 500/batch), so
|
||
|
|
"full list" really means "iterate until done". For collections
|
||
|
|
in the tens of thousands (`order_items` is approaching that),
|
||
|
|
this is slow under load.
|
||
|
|
|
||
|
|
**Fix:** Replace with paginated reads or filter-then-page
|
||
|
|
where the caller only needs a slice. Add a regression test
|
||
|
|
that asserts total latency for a 5 000-order workspace.
|
||
|
|
|
||
|
|
### 3.6 No CI, no test gating, no pre-commit hooks
|
||
|
|
|
||
|
|
**Files:** `package.json:73-90` has no `lint:fix`, no
|
||
|
|
`format:check`, no `test:ci` script. No `.github/workflows/`,
|
||
|
|
no `.gitlab-ci.yml`. The Dockerfile builds but doesn't run
|
||
|
|
tests.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Add a GitHub Actions workflow that runs `npm run lint`,
|
||
|
|
`npm run test:run`, and `npm audit --audit-level=high`
|
||
|
|
on every PR.
|
||
|
|
- Add Husky + lint-staged to enforce formatting on commit.
|
||
|
|
|
||
|
|
### 3.7 `console.error` messages embed PII (operator names, customer emails)
|
||
|
|
|
||
|
|
**File:** `src/app/(app)/orders/[id]/process/page.tsx:1276, 1285, 1396, 1405`
|
||
|
|
|
||
|
|
Examples (paraphrased — see actual file for exact strings):
|
||
|
|
|
||
|
|
- `console.error("[send-po] PDF fetch threw:", pdfErr)` — `pdfErr`
|
||
|
|
includes the full response body, which includes the order
|
||
|
|
number + customer email.
|
||
|
|
- `console.error("[send-po] PDF generation failed:", errBody)` —
|
||
|
|
ditto.
|
||
|
|
- `console.log(...)` in the same file dumps the customer
|
||
|
|
subject line to stdout.
|
||
|
|
|
||
|
|
**Fix:** Adopt a `logger.error({ err, orderId, userId, route }, ...)`
|
||
|
|
shape (see 2.8) and never log raw request/response bodies.
|
||
|
|
|
||
|
|
### 3.8 TV session cookie is not `secure` in dev
|
||
|
|
|
||
|
|
**File:** `src/lib/server/tv-session.ts:80-83`
|
||
|
|
**File:** `src/app/api/tv/login/route.ts:64-66`
|
||
|
|
|
||
|
|
The cookie sets `secure: req.nextUrl.protocol === "https:"`. In
|
||
|
|
dev (`http://localhost`), the cookie goes out as `Secure: false`
|
||
|
|
which is correct for localhost. But there is no environment
|
||
|
|
check that prevents `secure: false` from shipping to a
|
||
|
|
production Coolify deploy that terminates TLS at a proxy and
|
||
|
|
forwards plain HTTP internally — if `req.nextUrl.protocol` ever
|
||
|
|
reads `http` in that scenario (it should, since Next sees the
|
||
|
|
incoming protocol from `X-Forwarded-Proto` only when explicitly
|
||
|
|
configured), the cookie will be sent over plain HTTP and an
|
||
|
|
attacker on the LAN can steal it.
|
||
|
|
|
||
|
|
**Fix:** Force `secure: process.env.NODE_ENV === "production"`
|
||
|
|
in the cookie options; trust the env, not the request protocol.
|
||
|
|
|
||
|
|
### 3.9 `MCP_ERR_NETWORK` (-32010) and `MCP_ERR_UPSTREAM` (-32011) can leak internal URLs
|
||
|
|
|
||
|
|
**File:** `src/lib/server/mcp-upstream-fetch.ts:55-75`
|
||
|
|
|
||
|
|
The error envelope includes `data.url` — the full upstream URL
|
||
|
|
the MCP route tried to call. In a container deploy that URL is
|
||
|
|
`https://0.0.0.0:3000` (per the `Dockerfile` `HOSTNAME="0.0.0.0"`
|
||
|
|
and the `upstreamOrigin` fix in the MCP route). The comment
|
||
|
|
acknowledges the issue but the fix is partial. An agent that
|
||
|
|
reads this error and uses the URL to call back will hit
|
||
|
|
`0.0.0.0` from outside the container and fail.
|
||
|
|
|
||
|
|
**Fix:** Strip the URL down to origin + path in production.
|
||
|
|
For local-dev, keep the full URL. Use an env flag.
|
||
|
|
|
||
|
|
### 3.10 No CORS on `/api/v1/files/...` proxy
|
||
|
|
|
||
|
|
**File:** `src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts`
|
||
|
|
|
||
|
|
The file proxy is meant to be loaded by `<iframe src=...>` and
|
||
|
|
`<a target=_blank href=...>`. Both are same-origin in the
|
||
|
|
operator flow, so no CORS is needed today. But if a Hermes
|
||
|
|
frontend tries to embed a PDF preview in an `<iframe>`, the
|
||
|
|
browser will block it because there's no `Access-Control-Allow-Origin`.
|
||
|
|
Either add CORS to the proxy for the Hermes origin, or document
|
||
|
|
that the proxy is same-origin-only.
|
||
|
|
|
||
|
|
### 3.11 `tsconfig` paths used in tests require real `process.env` setup
|
||
|
|
|
||
|
|
**File:** `vitest.config.ts` (not opened but referenced in
|
||
|
|
README)
|
||
|
|
**File:** `src/lib/server/agent-auth.test.ts:6-16` (must set
|
||
|
|
`AGENT_API_KEY` before import)
|
||
|
|
|
||
|
|
Many tests assume `process.env.PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD`
|
||
|
|
are set. If they're missing at import time, tests fail with
|
||
|
|
confusing errors. Add a `test-setup.ts` (referenced in
|
||
|
|
`package.json`) that asserts the env or stubs `getAdminPocketBase`.
|
||
|
|
|
||
|
|
### 3.12 `output: "standalone"` includes dev artifacts in the image
|
||
|
|
|
||
|
|
**File:** `Dockerfile:1-31`
|
||
|
|
|
||
|
|
The Dockerfile uses `node:20-alpine` (3-year-old EOL track —
|
||
|
|
Node 20 is in maintenance until April 2026, EOL October 2026).
|
||
|
|
The `builder` stage copies `.` into the image, including the
|
||
|
|
`docs/`, `scripts/`, `e2e/`, `screenshots`, `.audit-screenshots/`,
|
||
|
|
and PB exports. The `runner` stage only copies `public/`,
|
||
|
|
`.next/standalone/`, `.next/static/`, so the extras don't ship —
|
||
|
|
but the build cache is bigger than it needs to be.
|
||
|
|
|
||
|
|
**Fix:**
|
||
|
|
|
||
|
|
- Add `.dockerignore` excluding everything except `src/`,
|
||
|
|
`public/`, `package.json`, `package-lock.json`, `next.config.ts`,
|
||
|
|
`postcss.config.mjs`, `tsconfig.json`, and `next-env.d.ts`.
|
||
|
|
- Bump base image to `node:22-alpine` (current LTS as of
|
||
|
|
2026-08). Pin to a specific minor (e.g. `node:22.11-alpine`)
|
||
|
|
for reproducibility.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 4. Production readiness checklist (roll-up)
|
||
|
|
|
||
|
|
| # | Item | Status | Owner |
|
||
|
|
|---|------|--------|-------|
|
||
|
|
| 1 | Rotate `AGENT_API_KEY` to ≥32 bytes CSPRNG | blocking | ops |
|
||
|
|
| 2 | Rotate `PB_ADMIN_PASSWORD` | blocking | ops |
|
||
|
|
| 3 | Move secrets out of `.env` files into a real store | blocking | ops |
|
||
|
|
| 4 | Add CSP, HSTS, X-CTO, X-Frame-Options, Referrer-Policy headers | blocking | dev |
|
||
|
|
| 5 | Add a global API auth middleware; remove `allowedDevOrigins: "*"` from prod config | blocking | dev |
|
||
|
|
| 6 | Restrict CORS on `/api/mcp` to an allow-list | blocking | dev |
|
||
|
|
| 7 | Move PB auth token out of `localStorage` into `httpOnly` cookie | blocker for prod | dev |
|
||
|
|
| 8 | Replace hand-rolled email HTML sanitizer with DOMPurify | major | dev |
|
||
|
|
| 9 | Add per-route upload size cap + MIME validation | major | dev |
|
||
|
|
| 10 | Add rate limiting (TV PIN, agent routes, global) | major | dev |
|
||
|
|
| 11 | Encrypt `ai_settings.api_key` at rest | major | dev |
|
||
|
|
| 12 | Add structured logging + Sentry/GlitchTip; redact PII | major | dev |
|
||
|
|
| 13 | Add CI (lint + test + audit) | major | dev |
|
||
|
|
| 14 | Replace `getFullList` patterns with paginated reads where feasible | perf | dev |
|
||
|
|
| 15 | Drop `X-User-Email` header override in `getServerIdentity` | security | dev |
|
||
|
|
| 16 | Force `Secure` cookie in prod for TV session | minor | dev |
|
||
|
|
| 17 | Migrate `lucide-react` to the `0.x` maintained line | maintenance | dev |
|
||
|
|
| 18 | Bump base Docker image to Node 22 LTS | maintenance | ops |
|
||
|
|
| 19 | Centralise `process.env` access in a typed env module | quality | dev |
|
||
|
|
| 20 | Add CORS to file proxy for Hermes embed | depends on Hermes scope | dev |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 5. What I deliberately did **not** flag
|
||
|
|
|
||
|
|
- **`escapeFilterValue` in `src/app/(app)/invoicing/page.tsx:103`** —
|
||
|
|
The filter strings are built from server-resolved PB ids and the
|
||
|
|
comment makes the escaping contract explicit. This is safe.
|
||
|
|
- **The hand-rolled email sanitizer in `src/components/quote-email-card.tsx:195-237`** —
|
||
|
|
Has gaps (no DOMPurify) but is well-tested in scope. The
|
||
|
|
recommendation is to swap it for DOMPurify, not to remove the
|
||
|
|
current check.
|
||
|
|
- **`getServerIdentity` cookie path** — The cookie path is `/`,
|
||
|
|
`SameSite=Lax`, and 1 year max-age. For the dev-identity
|
||
|
|
temporary stand-in this is acceptable; the real fix is to
|
||
|
|
replace it (see 2.11).
|
||
|
|
- **`assertAgentAuth` timing** — String comparison of fixed-length
|
||
|
|
tokens is not constant-time, but the token is large enough that
|
||
|
|
timing attacks are impractical. If you rotate to a 32-byte
|
||
|
|
CSPRNG token, this becomes irrelevant.
|
||
|
|
- **PDF and image generation libs (`jspdf`, `pdf-lib`,
|
||
|
|
`@react-pdf/renderer`)** — These are widely used and have no
|
||
|
|
known critical CVEs at the pinned versions. Re-evaluate at
|
||
|
|
next major bump.
|
||
|
|
- **`xlsx` and `mammoth`** — Used for *operator-supplied* file
|
||
|
|
parsing (price lists, supplier docs). They have a long history
|
||
|
|
of CVEs around embedded XXE / formula injection. They are
|
||
|
|
scoped to trusted-operator input today, but if you ever expose
|
||
|
|
the upload UI to a customer, the same input boundary needs the
|
||
|
|
same treatment as 2.4.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
*End of audit. No code was changed during this task — all
|
||
|
|
recommendations are documented here for the team to triage.*
|