Skip to content

Clear the open CodeQL alerts (#1, #2, #3, #5, #7, #8) - #1009

Merged
eaitbrahim merged 4 commits into
mainfrom
fix/codeql-alerts
Oct 14, 2026
Merged

eaitbrahim merged 4 commits into
mainfrom
fix/codeql-alerts

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Clears the six open CodeQL code-scanning alerts on main. No alert is dismissed here; the owner decides dismissals.

Per alert

Alert Rule Verdict Fix Test
#3, #7 py/path-injection, keel/web/staticfiles.py False positive (the resolve() + relative_to() containment was real; CodeQL does not model relative_to as a sanitizer) resolve_static_asset now answers by exact name from _servable_files(root), a listing of real files whose resolved location stays under the root. The request path is only compared with names the package produced, never joined or walked. At least as strict: also refuses non-canonical spellings (sub/../index.html, ./x, a//b, trailing slash) that no browser sends. shell_request_paths reuses the same listing Existing traversal / null-byte / double-encoding tests still green, plus new: symlink out of root, symlinked directory out of root, in-root symlink resolves, symlink loop, non-canonical spellings refused (red before the fix)
#5 py/path-injection, keel/web/server.py _serve_static read False positive, same flow Cleared by the change above: the Path it reads is the listing's own value. No code was moved out of do_GET/_serve_static Covered by the wire-level traversal tests in test_server.py
#2 py/http-response-splitting, keel/web/server.py False positive for splitting (http.server splits the request line on whitespace, and parsed.path is never percent-decoded, so CR/LF cannot reach the header). Real but minor open redirect: /\evil.example?token=<valid> redirected to /\evil.example, which browsers treat as //evil.example (needs the session token, so low impact) The token-exchange Location is staticfiles.shell_redirect_target: the member of shell_request_paths the decoded path names, else /. No request byte can reach the header test_the_token_exchange_redirects_only_to_a_path_the_console_answers (encoded CRLF, %5C and raw backslash, /api/pulse, unknown name all go to /; /rules and /status are kept), and test_a_raw_carriage_return_in_the_request_line_injects_no_header (real control bytes over a socket)
#1 py/clear-text-logging-sensitive-data, scripts/robinhood_order_probe.py Near-real: the refusal echoed whatever the operator typed after --private-key-var / --api-key-var, so a key pasted where its variable name belongs went to the terminal The refusal names the missing flag and repeats nothing the operator or the .env supplied test_a_missing_credential_is_named_by_its_flag_and_echoes_no_value (red before the fix)
#8 py/bad-tag-filter, tests/web/test_client_assets.py False positive in practice (the input was lowercased first), but the regex was a hand-written tokenizer test_the_shell_ships_no_inline_script_or_style collects tags with html.parser; same assertions (every script has a src; the only classic script is exactly {"src": "/js/theme.js"}; it comes before the first stylesheet link; no <style>/<base>) Mutation-checked: an uppercase inline <SCRIPT >, swapping script and stylesheet order, and an extra attribute on the theme script each make it fail

Verification

  • Full pytest: 8501 passed, 3 skipped. ruff check, ruff format --check, mypy are clean.
  • Mutation checks, each confirmed applied (cmp) and restored from a saved copy: dropping the containment filter, dropping is_file, re-adding the join fallback, echoing the request path in the redirect, echoing --private-key-var, and three index.html mutants. All were killed. A defensive try/except around resolve() survived its mutant, so it was dead code (non-strict resolve() does not raise on a loop) and was removed.
  • Real browser on a scratch server (port 8791): service worker unregistered and caches cleared, then /rules?token= gave a 303 to /rules, the shell rendered "Rules", all 18 resources returned 200, no console errors.

🤖 Generated with Claude Code

… redirect, flag-named probe refusal

- #3/#5/#7 py/path-injection: resolve_static_asset answers by exact name from a listing of the
  static root (_servable_files: real files whose resolved location stays under the root) instead
  of joining the request path onto the root. Same containment, stricter on non-canonical
  spellings, and no request byte reaches a filesystem call.
- #2 py/http-response-splitting: the token exchange redirects to a member of
  shell_request_paths (shell_redirect_target), never to the request's own spelling; also closes
  a `/\host` protocol-relative redirect.
- #1 py/clear-text-logging-sensitive-data: the probe's missing-credential refusal names the
  missing flag, never the value typed after it.
- #8 py/bad-tag-filter: the shell's script-tag test parses with html.parser instead of a regex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@eaitbrahim eaitbrahim left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Security review of the CodeQL fixes. No exploitable issue found. I checked that each fix is at least as strict as before: .., absolute, %2e%2e, %252e%252e, null byte, backslash, /A.CSS on case-insensitive APFS, NFC/NFD and non-canonical spellings are all refused now, because it is an exact-name lookup. The redirect Location is always a member of the closed set: CRLF, %5C, \\, //host and /%2Frules all go to /. Every ?token= producer (server.py:170, runtime.py:295) emits /?token=, so no deep link regresses. A per-request walk of 17 files measured about 0.75 ms. The listing serves nothing new: the content-type gate in _serve_static is unchanged. The probe refusal no longer echoes either --*-var value, and the probe has no other print of them. The 4 touched test files pass (399). Two low findings are inline: one docstring and one test that cannot fail.

Comment thread keel/web/staticfiles.py Outdated
Comment thread tests/web/test_server.py Outdated
eaitbrahim and others added 2 commits October 14, 2026 13:40
…t about its premise

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@eaitbrahim eaitbrahim left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Round 2 security review. One finding, Low, inline. It is a stale docstring, not a hole.

Both round-1 threads are addressed.
(a) The _servable_files docstring and the module docstring now say that symlinked directories are never descended into, and that is_relative_to covers file symlinks. test_nothing_under_a_symlinked_directory_is_listed_even_inside_the_root pins this, and test_a_symlink_out_of_the_root_is_refused pins the filter.
(b) The raw-CR test now says that it pins the http.server premise and asserts the 400. The encoded-CRLF parametrized cases fail on main, because the old Location echoed /%0d%0a....

What I checked independently:

  • Traversal. .., absolute, %2e%2e, %252e%252e, null byte, backslash and %5c, uppercase on case-insensitive APFS (/JS/main.js was served before and is a 404 now), trailing slash, and non-canonical spellings. All resolve to nothing, because the request is only compared with keys of the listing. The returned Path is the listing's own value. That covers _serve_static's read_bytes (#5).
  • Redirect. /\\host, %5C, CRLF, /api/*, an unknown name and %ff all go to /. Every producer of a ?token= URL (ServeConfig.url, runtime.url_for) uses /, and /status?token= is still kept. So no deep-link regression.
  • Nothing newly served. The listing holds the same 17 files. resolve_client_route and content_type_for are unchanged.
  • Performance. resolve_static_asset takes about 0.74 ms per call on the real static/.
  • Tests. The new staticfiles tests are red without the filter or the lookup. The probe test is red on main. The html.parser rewrite keeps every assertion. test_staticfiles, test_server, test_client_assets, test_robinhood_order_probe and test_web_prose_stays_true gave 407 passed.
  • Stale text. I grepped keel/web and tests/web for relative_to, resolve() and containment. The only stale text is the one below.

Comment thread keel/web/staticfiles.py
…ic spellings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@eaitbrahim eaitbrahim left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review round 3 (head 8ef4795): no findings.

I re-checked the three resolved threads against the code:

  • Symlinked-directory docstrings: _servable_files and the module docstring now say that rglob does not descend into symlinked directories, and requires-python >=3.14 makes that the default. test_nothing_under_a_symlinked_directory_is_listed_even_inside_the_root pins it.
  • Raw-CR socket test: its docstring now says it pins the stdlib 400 premise and cannot fail against the fix itself. The parametrized percent-encoded cases are what test shell_redirect_target.
  • _is_a_sessioned_shell_read (server.py:812): the docstring now says the list and _serve_static agree by construction. That holds: /js/../js/main.js is neither in shell_request_paths nor served.

Independent checks:

  • Strictness: I probed the resolver against the real STATIC_ROOT. These all return None: .., encoded .., absolute paths, %2f, //, a null byte (%00), a raw backslash, %5C, /JS/main.js (case), and a trailing slash. The listing is a subset of what the old join served, so no file is newly served. It contains no dotfiles.
  • Redirect: Location is always a member of shell_request_paths or /. //evil, /\evil, /rules/ and /RULES all go to /, and /%72ules goes to /rules. Every ?token= URL keel builds (ServeConfig.url, runtime.url_for) is rooted at /, so this is not a deep-link regression.
  • Probe: lines 497-498 are the only uses of args.*_var, and the refusal echoes neither.
  • Tests: the non-canonical-spelling case sub%2fnested.html/ would have been served by the old join, so that test is red before the fix. The html.parser assertions check tag structure, not substrings.
  • Stale prose: no remaining reference to the old join, the old relative_to mechanism or the old Location behaviour in keel/web, tests/web or docs/architecture.md.
  • Test run: tests/web/test_staticfiles.py, test_server.py, test_client_assets.py and tests/scripts/test_robinhood_order_probe.py: 400 passed.

@eaitbrahim
eaitbrahim merged commit 787e0cd into main Oct 14, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/codeql-alerts branch October 14, 2026 18:39
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