fix(security): close 6 OAuth backend + nx-claude script scan findings - #525
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🤖 Claude Code Review
SummaryThis PR closes the CSRF gap flagged on the prior review by binding the OAuth state token to the initiating browser via a double-submit cookie pattern (HMAC-signed state + The crypto, validation order, and one-time-use semantics all look correct. No blocking issues found. Detailed analysis
|
📚 Documentation Check ✅Verdict: Passed No files in packages/plugins/ were modified, so no plugin version bump is required. Documentation gaps exist but fail_on_missing_docs is false. PR #525 Documentation AnalysisNo plugin version bump required — all changes are in Key Changes in This PR
Documentation Gaps Found
Missing Updates
Suggestions (4)
🤖 Generated by Claude Documentation Validator | Mode: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6ff9be7061
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
📋 Review verdict: APPROVE
👆 The main review comment above is the source of truth for this PR review. It is automatically updated on each review cycle, so always refer to it for the most current feedback.
This formal review submission is for the verdict only.
…cookie) Closes the Codex P1 CSRF finding on /slack/oauth/callback. Because /slack/oauth/authorize is public and mints valid signed state tokens for anyone, a signed state alone could not prove the browser hitting /callback started the flow. /authorize now generates a per-browser CSPRNG nonce, sets it in an HttpOnly; Secure; SameSite=Lax cookie (scoped to /slack/oauth), and folds it into the HMAC-signed state token. /callback re-reads the nonce from the cookie and validateState requires it to match the signed copy (constant-time compare) in addition to verifying the signature and freshness, then clears the cookie for one-time use. A missing/empty cookie nonce is rejected early so a stripped cookie cannot bypass the binding. No new dependency: cookies are written via Express res.cookie/clearCookie and read by a small readCookie helper instead of adding cookie-parser. The existing 6 security fixes are unchanged. Tests: state.spec.ts (matching/mismatched/empty nonce, tampered + forged signature), cookies.spec.ts (reader), plus route + integration tests that drive a real /authorize -> /callback round-trip and assert Set-Cookie on /authorize, cleared cookie on /callback, and 400 on foreign/absent cookie. All 102 tests pass; typecheck and lint clean. Resolved review feedback from: chatgpt-codex-connector PR: #525 Claude-Session: https://claude.ai/code/session_01W62gurznvTz2w81B8dcwG9
Slack OAuth backend (apps/slack-oauth-backend): - #1 (HIGH) CSRF: replace the length-only state check with a stateless HMAC-SHA256 signed state token (new src/oauth/state.ts). generateState signs a CSPRNG nonce + timestamp with config.sessionSecret and base64url- encodes it; validateState recomputes the HMAC with timingSafeEqual and a 10-min freshness window. Stateless by design so it works on Vercel serverless (no shared store); base64url satisfies the existing callback validator charset/length bounds. - #2 (MED) weak randomness: nonce now from crypto.randomBytes(16), not Math.random()/Date.now(). - #3 (MED) tokens in cacheable HTML: add Cache-Control no-store (+no-cache/ Pragma/Expires) on all token-bearing /callback responses and the /refresh JSON; stop rendering the refresh token in the HTML fallback. - #6 (MED) unauthenticated /slack/refresh: proportionate hardening (rate limit 10->5/min, no-store header, documented threat model). The refresh_token is itself the bearer credential in a stateless flow, so per-caller auth is intentionally not added. nx-claude scripts (packages/ai-toolkit-nx-claude): - #4 (MED) bash arithmetic command injection: source validate-numeric.sh and validate registry-derived values (^[0-9]+$) before $((...)) in reset-prerelease-version.sh. - #5 (MED) shell injection in generated slack-env.sh: POSIX single-quote escaping (shSingleQuote) in createSlackEnvFile and updateRefreshToken; the latter also switches String.replace to a replacement function to neutralize $-substitution corruption. Verification: 83/83 backend tests pass (integration suite updated to mint real signed states); typecheck + lint clean on both projects; #4 and #5 functionally verified (injection payload rejected, metacharacters inert); code-reviewer pass: READY TO MERGE. Finding #7 (heredoc delimiter) was already fixed on next by PR #509.
…cookie) Closes the Codex P1 CSRF finding on /slack/oauth/callback. Because /slack/oauth/authorize is public and mints valid signed state tokens for anyone, a signed state alone could not prove the browser hitting /callback started the flow. /authorize now generates a per-browser CSPRNG nonce, sets it in an HttpOnly; Secure; SameSite=Lax cookie (scoped to /slack/oauth), and folds it into the HMAC-signed state token. /callback re-reads the nonce from the cookie and validateState requires it to match the signed copy (constant-time compare) in addition to verifying the signature and freshness, then clears the cookie for one-time use. A missing/empty cookie nonce is rejected early so a stripped cookie cannot bypass the binding. No new dependency: cookies are written via Express res.cookie/clearCookie and read by a small readCookie helper instead of adding cookie-parser. The existing 6 security fixes are unchanged. Tests: state.spec.ts (matching/mismatched/empty nonce, tampered + forged signature), cookies.spec.ts (reader), plus route + integration tests that drive a real /authorize -> /callback round-trip and assert Set-Cookie on /authorize, cleared cookie on /callback, and 400 on foreign/absent cookie. All 102 tests pass; typecheck and lint clean. Resolved review feedback from: chatgpt-codex-connector PR: #525 Claude-Session: https://claude.ai/code/session_01W62gurznvTz2w81B8dcwG9
aba4065 to
c9176a1
Compare
Superseded by new review after PR update
There was a problem hiding this comment.
📋 Review verdict: APPROVE
👆 The main review comment above is the source of truth for this PR review. It is automatically updated on each review cycle, so always refer to it for the most current feedback.
This formal review submission is for the verdict only.
…ings semgrep's direct-response-write rule fires on the three res.send() / res.status(400).send() calls in the /callback handler that emit formatter HTML. These are confirmed false positives: formatSuccessPage (formatter.ts lines 253-256) and formatErrorPage (line 462) escape every user-controlled input via escapeHtml() before interpolation. semgrep cannot trace the escaping across the function boundary so it flags the call sites. Added narrowly-scoped nosemgrep suppressions (specific rule id, not bare nosemgrep) with a one-line justification on each of the three lines. Local scan confirms 0 findings after suppression. Claude-Session: https://claude.ai/code/session_01W62gurznvTz2w81B8dcwG9
Superseded by new review after PR update
There was a problem hiding this comment.
📋 Review verdict: APPROVE
👆 The main review comment above is the source of truth for this PR review. It is automatically updated on each review cycle, so always refer to it for the most current feedback.
This formal review submission is for the verdict only.
…eculative XSS suppressions Claude-Session: https://claude.ai/code/session_01W62gurznvTz2w81B8dcwG9
Superseded by new review after PR update
There was a problem hiding this comment.
📋 Review verdict: APPROVE
👆 The main review comment above is the source of truth for this PR review. It is automatically updated on each review cycle, so always refer to it for the most current feedback.
This formal review submission is for the verdict only.
Summary
Closes 6 of the 7 open security-scan findings (dated 2026-03-28) against
next. Finding #7 (static heredoc delimiter) was already fixed by #509, so this PR covers the remaining six.Implemented and adversarially verified via a parallel agent workflow (3 domain implementers + 6 per-finding verifiers), then a code-reviewer pass on the diff.
Slack OAuth backend (
apps/slack-oauth-backend)src/oauth/state.ts).generateState()signs a CSPRNG nonce + timestamp withconfig.sessionSecretand base64url-encodes it;validateState()recomputes the HMAC withtimingSafeEqualand a 10-min freshness window. Forged/unsigned/tampered/expired states are rejected.crypto.randomBytes(16)instead ofMath.random()/Date.now().Cache-Control: no-store(+no-cache/Pragma/Expires) on all token-bearing/callbackresponses and the/refreshJSON; the refresh token is no longer rendered in the HTML fallback./slack/refresh. Proportionate hardening: rate limit 10 → 5/min,no-storeheader, documented threat model.Why HMAC-signed state, not a server-side store (as the scan suggested): the backend runs on Vercel serverless, where in-process memory is not shared across invocations/instances. A
MemoryCachewould mean/authorizeand/callbackhit different instances and every legitimate flow would miss the cache. HMAC signing with the existingsessionSecretblocks forgery with no store. base64url output satisfies the existingvalidateOAuthCallbackregex (/^[a-zA-Z0-9_-]+$/, length 16..500).Why #6 keeps the token relay unauthenticated: the
refresh_tokenis the bearer credential in this stateless flow; per-caller auth can't be added without new session infra, and the real leak vector (a refresh token in cacheable HTML) is closed by #3. The hardening reduces the abuse surface without pretending the security boundary changed.nx-claude scripts (
packages/ai-toolkit-nx-claude)reset-prerelease-version.sh. Sources the existing-but-unused.github/scripts/validate-numeric.shand validates registry-derived values (^[0-9]+$) before$((...)). Payload1.1.0-next.a[$(...)]is now rejected.slack-env.sh. NewshSingleQuote()POSIX single-quote escaping increateSlackEnvFile;updateRefreshTokenalso switchesString.replaceto a replacement function to neutralize$-substitution corruption on tokens containing$.Test plan
nx test slack-oauth-backend→ 83/83 pass. Newstate.spec.tscovers fresh/tampered/expired/empty/malformed tokens; the integration suite was updated to mint real signed states viagenerateState()(the old hardcoded unsigned states correctly 400 now).nx run-many -t typecheck lint -p slack-oauth-backend ai-toolkit-nx-claude→ clean (0 errors; pre-existingno-explicit-anywarnings only).$(...), backticks,$HOME, and a single-quote breakout all inert when sourced (bash); benignxoxe-1-…token round-trips verbatim.Notes
CLAUDE.md/version changes: the nx-claude edits are internal hardening (no API change) and the package is not underpackages/plugins/, so the mandatory plugin-version-bump rule does not apply.SESSION_SECRETentropy at boot, and theloadSlackConfigparser's single-quote truncation (benign for realxoxe-tokens).🤖 Generated with Claude Code
AI-Generated Description
Summary
Closes 6 of the 7 open security-scan findings (dated 2026-03-28) against
next. Finding #7 (static heredoc delimiter) was already fixed by #509, so this PR covers the remaining six.Implemented and adversarially verified via a parallel agent workflow (3 domain implementers + 6 per-finding verifiers), then a code-reviewer pass on the diff.
Slack OAuth backend (
apps/slack-oauth-backend)src/oauth/state.ts).generateState()signs a CSPRNG nonce + timestamp withconfig.sessionSecretand base64url-encodes it;validateState()recomputes the HMAC withtimingSafeEqualand a 10-min freshness window. Forged/unsigned/tampered/expired states are rejected.crypto.randomBytes(16)instead ofMath.random()/Date.now().Cache-Control: no-store(+no-cache/Pragma/Expires) on all token-bearing/callbackresponses and the/refreshJSON; the refresh token is no longer rendered in the HTML fallback./slack/refresh. Proportionate hardening: rate limit 10 → 5/min,no-storeheader, documented threat model.MemoryCachewould mean/authorizeand/callbackhit different instances and every legitimate flow would miss the cache. HMAC signing with the existingsessionSecretblocks forgery with no store. base64url output satisfies the existingvalidateOAuthCallbackregex (/^[a-zA-Z0-9_-]+$/, length 16..500).refresh_tokenis the bearer credential in this stateless flow; per-caller auth can't be added without new session infra, and the real leak vector (a refresh token in cacheable HTML) is closed by #3. The hardening reduces the abuse surface without pretending the security boundary changed.nx-claude scripts (
packages/ai-toolkit-nx-claude)reset-prerelease-version.sh. Sources the existing-but-unused.github/scripts/validate-numeric.shand validates registry-derived values (^[0-9]+$) before$((...)). Payload1.1.0-next.a[$(...)]is now rejected.slack-env.sh. NewshSingleQuote()POSIX single-quote escaping increateSlackEnvFile;updateRefreshTokenalso switchesString.replaceto a replacement function to neutralize$-substitution corruption on tokens containing$.Test plan
nx test slack-oauth-backend→ 83/83 pass. Newstate.spec.tscovers fresh/tampered/expired/empty/malformed tokens; the integration suite was updated to mint real signed states viagenerateState()(the old hardcoded unsigned states correctly 400 now).nx run-many -t typecheck lint -p slack-oauth-backend ai-toolkit-nx-claude→ clean (0 errors; pre-existingno-explicit-anywarnings only).$(...), backticks,$HOME, and a single-quote breakout all inert when sourced (bash); benignxoxe-1-…token round-trips verbatim.Notes
CLAUDE.md/version changes: the nx-claude edits are internal hardening (no API change) and the package is not underpackages/plugins/, so the mandatory plugin-version-bump rule does not apply.SESSION_SECRETentropy at boot, and theloadSlackConfigparser's single-quote truncation (benign for realxoxe-tokens).