Skip to content

fix(security): cronScript auth header + OAuth state entropy fence - #8263

Merged
iamwhatever merged 1 commit into
mainfrom
fix/sec-cronscript-header-state-fence-2026-09-03
Sep 4, 2026
Merged

fix(security): cronScript auth header + OAuth state entropy fence#8263
iamwhatever merged 1 commit into
mainfrom
fix/sec-cronscript-header-state-fence-2026-09-03

Conversation

@iamwhatever

@iamwhatever iamwhatever commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Problem / Motivation

Two verified security findings from the watchdog queue.

F1 — cronScript omitted the session-key header. In website/src/api/client.ts, every /api/crons/* call passes { headers: { ..._sk } }; cronScript was the one that did not.

F2 (#8051) — the OAuth front-channel entropy exemption had no shape bound. Commit 1b48454 (fix: scope OAuth entropy exemption to approved parameters) replaced a guarded predicate with an unconditional one:

approved_value = bool(separator) and key in _OAUTH_ENTROPY_QUERY_PARAMS

It dropped _PKCE_S256_CHALLENGE_RE ([A-Za-z0-9_-]{43}\Z), the code_challenge_method == ["S256"] requirement, and both _contains_fixed_credential / _text_contains_bare_secret guards, and widened the exempt set from code_challenge alone to state, nonce, and code_challenge. Because _BARE_SECRET_RUN_RE is [A-Za-z0-9+/]{40,} — the AWS-secret shape — a raw AWS secret in state= at an approved endpoint was blanked before the markerless scan ran, so oauth_url_contains_credential() returned False and the operator approval banner showed no credential warning. It also deleted test_bare_aws_secret_inside_state_fails_closed_everywhere and test_pkce_challenge_wrapping_bare_aws_secret_fails_closed.

Why it matters

The approval banner is the operator's last look at a consent URL before authorising it. A credential smuggled into state= at an approved endpoint reached that banner with no warning, so the one human check in the flow was silently disarmed for exactly the shape the heuristic exists to catch.

F1 is the weaker of the two and this PR does not overclaim it: it is not exploitable at the handler today (see Manual verification). It restores an invariant the file documents at its own _sk definition, so a gate added to this read later does not silently skip.

What changed (motivation → approach → change)

F1 (one line). Added { headers: { ..._sk } } to cronScript, copied verbatim from the sibling cronRunDetail call directly above it.

F2 — the charset/length bound, deliberately not the _text_contains_bare_secret restore. Restoring that guard is the smaller edit but is a straight revert of #8051's real bug fix: its own retained test test_recognized_oauth_entropy_does_not_hit_bare_secret_lottery asserts _text_contains_bare_secret(entropy) is True for a legitimate S256 challenge and a legitimate 40-char state, so that guard is precisely what produced false credential banners on real sign-in URLs. The exemption is instead bounded to the shapes the protocol itself can emit:

  • base64url (RFC 4648 §5) emits - / _ and never + or /, so a value containing either cannot be protocol entropy and keeps the markerless scan;
  • an S256 code_challenge is base64url of a 32-byte digest, so it is exactly 43 characters; any other length in that field is not a challenge shape;
  • every decoded form must hold the shape, not just the raw one. One decode pass is not enough, for the same reason it is not enough in _exfil_url_warning: %252F decodes to %2F, which still carries no literal /, so a raw-plus-one-decode test would hand the exemption to a base64-standard AWS-secret run. The predicate decodes until the text stops changing, bounded by the module's existing _MAX_URL_DECODE_PASSES, and fails closed when a layer still remains at the bound. This mirrors the established idiom at _exfil_url_warning rather than inventing a second one.

The endpoint gate, the fixed-credential passes, and the diagnostic contract are untouched.

Two residuals, both narrowed in the spec rather than left implied:

  1. A 40-char credential that happens to be purely alphanumeric occupies the same shape space as ordinary base64url state entropy and is still banner-exempt at an approved endpoint. Unavoidable without reintroducing the false positive; roughly 72% of random AWS secret keys contain a + or / and are now caught, and the general output redactors keep the heuristic for all of them regardless.
  2. The exemption now fails closed on base64-standard state — a provider that base64-standard-encodes state (percent-encoded %2F / %2B) will get a banner warning it did not get before. That is the deliberate direction: the two shapes are indistinguishable, and fix: scope OAuth entropy exemption to approved parameters #8051's own "legitimate entropy" fixture is standard-alphabet base64.b64encode(digest)[:40], passing only because that particular digest happened to contain no + or /.

Tests

  • Restored test_bare_aws_secret_inside_state_fails_closed_everywhere (the /-bearing BARE_AWS_SECRET, which the charset bound catches).
  • test_percent_encoded_secret_alphabet_cannot_buy_the_exemption — single-encoded %2F is flagged end to end.
  • test_no_encoding_depth_earns_the_entropy_exemption (single / double / triple / over-budget) — no encoding depth wins the exemption. Deliberately scoped to the predicate, with a comment saying so, because whether the banner then warns on a doubly-encoded run is a separate pre-existing property (see Manual verification) and this test must not appear to cover it.
  • test_off_length_challenge_loses_the_s256_exemption — a 40-char value in code_challenge is not a challenge shape.
  • test_markerless_secret_shape_is_banner_exempt_but_generically_redacted's state case now uses the alphanumeric secret rather than the /-bearing one, so it documents residual 1 instead of contradicting the restored test.
  • test_pkce_challenge_wrapping_bare_aws_secret_fails_closed was not restored: its fixture is a 43-char purely-alphanumeric value, indistinguishable from a real S256 challenge by shape. It falls inside residual 1 and no shape bound can catch it.

test/test_security.py: 1295 passed, 1 skipped.

Revert-verify. Reverting src/kiro_crew/security.py alone, leaving the tests: 7 failed without the fix, 7 pass with it — so each new test is load-bearing rather than passing incidentally.

Manual verification

The %252F banner blind spot is pre-existing and is NOT closed here. After this fix the exemption is correctly refused for a doubly-encoded secret, but the banner still does not warn, because the markerless bare-secret scan evaluates only the raw URL plus one unquote while _MAX_URL_DECODE_PASSES is 3. That is a property of the scan, not of the exemption, and it reproduces outside every OAuth exemption: %252F is equally unflagged in a parameter that was never exempt (&session_blob=) and at a completely unapproved endpoint (https://attacker.example/x?blob=), where no exemption code runs at all — on this branch and on the base alike. Closing it means deepening the scan loop for every parameter and every endpoint, which is a wider change than this PR's purpose; flagged for a maintainer rather than silently widened here.

F1 is not exploitable at the handler today. api_cron_script_source in src/kiro_crew/dashboard/handlers/cron.py never reads the session key, the route is already authenticated by the route-wide token_auth_middleware, the job id is the only caller-supplied input, and the file path is derived server-side from the stored job record.

Depth × parameter matrix, run against the built module: no encoding depth (literal, %2F, %252F, %2525…) wins the exemption for a /- or +-bearing secret in state, nonce, or code_challenge; and every legitimate fixture (opaque-state-123, a 40-char base64url state/nonce, both 43-char S256 challenges) stays exempt.

Screenshots / video

Why no screenshot: the only frontend change adds a request header argument to one API method; nothing rendered changes.

Related Issues

no linked issue: filed from the security watchdog queue rather than a tracked issue; #8051 is named in the body as the commit under repair, not as an issue to close.

Pattern harvest

Rule candidate: review-prompt
Pattern: a security predicate that judges a URL-derived value on the raw plus one decoded form, in a module that already establishes decode-until-stable bounded by _MAX_URL_DECODE_PASSES. Both defects in this PR are the same shape — an exemption widened without a bound on what it exempts — and the second was found only because a reviewer probed encoding depth. Any new "this value is safe to skip scanning" predicate should be required to state its decode depth explicitly.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of 9f2c6bf91341b13ae12b409057fd5c55ba4acf1b — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: PASS

A verified banner bypass, closed by bounding the exemption to protocol-emittable shapes rather than reverting #8051's false-positive fix — the right middle path.

The charset/length fence is well-derived (base64url can't emit +//; S256 is exactly 43 chars), the decode-until-stable predicate reuses the module's existing _MAX_URL_DECODE_PASSES idiom and fails closed at the bound, both residuals are stated honestly in the spec in the same commit, and the pre-existing %252F markerless-scan blind spot is correctly scoped out rather than silently half-fixed. F1 is a one-line invariant restore with its non-exploitability disclosed rather than overclaimed.

Suggestions

  • The 43-char rule now rejects code_challenge_method=plain challenges (44–128 unreserved chars), a fail-closed false-banner class the residuals don't name — either condition the length rule on the method parameter or add it to the spec's residual list.

[DESIGN-REVIEWED] 9f2c6bf

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of 9f2c6bf91341b13ae12b409057fd5c55ba4acf1b — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

This PR is almost entirely backend security-heuristic hardening (OAuth entropy shape fence in security.py, tests, spec update). The sole frontend change adds the session-key header to cronScript, matching every sibling call in client.ts (cronRunDetail, cronHistory already send { ..._sk }) — no new strings, surfaces, states, or screenshots to evaluate.

UX-Verdict: PASS

No user-facing surface changes; the one frontend line fixes cron-script fetches to authenticate like its siblings, unbreaking that view.

[UX-REVIEWED] 9f2c6bf

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 9f2c6bf91341b13ae12b409057fd5c55ba4acf1b — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All checks done. Composing the review.

First-Principles-Verdict: CONCERNS

F2 is a cause-level fix that earns every line; F1's premise — "the one call that did not" — is false, and the blessed get() helper it ignores is the real fix.

What this change ships

Intent: restore the operator's credential warning on OAuth consent URLs and the session-key header on the cron-script read — a FIX (two declared fixes, nothing rides along).

  1. Credential-shaped (+//-bearing) values in state/nonce/code_challenge trigger the banner again — justified, cause-level
  2. A non-43-char code_challenge loses the exemption — justified, protocol-derived (RFC 4648 §5 / S256)
  3. Over-encoded values fail closed at the decode bound — justified, mirrors existing _exfil_url_warning idiom
  4. Providers base64-standard-encoding state now get a warning they didn't — declared residual, deliberate direction
  5. Cron-script view sends the session-key header — declared, but symptom-level point patch (see Watch)
  6. security.md spec updated in the same commit — mandated by AGENTS.md
  7. Restored + new tests, revert-verified — part of the fix

Watch

  • The F1 justification is contradicted by the file: "every /api/crons/* call passes { headers: { ..._sk } }; cronScript was the one that did not" — updateCron (client.ts:2528) is a bare-fetch PATCH to /api/crons/:id with no _sk. Grepped fetch\('/api/cron: 8 call sites, 4 lack _sk (2523, 2528, 2552, 2555). One sibling fixed, three left, under a claim of zero.
  • Root cause is named by the file itself: methods "on raw fetch" bypass the transport helpers (client.ts:1561-1564); 2528/2555 also skip patch() and therefore trackArtifactWrite. The general fix is larger than this PR — accepted-and-deferred, but the description should not claim the invariant already holds.

Subtractions

  • cronScript (client.ts:2541): replace the hand-spelled { headers: { ..._sk } } raw fetch with the existing blessed get() helper (client.ts:1532), which injects _sk by construction — the mechanism that makes this class of omission impossible already exists in the file.

[FIRST-PRINCIPLES-REVIEWED] 9f2c6bf

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 9f2c6bf91341b13ae12b409057fd5c55ba4acf1b — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 9f2c6bf

Verdict parsed from the review's SHA-scoped output markers for commit 9f2c6bf91341b13ae12b409057fd5c55ba4acf1b.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 9f2c6bf91341b13ae12b409057fd5c55ba4acf1b: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 9f2c6bf91341b13ae12b409057fd5c55ba4acf1b and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 9f2c6bf

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 9f2c6bf91341b13ae12b409057fd5c55ba4acf1b: <one-sentence reason>

@iamwhatever
iamwhatever marked this pull request as ready for review September 3, 2026 21:22
@iamwhatever
iamwhatever requested a review from a team September 3, 2026 21:22
@iamwhatever
iamwhatever requested a review from a team as a code owner September 3, 2026 21:22
@iamwhatever
iamwhatever requested a review from dwu96 September 3, 2026 21:22
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 3, 2026
@iamwhatever
iamwhatever force-pushed the fix/sec-cronscript-header-state-fence-2026-09-03 branch from 16cdf9d to 165f8b6 Compare September 4, 2026 00:18
@iamwhatever

Copy link
Copy Markdown
Collaborator Author
  • One decode pass lets double-encoded bare secrets retain the OAuth entropy exemption (span=residual/security) — fixed in 165f8b63f.

Confirmed by running the payload, not by argument. The other lane rebutted this same mechanism on the grounds that _BARE_SECRET_RUN_RE cannot match after one decode; that rebuttal is wrong, because the value is blanked outright, so no pass ever sees it at any depth.

Decode iteratively through _MAX_URL_DECODE_PASSES, checking every form, and reject the exemption if another decode remains after the bound.

Taken as written. _oauth_entropy_value_is_protocol_shaped now decodes until the text stops changing, checks every intermediate form, and fails closed when a layer still remains at the bound — mirroring the existing idiom in _exfil_url_warning rather than adding a second one. Verified across a depth × parameter matrix: no depth (literal, %2F, %252F, %2525…) wins the exemption for a /- or +-bearing secret in state, nonce, or code_challenge, while every legitimate fixture stays exempt. Revert-verify: 7 fail without the fix, 7 pass with it.

One correction to the finding's stated outcome, because the PR body must not claim more than it delivers: refusing the exemption does not by itself make the banner warn on a doubly-encoded secret. The markerless scan evaluates only the raw URL plus one unquote while _MAX_URL_DECODE_PASSES is 3, and that gap is pre-existing and independent of the exemption — %252F is equally unflagged in a never-exempt parameter (&session_blob=) and at an unapproved endpoint where no exemption code runs, on this branch and on the base alike. Closing it means deepening the scan loop for every parameter and endpoint, which is wider than this PR; it is called out under Manual verification for a maintainer instead of being silently widened here. The new regression test is deliberately scoped to the predicate, with a comment saying so, so it cannot be misread as covering the scan.

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
@iamwhatever
iamwhatever force-pushed the fix/sec-cronscript-header-state-fence-2026-09-03 branch from 165f8b6 to 9f2c6bf Compare September 4, 2026 05:17
@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@iamwhatever
iamwhatever merged commit dcd2972 into main Sep 4, 2026
64 checks passed
@iamwhatever
iamwhatever deleted the fix/sec-cronscript-header-state-fence-2026-09-03 branch September 4, 2026 06:03
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 4, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator

Open PR relationship audit

This is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion.

Relationship findings

  • PR #7739 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #7739: REBASE. Different behaviors that complement each other; only a doc-line conflict and a shared predicate. Whichever merges second rebases the security.md bullet. Files: docs/system-specs/modules/security.md, src/kiro_crew/security.py.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

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.

3 participants