Skip to content

fix(security): never vend the LLM API key to the browser - #286

Open
NovakPAai wants to merge 1 commit into
mainfrom
claude/novak-llm-key-redaction
Open

fix(security): never vend the LLM API key to the browser#286
NovakPAai wants to merge 1 commit into
mainfrom
claude/novak-llm-key-redaction

Conversation

@NovakPAai

Copy link
Copy Markdown
Collaborator

Summary

A server-side audit found that GET /api/llm-config returned the stored LLM API key (Settings → Integrations → AI titles) verbatim on every Settings load — unmasked in each network response and devtools log, then pre-filled into the password input's value. The GitHub endpoints already follow the right rule (/api/github/profile explicitly strips token/repoToken with a "Never vend raw tokens to the browser" comment); the LLM endpoint was the one exception.

  • GET /api/llm-config now returns { model, url, hasKey, keyHint }keyHint is a ••••-masked last-4 so the user can tell which key is stored without the secret ever reaching the DOM.
  • Because the key no longer round-trips, POST with an empty apiKey field means "keep the stored key" (otherwise every URL/model tweak would silently wipe it); an explicit { clearApiKey: true } removes it.
  • saveLLMConfig writes ~/.claude/codedash-llm.json with mode 0600 and chmods a pre-existing world-readable file from older versions (matching the 0600 convention the workspace-layouts/commands stores already use).
  • Frontend shows the stored-key state via the input placeholder (••••1234 (saved — type to replace)), never pre-fills the value; autocomplete="new-password" keeps browser autofill managers out. saveLLMSettings also surfaces save failures now instead of toasting "saved" unconditionally.
  • Drive-by in the same security bucket: rel="noopener noreferrer" on all target="_blank" links in leaderboard.js (reverse-tabnabbing).

Test plan

  • node --test "test/**/*.test.js" — 262 passed, 1 skipped (win32-only); includes 5 new source-contract tests (test/llm-config-redaction.test.js)
  • Live round-trip against a running server: GET carries no apiKey field and shows ••••1234; config file lands -rw-------; a save with an empty key field preserves the stored key; clearApiKey: true clears it (original local config backed up and restored after the test)

🤖 Generated with Claude Code

GET /api/llm-config returned the stored API key verbatim on every
Settings load — it sat unmasked in each network response/devtools log
and the frontend dropped it straight into the password input's value.
The GitHub endpoints already follow the right rule ("Never vend raw
tokens to the browser", /api/github/profile strips token/repoToken);
the LLM endpoint was the one exception.

- GET /api/llm-config now returns { model, url, hasKey, keyHint } —
  keyHint is a ••••-masked last-4 so the user can tell which key is
  stored without the secret ever reaching the DOM.
- Because the key no longer round-trips, POST with an empty apiKey
  field means "keep the stored key" (otherwise every URL/model tweak
  would silently wipe it); an explicit { clearApiKey: true } removes it.
- saveLLMConfig writes ~/.claude/codedash-llm.json with mode 0600 and
  chmods a pre-existing world-readable file from older versions.
- Frontend shows the stored-key state via the input placeholder
  ("••••1234 (saved — type to replace)"), never pre-fills the value;
  autocomplete="new-password" keeps browser autofill managers out.
  saveLLMSettings now also surfaces save failures instead of toasting
  "saved" unconditionally.
- leaderboard.js: rel="noopener noreferrer" on all target=_blank links
  (reverse-tabnabbing).

Verified live: GET carries no apiKey field, hint shows ••••1234, file
lands -rw-------, an empty-key save preserves the key, clearApiKey
clears it. Source-contract tests added (test/llm-config-redaction.test.js).
@NovakPAai
NovakPAai requested a review from vakovalskii August 2, 2026 19:55
vakovalskii added a commit that referenced this pull request Aug 3, 2026
Conflict in leaderboard.js: #288 replaces the inline GitHub device-code modal
markup with _lbBuildAuthModal() (focus trap + Escape + focus return). Took #288
for that hunk only — resolving the whole file to either side would have dropped
the other PR's work; #286's rel="noopener noreferrer" on the remaining three
target="_blank" links is kept (its test asserts all four).
vakovalskii added a commit that referenced this pull request Aug 3, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant