fix(security): resolve all high/critical CodeQL findings (#177, #179, #180)#183
Conversation
toEmbedUrl()'s fallback branch used raw.includes(...) to check for a known-safe embed URL, then returned the unvalidated input unmodified. A crafted string containing the substring anywhere could pass the check while breaking out of the iframe's src="..." attribute it gets interpolated into unescaped, injecting arbitrary markup. Validate via new URL(raw).hostname against an exact allowlist instead, and escape the result at every interpolation site as defense in depth. Also escape the two playground select-field values that were concatenated into an HTML-shaped string unescaped. Fixes #179 (CodeQL: DOM text reinterpreted as HTML x3, incomplete URL substring sanitization x1).
A prior fix for this same CodeQL alert removed the localStorage.setItem call for the user's bring-your-own AI provider key but left the getter/isConfigured() reading from the now-permanently-empty key, silently breaking every AI feature (chat, summary, insights, prioritisation all gated behind isConfigured()). Store the key in a module-scoped variable instead of any browser storage. It never survives a page reload (the standard, expected trade-off for a secret that must not persist), but the feature works again and the key is never written to localStorage/sessionStorage. provider/model (not secret) still persist in localStorage as before. Fixes #180 (CodeQL: clear text storage of sensitive information).
Every affected route returned str(exc) (or an f-string embedding it) directly in the JSON response body. Some catch broad `except Exception`, which can surface raw database driver errors, connection details, or internal paths to the client rather than just intentional validation messages. Replace with a fixed, generic message per error class; the existing logger.error(...)/logger.warning(...) calls already capture full detail server-side. In api/routes/ai.py, extract a shared _ai_error_response() helper since the same 3-exception pattern (VectorStoreNotBuilt, ValueError, RuntimeError from get_completion()) repeated verbatim across 4 routes, and add the server-side logging that was missing there entirely. Also fixes an f-string leak in compliance.py's FileNotFoundError handler that the "detail" key search missed. Fixes #177 (CodeQL: information exposure through an exception, all 27 findings across api/app.py and 8 api/routes/ modules).
Self-review caught that the defense-in-depth escapeHTML() calls added for #179 wrapped values assigned via .textContent, not .innerHTML. .textContent never interprets HTML, so escaping there adds no security value while risking visible text corruption (a literal "&" shown on screen) if the underlying value ever contains a special character. toEmbedUrl()'s URL-origin validation is the real, load-bearing fix and is unaffected by this change — it guarantees embedUrl can only ever be '' or a well-formed https://youtube.com|vimeo.com URL regardless of which DOM property ultimately consumes it.
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
m-khan-97
left a comment
There was a problem hiding this comment.
Thorough review of all three fixes here, checked against actual source rather than just the description:
#179 (stored XSS) — verified the new `toEmbedUrl()` logic directly: `new URL(raw).hostname` against an exact-match allowlist correctly defeats every bypass class I'd try — substring tricks (`evil.com/?x=youtube.com/embed`), subdomain spoofing (`youtube.com.evil.com`), and userinfo tricks (WHATWG URL parsing puts anything before `@` into userinfo, not hostname, so that vector is closed by construction, not just by the allowlist). The test file backs this with the exact bypass strings you'd expect an attacker to try, including markup injection. Good fix.
#180 (clear-text storage + the live bug it caused) — really glad this called out that the previous attempted fix had already broken all four AI features by removing the localStorage write but leaving the getter reading from it. Moving to a module-scoped in-memory variable satisfies CodeQL and restores the feature in one change, with tests for both properties (never touches localStorage under any key, and still round-trips correctly in memory). The documented trade-off (key doesn't survive reload) is the right call and clearly explained in the comment.
#177 (error exposure) — the sweep across all 8 route files plus `api/app.py`'s error handlers is complete and consistent. Good catch on `compliance.py`'s f-string leak specifically — that's a real, distinct leak vector from the `"detail"` key pattern the rest of the routes had, and a naive grep for `"detail"` would've missed it. The 25 new regression tests use a distinctive marker string rather than checking for generic wording, which is the right way to make this kind of test actually catch a regression instead of coincidentally passing.
Also appreciated the self-review note about reverting the `.textContent` escaping — recognizing that fix added no security value and could visibly corrupt text is exactly the kind of check that should happen before merge, not after someone notices garbled output in production.
Approving.
ritiksah141
left a comment
There was a problem hiding this comment.
Two security issues need to be addressed before this PR can be approved.
-
toEmbedUrl()validates withnew URL(raw)but returns the originalrawstring. An allowed hostname can therefore retain attribute-breaking characters. For example,https://youtube.com/embed/x" onload="alert(1)passes the protocol and hostname checks and is returned unchanged. Return the canonicalparsed.hrefafter validating the expected YouTube or Vimeo embed pathname, and add regression cases for quotes or markup characters on an allowed hostname. -
The in-memory API key change does not remove a legacy
ai_api_keyalready stored by earlier releases. Existing users can therefore retain the clear-text secret in localStorage indefinitely unless they manually useclear(). Remove the legacy entry during module initialization or a one-time migration, and add a test that pre-populatesai_api_keyand verifies it is deleted without being read into memory.
The remaining changes and tests look sound. Local verification passed: 329 tests passed, 3 skipped; the 17 targeted error-exposure tests passed; both JavaScript regression suites passed; Ruff and formatting checks passed.
Two residual issues from #183's merge, found in post-merge review: - toEmbedUrl() validated the host via new URL() but returned the raw input, so an attribute-injection payload on an allowed host (e.g. https://www.youtube.com/embed/abc" onload="alert(1)) passed validation and kept its literal double-quote, breaking out of the iframe src="..." attribute. Now returns parsed.href, which percent-encodes quotes, spaces, and brackets. - The switch to in-memory key storage removed the localStorage write but never purged keys that older builds had already stored, so existing users kept a plaintext ai_api_key indefinitely. Added a one-time purge on module load (not read into memory) and in clear(). Both fixes ship with regression tests confirmed to fail against the pre-fix code and pass against the fix. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
Fixes #177, #179, #180 — all high and critical CodeQL findings (34 total across the 4 issues filed from the code-scanning review; this PR covers the 3 grouped as high/critical, per team decision to split high+critical into one PR and medium into a separate one).
toEmbedUrl()inwebsite/script.jsvalidated embed URLs with a substring check (raw.includes('youtube.com/embed')) and returned the unvalidated raw input when it matched — bypassable with a crafted string, breaking out of the iframe'ssrc="..."attribute. Rewritten to validate vianew URL(raw).hostnameagainst an exact allowlist. Addedwebsite/test_toEmbedUrl.mjs, a dependency-free test that loads the real production file (no duplicated logic) and verifies the exploit string is rejected while legitimate YouTube/Vimeo URLs still work.frontend/src/utils/aiApi.js's AI provider API key storage. A prior attempt to fix this same CodeQL alert had removed thelocalStorage.setItemcall but left the getter/isConfigured()reading from the now-permanently-empty key — silently breaking all four AI features regardless of what key a user entered. Moved the key to a module-scoped in-memory variable: satisfies CodeQL (never touches browser storage) and restores the feature. Addedfrontend/src/utils/aiApi.test.mjs.api/app.pyand 8 route modules underapi/routes/where exceptions were reflected back to the client viastr(exc)or an f-string, including from broadexcept Exceptionblocks that could surface raw DB driver errors. Replaced with fixed, generic messages; existing/addedlogger.error(...)calls preserve full detail server-side. Extracted a shared_ai_error_response()helper inai.pyfor 4 near-identical handlers. Also fixed an f-string leak incompliance.py'sFileNotFoundErrorhandler that a plain"detail"key search would have missed.Self-review
Ran a high-effort, 6-agent parallel review against the full diff. One real issue found in my own work:
escapeHTML()had been added at 3 interpolation sites inwebsite/script.jsthat turned out to be.textContentassignments, not.innerHTML—.textContentnever interprets HTML, so escaping there added no security value while risking visible text corruption (a literal&on screen) if the underlying value ever contained a special character. Reverted all 3;toEmbedUrl()'s URL-origin validation is the real, load-bearing fix and doesn't depend on them.Everything else the review surfaced was low-severity/architectural (no shared error-response helper across all 9 API files yet, no repo-wide lint rule preventing a future
str(exc)regression, two new JS test files each hand-roll similar sandboxing boilerplate since neitherwebsite/norfrontend/has an existing test framework) — noted as follow-up-worthy but not blocking this fix.Test plan
ruff check/ruff format --check— cleantests/test_error_exposure.py,tests/test_ai_error_exposure.py) asserting error responses never contain a"detail"key or the raw exception text, across every route touchedwebsite/test_toEmbedUrl.mjs(13 cases) andfrontend/src/utils/aiApi.test.mjs(6 cases) — both load and test the real production source, wired into CI (ci.yml: newwebsitejob, new step in thefrontendjob)dev