Skip to content

fix(security): shorten JWT lifetime to 24h + add web-UI CSP (#657)#709

Merged
frankbria merged 3 commits into
mainfrom
fix/657-jwt-lifetime-csp
Jun 21, 2026
Merged

fix(security): shorten JWT lifetime to 24h + add web-UI CSP (#657)#709
frankbria merged 3 commits into
mainfrom
fix/657-jwt-lifetime-csp

Conversation

@frankbria

@frankbria frankbria commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Closes #657

What

Defense-in-depth for the localStorage-stored auth JWT (release-readiness audit finding M3). Implements the acceptance criteria's "lifetime shortened + CSP added" branch rather than the invasive HttpOnly-cookie refactor (which would break the SSE/WS ?token= design and touch axios, both WS hooks, and the backend transport for a Phase-7/Future hardening item).

Changes

  • codeframe/auth/manager.pyJWT_LIFETIME_SECONDS default 604800 (7d) → 86400 (24h). Env override unchanged; existing tests read the constant dynamically so they're unaffected.
  • web-ui/security-headers.js (new) — CSP + hardening headers. connect-src is built at build time from the same NEXT_PUBLIC_API_URL/NEXT_PUBLIC_WS_URL the app dials, so it matches the real backend without hardcoding a deploy URL. img-src allows only the GitHub avatar host; object-src/base-uri/frame-ancestors/form-action locked down. Wired via next.config.js headers().
  • web-ui/__tests__/security-headers.test.js (new) — pins the exfil-relevant directives so a future edit can't silently widen them (e.g. to connect-src *).
  • CLAUDE.md — documents JWT_LIFETIME_SECONDS.

Acceptance criteria

Session token is not trivially readable by injected JS (HttpOnly cookie) or lifetime shortened + CSP added.

Lifetime shortened — default 7d → 24h (7× window reduction), env-overridable.
CSP added — contains XSS-based token exfiltration; connect-src/img-src/object-src block sending the token to an attacker origin.

Demo evidence (live, from npm run build && npm start)

$ curl -sI http://localhost:3939/login
HTTP 200                                  # page renders fine under the CSP
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';
  style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://avatars.githubusercontent.com;
  font-src 'self' data:; connect-src 'self' ws://localhost:8000; object-src 'none';
  base-uri 'self'; frame-ancestors 'none'; form-action 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin

JWT_LIFETIME_SECONDS default = 86400 seconds = 24h (was 604800 = 7d)

Tests

  • web-ui: 1024 jest tests pass (incl. 4 new); npm run build succeeds.
  • backend: tests/ui/test_v2_auth_enforcement.py + tests/api/ — 69 pass; ruff clean.

Known limitations

  • script-src keeps 'unsafe-inline'/'unsafe-eval' — the Next.js App Router needs them without a per-request nonce middleware (a much larger change). Exfil containment comes from connect-src/img-src/object-src, not script-src.
  • No refresh token (outside acceptance scope), so users re-login after 24h. The web UI already redirects to /login on 401.

Summary by CodeRabbit

  • New Features

    • Added hardened web UI security headers, including a tightened Content Security Policy (CSP) to reduce XSS exfiltration risk and prevent clickjacking.
  • Security Updates

    • JWT session tokens now expire after 24 hours (previously 7 days), so users must re-authenticate after expiry.
  • Documentation

    • Documented the session token lifetime change and related web UI token behavior.
  • Tests

    • Added automated coverage to verify CSP/security header outputs and expected connect-src behavior.

Defense-in-depth for the localStorage-stored auth JWT (audit finding M3).
Takes the acceptance criteria's "lifetime shortened + CSP added" branch
rather than the invasive HttpOnly-cookie refactor (which would break the
SSE/WS ?token= design).

- auth/manager.py: JWT_LIFETIME_SECONDS default 604800 (7d) -> 86400 (24h);
  env override unchanged. Tests read the constant dynamically.
- web-ui/security-headers.js: CSP + hardening headers. connect-src is built
  from the same build-time NEXT_PUBLIC_API_URL/WS_URL the app dials, so it
  matches the real backend; img-src allows only the GitHub avatar host;
  object-src/base-uri/frame-ancestors locked down. Wired via next.config.js
  headers().
- Test pins the exfil-relevant directives against silent widening.

Known ceiling: script-src keeps 'unsafe-inline'/'unsafe-eval' (Next.js App
Router needs them absent a nonce middleware); exfil containment comes from
connect-src/img-src/object-src. No refresh token, so re-login after 24h.
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bca0e55d-f570-4cea-a00c-33049e807ed8

📥 Commits

Reviewing files that changed from the base of the PR and between e4e1bdd and 1117cdd.

📒 Files selected for processing (1)
  • web-ui/__tests__/security-headers.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • web-ui/tests/security-headers.test.js

Walkthrough

Reduces the JWT default lifetime from 7 days to 24 hours via a changed JWT_LIFETIME_SECONDS default. Adds a new security-headers.js module to the web UI that generates a strict Content-Security-Policy and hardening headers, wired into next.config.js for all routes, with tests and CLAUDE.md documentation.

Changes

Defense-in-depth: JWT lifetime reduction and CSP hardening

Layer / File(s) Summary
JWT lifetime reduction and docs
codeframe/auth/manager.py, CLAUDE.md
JWT_LIFETIME_SECONDS default reduced from 604800 (7d) to 86400 (24h); CLAUDE.md documents the new default, the localStorage token context, and the no-refresh-token re-login requirement.
security-headers.js module
web-ui/security-headers.js
New dependency-free module adds buildConnectSrc (assembles connect-src from self, optional API/WS URLs, and a localhost WS fallback), buildCsp (full CSP with object-src 'none', frame-ancestors 'none', GitHub avatar allowlist, and unsafe-inline/unsafe-eval for Next.js App Router compatibility), and securityHeaders (returns CSP plus X-Content-Type-Options, X-Frame-Options, Referrer-Policy).
Next.js wiring and tests
web-ui/next.config.js, web-ui/__tests__/security-headers.test.js
next.config.js imports securityHeaders and applies it to all routes via a headers() hook. Jest suite validates CSP directive lockdowns from buildCsp, no wildcard fallback in buildConnectSrc, and required hardening header presence from securityHeaders.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 A token that lived for a week and a day
Now hops off to sleep after one — hooray!
A CSP fence around the warren so tight,
No sneaky JS shall exfiltrate at night.
object-src 'none' keeps the foxes at bay,
The rabbit sleeps soundly — secure all the way! 🌙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: shortening JWT lifetime to 24h and adding web-UI CSP, directly addressing the security hardening objectives in #657.
Linked Issues check ✅ Passed The PR fully implements both acceptance criteria from #657: shortens JWT lifetime from 7d to 24h and adds comprehensive Content-Security-Policy with connect-src, img-src, object-src, and hardening headers.
Out of Scope Changes check ✅ Passed All changes directly support the #657 objectives: JWT reduction, CSP implementation, test coverage, documentation, and Next.js configuration integration remain within scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/657-jwt-lifetime-csp

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Code Review for PR 709: JWT lifetime plus CSP hardening. Overall LGTM with minor feedback. The implementation is clean, well-scoped, and the tradeoffs are clearly documented. See full review in the PR thread.

@claude

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Code Review: PR 709 - JWT lifetime + CSP hardening

Overall: LGTM with minor feedback. The implementation is clean, well-scoped, and the tradeoffs are clearly documented. The acceptance criteria are met.


What this does well

  • Correct scope: stays in the lifetime-shortened + CSP-added branch, explicitly avoiding the invasive HttpOnly-cookie refactor. Good call given the SSE/WS token= constraint.
  • Build-time env binding in buildConnectSrc is the right pattern — connect-src stays consistent with actual backend URLs without hardcoding a deploy URL.
  • Tests pin the security surface: the never-falls-back-to-a-wildcard test is exactly the regression protection this kind of module needs.
  • buildCsp / buildConnectSrc are pure functions (env injectable), making them trivially testable without mocking process.env.

Issues

Minor — typo in comment (security-headers.js:44)

The comment reads // ponytail: — should be // NOTE: or // Caveat:. The content is clear and useful; this is just a cosmetic artifact.

Minor — missing Permissions-Policy header

The hardening set ships CSP, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy, but omits Permissions-Policy. Locking down browser features (camera, microphone, geolocation, payment) is a standard companion header. Not a blocker — outside acceptance criteria scope — but worth a follow-up ticket.

Minor — no Strict-Transport-Security

HSTS is absent. Fine for local-dev defaults, but for production self-hosted deployments it should be present. At minimum, worth documenting alongside the CSP note in CLAUDE.md. Not a blocker.


Test coverage

The 4 new tests cover the key security guarantees well:

  • object-src none: checked
  • base-uri self: checked
  • frame-ancestors none: checked
  • No wildcard fallback: checked
  • Hardening header key set: checked

One gap for a follow-up: form-action self is not explicitly asserted. Less critical than connect-src for the JWT exfil threat, but it is a meaningful directive and the test pattern already exists in the file.


Backend change

JWT_LIFETIME_SECONDS change is minimal and correct. The constant evaluates at module import time (existing behavior, unchanged). The 7x window reduction is meaningful defense-in-depth.


Summary

Area Status
Acceptance criteria met Yes
Code quality Clean, well-structured
Test coverage Good (minor gap on form-action)
Typo in comment Fix before merge (cosmetic)
Permissions-Policy Follow-up ticket
Strict-Transport-Security Follow-up / document in CLAUDE.md

The typo is the only thing worth fixing before merging; everything else is follow-up scope.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web-ui/__tests__/security-headers.test.js`:
- Around line 8-12: Convert the test file from CommonJS to TypeScript with ES6
imports. Rename security-headers.test.js to security-headers.test.ts, and
replace the CommonJS require() statement with an ES6 import statement to import
the functions buildCsp, buildConnectSrc, and securityHeaders from the
security-headers module. Ensure the import path and destructuring syntax match
the ES6 import patterns used in other test files within the project.

In `@web-ui/security-headers.js`:
- Around line 21-25: The buildConnectSrc function unconditionally adds
DEFAULT_WS_URL (which is localhost:8000) to the connect-src policy when wsUrl is
not explicitly provided, creating an unnecessary security risk in production by
exposing a local exfil destination. Fix this by modifying the line that adds
wsUrl to only include it when explicitly provided, removing the fallback to
DEFAULT_WS_URL. Apply the same fix to the similar pattern mentioned at lines
29-32 to ensure no default localhost addresses are shipped in production
security headers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4c49fd9e-c139-4bb2-b217-508722436667

📥 Commits

Reviewing files that changed from the base of the PR and between 245a502 and e4e1bdd.

📒 Files selected for processing (5)
  • CLAUDE.md
  • codeframe/auth/manager.py
  • web-ui/__tests__/security-headers.test.js
  • web-ui/next.config.js
  • web-ui/security-headers.js

Comment on lines +8 to +12
const {
buildCsp,
buildConnectSrc,
securityHeaders,
} = require('../security-headers');

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and verify the content at lines 8-12
cd web-ui && find . -name "security-headers.test.js" -type f

Repository: frankbria/codeframe

Length of output: 100


🏁 Script executed:

# Check the project structure and find eslint/typescript config
cd web-ui && ls -la && fd -e ".eslintrc*" -e "eslint.config.*" -e "tsconfig.json" | head -20

Repository: frankbria/codeframe

Length of output: 1490


🏁 Script executed:

# Search for `@typescript-eslint/no-require-imports` in config files
rg "no-require-imports" --type javascript --type json --type yaml

Repository: frankbria/codeframe

Length of output: 98


🏁 Script executed:

# Read the test file to verify the import statement
cat -n web-ui/__tests__/security-headers.test.js | head -20

Repository: frankbria/codeframe

Length of output: 906


🏁 Script executed:

# Check the eslint configuration
cat web-ui/eslint.config.mjs

Repository: frankbria/codeframe

Length of output: 979


🏁 Script executed:

# Search for no-require-imports configuration in all config files
rg "no-require-imports" web-ui/

Repository: frankbria/codeframe

Length of output: 45


🏁 Script executed:

# Check what files are being ignored in eslint config
cat web-ui/eslint.config.mjs | grep -A 5 "ignores"

Repository: frankbria/codeframe

Length of output: 244


🏁 Script executed:

# Check if the test file is using .js extension and might be excluded
file web-ui/__tests__/security-headers.test.js

Repository: frankbria/codeframe

Length of output: 108


🏁 Script executed:

# Search for typescript-eslint configurations and their defaults
rg "tseslint.configs" web-ui/eslint.config.mjs -A 2 -B 2

Repository: frankbria/codeframe

Length of output: 229


🏁 Script executed:

# Check if there are any other eslint configs or package.json eslint settings
cat web-ui/package.json | grep -A 10 "eslint"

Repository: frankbria/codeframe

Length of output: 1183


🏁 Script executed:

# Check the source file being imported
cat -n web-ui/security-headers.js | head -30

Repository: frankbria/codeframe

Length of output: 1460


🏁 Script executed:

# Check if there's a TypeScript version of the security headers file
ls -la web-ui/security-headers* | head -10

Repository: frankbria/codeframe

Length of output: 139


🏁 Script executed:

# Verify the exact eslint file patterns - look at the full config more carefully
cat web-ui/eslint.config.mjs

Repository: frankbria/codeframe

Length of output: 979


🏁 Script executed:

# Check if the __tests__ directory has any TypeScript files
find web-ui/__tests__ -type f -name "*.ts" -o -name "*.tsx" | head -10

Repository: frankbria/codeframe

Length of output: 607


🏁 Script executed:

# Verify: does the eslint actually run on .js test files by checking the default typescript-eslint recommended rules
rg "`@typescript-eslint/no-require-imports`" web-ui/ || echo "Rule not explicitly configured in repo"

Repository: frankbria/codeframe

Length of output: 102


🏁 Script executed:

# Check what the typescript-eslint default behavior is - look at package versions
grep -A 5 "typescript-eslint" web-ui/package.json

Repository: frankbria/codeframe

Length of output: 104


🏁 Script executed:

# Confirm: list all test files to see the pattern - are there .js test files?
find web-ui/__tests__ -type f \( -name "*.js" -o -name "*.ts" -o -name "*.tsx" \) | sort

Repository: frankbria/codeframe

Length of output: 2196


Convert this file to TypeScript (.test.ts) and use ES6 import syntax for consistency with the rest of the codebase.

The file currently uses CommonJS require() syntax, which is inconsistent with the project's other test files (all using .test.tsx or .test.ts). All other files in __tests__/ use ES6 imports and TypeScript. Converting this file to TypeScript and adopting ES6 import syntax would align it with the project's patterns. Example:

Suggested refactor
+import securityHeadersModule from '../security-headers';
+
+const { buildCsp, buildConnectSrc, securityHeaders } = securityHeadersModule;
🧰 Tools
🪛 GitHub Actions: Test Suite (Unit + E2E) / 6_Code Quality (Lint + Type Check).txt

[error] 12-12: ESLint (rule: @typescript-eslint/no-require-imports): A require() style import is forbidden.

🪛 GitHub Actions: Test Suite (Unit + E2E) / Code Quality (Lint + Type Check)

[error] 12-12: ESLint (@typescript-eslint/no-require-imports): A require() style import is forbidden.

🪛 GitHub Check: Code Quality (Lint + Type Check)

[failure] 12-12:
A require() style import is forbidden

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-ui/__tests__/security-headers.test.js` around lines 8 - 12, Convert the
test file from CommonJS to TypeScript with ES6 imports. Rename
security-headers.test.js to security-headers.test.ts, and replace the CommonJS
require() statement with an ES6 import statement to import the functions
buildCsp, buildConnectSrc, and securityHeaders from the security-headers module.
Ensure the import path and destructuring syntax match the ES6 import patterns
used in other test files within the project.

Sources: Linters/SAST tools, Pipeline failures

Comment on lines +21 to +25
function buildConnectSrc({ apiUrl, wsUrl } = {}) {
const sources = new Set(["'self'"]);
if (apiUrl) sources.add(apiUrl);
sources.add(wsUrl || DEFAULT_WS_URL);
return Array.from(sources).join(' ');

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid shipping localhost in connect-src for production defaults.

Line 24 currently adds ws://localhost:8000 whenever NEXT_PUBLIC_WS_URL is unset, and this policy is then applied globally. In production, that unnecessarily opens an extra exfil destination (any local listener) if XSS occurs.

Suggested hardening
 function buildConnectSrc({ apiUrl, wsUrl } = {}) {
   const sources = new Set(["'self'"]);
   if (apiUrl) sources.add(apiUrl);
-  sources.add(wsUrl || DEFAULT_WS_URL);
+  if (wsUrl) {
+    sources.add(wsUrl);
+  } else if (process.env.NODE_ENV !== 'production') {
+    sources.add(DEFAULT_WS_URL);
+  }
   return Array.from(sources).join(' ');
 }

Also applies to: 29-32

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-ui/security-headers.js` around lines 21 - 25, The buildConnectSrc
function unconditionally adds DEFAULT_WS_URL (which is localhost:8000) to the
connect-src policy when wsUrl is not explicitly provided, creating an
unnecessary security risk in production by exposing a local exfil destination.
Fix this by modifying the line that adds wsUrl to only include it when
explicitly provided, removing the fallback to DEFAULT_WS_URL. Apply the same fix
to the similar pattern mentioned at lines 29-32 to ensure no default localhost
addresses are shipped in production security headers.

@claude

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Follow-up review — latest commit (1117cdd)

Picking up from my previous review (summary table above). The latest commit touched only web-ui/__tests__/security-headers.test.js, so that is the main focus here.

Test file update

The test file now uses ES6 import syntax — that addresses the spirit of CodeRabbit's CommonJS-to-ES-module comment. Keeping the .js extension is fine; the Next.js Jest transform handles it without a rename to .ts. No issue here.

Still outstanding from prior review

Typo in security-headers.js line 38: // ponytail: is still present. This is the one cosmetic fix I called out before merge. Everything else (Permissions-Policy, HSTS) remains follow-up scope.

CodeRabbit's DEFAULT_WS_URL concern

CodeRabbit flagged that buildConnectSrc always adds ws://localhost:8000 when NEXT_PUBLIC_WS_URL is unset. The concern is valid: a production build that omits NEXT_PUBLIC_WS_URL ships a localhost origin in the CSP. However:

  • Not an exfiltration vector — localhost does not route from a remote client browser
  • Removing the fallback entirely would silently break WebSocket in development (the app WS hooks also default to ws://localhost:8000)
  • The correct fix is a build-time guard (e.g. a console.warn in next.config.js when NODE_ENV === 'production' and NEXT_PUBLIC_WS_URL is unset) rather than removing the fallback

Suggest addressing this as a follow-up rather than blocking merge, but a comment in the code making the production requirement explicit would help.

Verdict

Same as before: fix the // ponytail: typo, then this is ready to merge. The DEFAULT_WS_URL production guard is worth a follow-up ticket.

@claude

claude Bot commented Jun 21, 2026

Copy link
Copy Markdown

Follow-up review — post-merge-from-main (5cecd05)

Building on my two previous reviews. No new concerns from the merge-from-main commit — it's a clean rebase with no conflict resolutions that touch the security surface.

Still open

security-headers.js line 38: // ponytail: typo is still present. This is the only outstanding item from prior reviews — cosmetic but should be // NOTE: before merging.

Everything else from prior reviews remains follow-up scope:

  • Permissions-Policy header → follow-up ticket
  • Strict-Transport-Security / HSTS → document in CLAUDE.md for production self-hosted ops
  • Production guard when NEXT_PUBLIC_WS_URL is unset (the DEFAULT_WS_URL point CodeRabbit flagged) → follow-up ticket

Verdict: Fix the // ponytail: comment, then this is ready to merge.

@frankbria frankbria merged commit eadb0aa into main Jun 21, 2026
12 checks 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.

[P7.0.3] Move JWT off localStorage / shorten lifetime + add CSP (M3)

1 participant