Skip to content

fix(security): bound upstream responses, scrub credentials and pin media origins - #245

Merged
rabesss merged 39 commits into
mainfrom
fix/upstream-hardening
Oct 2, 2026
Merged

rabesss merged 39 commits into
mainfrom
fix/upstream-hardening

Conversation

@rabesss

@rabesss rabesss commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Lands the security-hardening work that was sitting on unpushed local branches since v0.1.29. Three branches stack into this one (each contains the one before it):

  1. Upstream response limits. Caps upstream bodies: 10 MiB for course/lecture lists and playlists, 1 MiB for login and stream info, 4 KiB for decryption keys. Playlist line bytes are counted too.
  2. Redaction and login.
    • A credential-scrubbing layer covers errors, events and diagnostics, including encoded forms.
    • Login no longer follows redirects.
    • Base URLs (including the slides URL) are made canonical.
  3. Exact media origins.
    • The bearer token is sent only to the baseUrl origin and to an explicit mediaOrigins / IMPARTUS_MEDIA_ORIGINS allowlist. Requests to other hosts go without the token.
    • Cross-origin redirects are refused, and a remote plain-HTTP baseUrl is rejected.
    • This closes the redirect token leaks that are still open on main.

Base. The branch starts at the v0.1.29 release merge (59a0c60) and merges into current main without conflicts.

Checks.

  • go build ./... and go test ./... pass on the branch as-is and on the merge with main (go 1.26.5).
  • All 26 commits are by the noreply identity. Their diffs were scanned for tokens, personal data and real hosts, and nothing new turned up: only *.example.test, example.com and documentation IP ranges.

Behaviour change for API consumers. A consumer whose segments or keys are served from a host other than baseUrl must list that host in mediaOrigins / IMPARTUS_MEDIA_ORIGINS.

Left out.

@pullfrog

pullfrog Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using glm-5.3 | 𝕏

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@factory-droid

factory-droid Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


Two high-confidence regressions remain: unconfigured public HTTP media is rejected before its bearer token can be stripped, and overlapping nested URL matches can panic the credential sanitizer.

2 inline comments posted

@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Critical risk] Adds response size limits, credential scrubbing, and media origin validation to upstream requests.

The PR should not merge until public CDN URLs retain independent signing parameters when the Impartus bearer is withheld.

Findings

  1. P1 Signed CDN URLs lose signatures ▶
  2. P2 Valid configuration fails at login ▶

Summary

The PR bounds upstream responses, adds credential redaction and URL canonicalization, and restricts authenticated media requests to configured origins.

  • Signed public-CDN URLs can lose their own authorization parameters during bearer stripping.
  • Base-URL validation does not consistently enforce the new client-side path rules.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  P[Playlist segment or key URL] --> O{Origin configured?}
  O -- Yes --> A[Request with bearer]
  O -- No --> S[Remove bearer and credential-named query parameters]
  S --> C[Public CDN request]
  C --> F[Signed URL may fail without its signature]
Loading

Reviews (1) · Last reviewed commit: "Merge final direct URL redaction"

The health probes now build their URLs with client.BaseURLPath, so
ensureScheme has no production callers left and TestEnsureScheme only
covered dead code. Delete both.

Found by the MiMo v2.6 Flash review.
Escaped quoted keys look back for their opening quote as far as the key
budget at every delimiter, so even the linear scan runs a few tens of
times slower than its benign twin there. The old bound of ten times the
benign scan plus 20ms left little headroom, and the test failed on a
loaded machine. Allow twenty times plus 150ms and take the fastest of
five runs: the quadratic scan still fails every case by a wide margin.

Follow-up to the GPT-6.1 Sol review finding fixed in 1bcd002.
@factory-droid

factory-droid Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Droid encountered an error —— View job

Droid Exec exited with code 1:
Tool "Edit" skipped (GPT-5.6 Sol uses ApplyPatch instead).
Tool "Create" skipped (GPT-5.6 Sol uses ApplyPatch instead).

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No critical issues — one minor cleanup suggestion inline. The security boundaries in the new commits hold up under a full pass: go build ./... and go test ./... pass, and every bearer/redaction boundary in the delta is pinned by tests asserting exact outcomes.

Reviewed changes

  • Preserved CDN signing parameters on unconfigured origins and redirects — withholdBearerFromURL/stripBearerQueryParams now remove only query parameters that actually carry the bearer, so Signature, sig, key, Expires and friends reach the CDN byte-for-byte; tokenless requests forward Location queries the way net/http would, and a bearer split across query pairs still fails closed.
  • Stopped reporting unconfigured-origin refusals as login failures — a 401/403 from an origin outside mediaOrigins (requested without the bearer) now surfaces as *UnconfiguredMediaOriginError through the downloader, pipeline, playback stream and sanitizeUpstreamErr, telling the user to extend mediaOrigins instead of re-authenticating; authentication failures still win when both occur.
  • Scrubbed submitted credentials from login errors — secrets.SanitizeErrorWithCredentials removes the username and password (longest first, before the generic scrub) from transport/read/decode failures, covering an upstream that echoes the request body; oversized credentials fail closed to REDACTED rather than skipping replacement.
  • Redacted whole quoted assignment values — open-value patterns (auth=, bare =/: assignments) now extend through the closing quote via quotedCredentialEnd, so password="pw-one pw-two" no longer leaves the tail visible after the first delimiter, while unrelated context (next=1) is kept.
  • Bounded the canonical assignment scan to linear time — breakCursor memoizes break-free spans, quotedAssignmentKeyBounds caps the opening-quote lookback, and scheme-word scans stop after the longest known scheme; an adversarial test pins both the linearity bound and that redaction output is unchanged.
  • Raised maxPlaylistSegments to 50000 counting both views together — a dual-view lecture now fits under the cap, with coverage moved up from parsePlaylist to getPlaylist.
  • Rejected dot path segments in baseUrl at config-validation time — ./.. segments now fail where the config is checked, matching the client's canonicalBaseURL boundary.
  • Rebuilt health probes on the shared boundary — ensureScheme and string concatenation are gone; probes construct the profile URL via client.BaseURLPath (invalid config fails closed) and issue an origin-scoped authenticated GET, with upstreamTCPHost deriving the dial address from the validated URL.
  • Documented which event fields Writer.Emit scrubs — Error only, since Details and friends carry no upstream text for current callers.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using glm-5.3 | 𝕏

Comment thread internal/secrets/secrets_test.go Outdated
Comment thread internal/client/client.go
if err != nil || !bearerWithheld {
return resp, err
}
return unconfiguredOriginRefusal(resp, parsedURL)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION]: Refusal misattributes the refusing origin after a redirect to a configured origin

A bearerWithheld request starts on an unconfigured origin A, but allowsRedirect (internal/client/media_origin.go:125) still permits a cross-origin redirect to a configured origin B. When B answers 401/403, unconfiguredOriginRefusal(resp, parsedURL) computes the origin from the initial parsedURL, so the error reports "media origin A returned HTTP 401 ... it is not in mediaOrigins" even though A never answered - B refused the request. The remedial hint then points the user at the wrong host, and allowlisting A will not stop B from refusing.

Derive the origin from resp.Request.URL (the final request that produced this response) inside unconfiguredOriginRefusal, keeping the initial URL only as a fallback, so the reported origin matches the host that actually returned the status.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

A quoted value is replaced through its closing quote, past the end of
its regex match. When the next key followed that quote directly, as in
password="pw-one pw-two"sig="sig-one sig-two", the closing quote was
the next match's boundary, so the match began inside the replaced text
and was skipped, and part or all of the second value reached errors and
events. Search again from the end of each replacement instead, so a key
right after the quote is found and a key inside a quoted value is still
never treated as an assignment. The encoded paths already redacted
these shapes; the test now covers them along with SanitizeError.

Found by the GPT-6.1 Sol review.
An unconfigured media origin can redirect to another origin, even one in
mediaOrigins, and the 401 or 403 then comes from the destination. The
error said the starting origin returned that status. Keep naming the
starting origin as the one to add to mediaOrigins, and when the response
came from a different origin, say that the request ended in HTTP N from
that origin. Both are scheme://host[:port] only.

Found by the GPT-6.1 Sol and GLM 5.3 reviews.
@factory-droid

factory-droid Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Droid encountered an error —— View job

Droid Exec exited with code 1:
Tool "Edit" skipped (GPT-5.6 Sol uses ApplyPatch instead).
Tool "Create" skipped (GPT-5.6 Sol uses ApplyPatch instead).

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in the two new commits — both fixes verified, go vet ./..., go build ./... and go test -race -count=1 ./... all pass. The review is non-approving only because the prior cleanup suggestion (leftover debug block in internal/secrets/secrets_test.go:823) is still open and untouched by this delta; nothing in the new commits needs fixing.

Reviewed changes

  • Redacted adjacent quoted assignments with no separator — replaceCredentialValues now resumes its search from the end of each replacement rather than the end of each regex match, so a key that directly follows a quoted value's closing quote (password="pw-one pw-two"sig="sig-one sig-two") is redacted instead of skipped, while a key inside a quoted value remains part of that value. The committed test fails against the parent commit with real leaks (sig-one/sig-two fragments), and a broader probe (escaped quotes, mixed quote styles, multibyte values, colon and JSON forms) stays clean; the residual adjacent-shape over-redaction (cookie="…"authorization="…" swallows its tail) is identical at the parent, fail-closed, and pre-existing.
  • Named the origin that refused a redirected media request — UnconfiguredMediaOriginError gains RespondingOrigin, derived from resp.Request.URL and origin-normalized, so a tokenless request that starts at an unconfigured origin and is redirected elsewhere reports where the 401/403 actually came from while still naming the starting origin as the one to configure; same-origin refusals keep the single-origin message, and no downstream consumer pinned the old wording.

Posted via Pullfrog · View workflow run

Pullfrog  | Fix it ➔ | View workflow run | Using glm-5.3 | 𝕏

The encoded-authorization case dumped parser internals through t.Logf and
asserted nothing; TestScrub_RedactsEncodedFreeFormAssignmentsWithoutKnownToken
already pins that behaviour. (Kilo Code and Pullfrog review bots)
@factory-droid

factory-droid Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Droid encountered an error —— View job

Droid Exec exited with code 1:
Tool "Edit" skipped (GPT-5.6 Sol uses ApplyPatch instead).
Tool "Create" skipped (GPT-5.6 Sol uses ApplyPatch instead).

@rabesss
rabesss merged commit 5469971 into main Oct 2, 2026
24 of 25 checks passed

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found. The one open thread from earlier reviews — the leftover debug block in internal/secrets/secrets_test.go — is addressed by this delta, so nothing remains blocking. go vet ./..., go build ./... and go test -count=1 ./... all pass.

Reviewed changes

  • Removed the leftover debug logging from the scrub table test — the t.Logf scaffolding (decoded view dump, credential ranges, regex-match values, step pointers) guarding the encoded authorization case is deleted; zero t.Logf calls remain in secrets_test.go, and TestScrub_RedactsEncodedFreeFormAssignmentsWithoutKnownToken continues to pin that behavior with exact want strings.

Pullfrog  | View workflow run | Using glm-5.3 | 𝕏

@rabesss
rabesss deleted the fix/upstream-hardening branch October 2, 2026 09:32
// boundary. Any other match ends before a byte that cannot start a key, so
// resuming there finds the same matches as one search over the whole text.
for last < len(value) {
index := step.expression.FindStringSubmatchIndex(value[last:])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION]: Replacement rescan loop is worst-case quadratic on credential-dense input

The rewrite searches value[last:] once per replacement, so total scan cost is O(n × matches), where the previous single FindAllStringSubmatchIndex pass was linear. Text shaped like repeated auth=1& pairs advances last only ~7 bytes per iteration while each rescan walks an O(n) suffix, so a dense adversarial diagnostic reaching Scrub — the self-declared defense-in-depth boundary for logs, terminal output and durable error summaries, with no length cap in the function itself — costs ~n². This package already bounds its other scans for exactly this reason (the bounded quoted-key lookback in quotedAssignmentKeyBounds, the bounded scheme-word scan via maxCredentialSchemeBytes).

Smallest credible fix: bound each rescan window to a maxCredentialKeyRunes-plus-scheme lookback past last, mirroring those bounded lookbacks, or document the assumed input-size bound at the loop.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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