Skip to content

Add content-key seam for CDN content-key enforcement - #346

Merged
juwoncube merged 10 commits into
release/v4.3.4from
fm/content-key-seam-pinned
Aug 11, 2026
Merged

Add content-key seam for CDN content-key enforcement#346
juwoncube merged 10 commits into
release/v4.3.4from
fm/content-key-seam-pinned

Conversation

@juwoncube

@juwoncube juwoncube commented Aug 11, 2026

Copy link
Copy Markdown

What

  • Adds an optional, injectable header provider for ContentController content requests, scoped to the Storm content host(s), with headers re-scoped when a request is redirected.
  • Adds a refresh-and-retry-once hook that fires on 401/403 content responses.

No behaviour change for any app that does not inject a provider — ThunderCloud stays App-Check-agnostic.

Why

The ARC CDN now enforces a content key on Storm bundle downloads (ARCBLOOD-4351 / SEC-578; dev is already returning 403 to unauthenticated pulls, prod follows). ContentController's request controllers are internal, so a consuming app cannot attach the x-content-key header itself. This seam lets each app own its key lifecycle app-side and feed the framework.

Related

Notes for reviewers

  • Targets release/v4.3.4 (cut from release/v4.3.3) per the package release convention — no master merge.
  • Branch is based on e389b82, the revision Blood currently pins, so the diff is exactly the two seam commits.

Redirect hardening (added to this PR)

A real-device verification found that nothing stripped provider-injected headers on redirect: URLSession copies request headers onto the redirect target's request, so a provider header was carried across the 303 from the Storm API onto the CDN host. Both hosts are ours today and the download depends on that carry, but a CMS-authored redirect to a third-party host would have carried the key off-site.

The provider's host-scoping contract now extends across redirects. The allowed-host decision stays with the injected provider — the seam asks it about the new target url, never a hardcoded host list — so an app that grants both the API and CDN hosts keeps its working download, while a chain ending anywhere it does not vouch for arrives with the provider headers stripped.

Because background URLSession tasks follow redirects automatically and never call willPerformHTTPRedirection, and downloadPackage defaults to inBackground: true, live-delegate scoping alone would not have covered the bundle download path. Background downloads now pre-resolve the redirect chain on the default session with scoping applied per hop, then hand the final resolved url to the background transfer.

Verification: device e2e re-proof at branch tip passed (data/blood-e2e-seamfix-verify/report.md).

Known follow-ups (accepted, not fixed here)

  • Background redirect tests do not pin the invariant. The two new background tests assert only the pre-resolution hops, which the existing inBackground: false tests already cover. They would still pass if the resolved url were ignored, so the pre-resolution behaviour is implemented but not test-verified.
  • Background downloads now wait on a foreground round trip. Pre-resolution runs on the default session before the transfer daemon takes over, so a background download no longer survives app suspension or termination as immediately as it did on the RequestController path.
  • Redirects from the resolved url are still unscoped. The background request against the resolved url is followed by the transfer daemon with no delegate, so a url that answers 200 at resolution time and redirects on the real request would carry the header onward.
  • HTTP status detail is collapsed. contentRequestError(for:) returns .invalidResponse for every error status, where the RequestController path returned HTTP.Error carrying the status code. Apps on the provider path can no longer tell a 404 from a 500.
  • Relaunch path requires the provider to be set synchronously. handleEventsForBackgroundURLSession can only rebuild the session if contentRequestHeaderProvider is already set; an app that configures it asynchronously will miss queued background events.

Notes for future work on this seam

  • PRs here target the active release/* branch, never master. Automation that defaults to the repository's default branch will open a master-targeted PR — check the base before relying on a tool-created one.
  • Background URLSession tasks follow redirects automatically and never call willPerformHTTPRedirection, and downloadPackage defaults to inBackground: true. Any per-redirect logic therefore has to be applied by pre-resolving the chain on a default session before the final url is handed to the background transfer; a test that forces inBackground: false will not catch the gap.

juwoncube and others added 2 commits August 10, 2026 13:11
`ContentController` now exposes two opt-in hooks so an app can attach its own
headers to the requests Storm makes for content, without ThunderCloud needing
to know anything about where those headers come from:

- `contentRequestHeaderProvider` is consulted before every content request
  (update check, delta download and full bundle download) and its headers are
  merged onto the outgoing request. It is passed the url the request will be
  sent to so an app can scope what it returns to a particular host.
- `contentAuthFailureHandler` is called when a content request comes back 401
  or 403, giving the app a chance to refresh whatever the provider returns
  before the request is re-sent. The request is only ever re-sent once.

Both default to nil, in which case content requests are made exactly as they
were before.

The response handling for the update check and the bundle download moves into
`finishUpdateCheck(...)` and `finishDownload(...)` so it can be reached either
directly or after a retry. The logic is unchanged.

Tests cover the headers reaching the outgoing request on all three request
paths, 401 and 403 each causing exactly one refresh and one retry, and nil
hooks leaving requests untouched. They intercept the requests with a
`URLProtocol` stub, which needs `URLSessionConfiguration.default` swizzling
because `RequestController` builds its sessions internally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`URLSession` copies a request's headers onto the request it sends to a
redirect's target, including when that target is on a different host. A content
request can be redirected off the Storm API onto whichever host serves the
bundle, so a header an app only meant for one host was being sent on to
whichever host the redirect named.

`ContentRequestSession` performs content requests and asks
`contentRequestHeaderProvider` what should be sent each time it is about to
follow a redirect, removing any header the provider doesn't return for the new
url before the request is sent. An app can therefore scope a header to the
hosts it trusts and rely on it not leaving them.

This session is only used when a provider has been set. With no provider
`ContentController` still makes its requests through `RequestController`
exactly as it always has, so apps which don't use the hook are unaffected.

Downloads made in the background use a session with a fixed identifier, which
`handleEventsForBackgroundURLSession(session:completionHandler:)` now
recognises so the events for it are still delivered after a relaunch.

Tests cover a redirect to a host the provider does not vouch for arriving
without the header, and a redirect to one it does vouch for still carrying it,
for both the update check and a bundle download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@juwoncube
juwoncube requested a review from a team as a code owner August 11, 2026 08:53
@juwoncube
juwoncube requested review from 3scsamdavis, JonValijonov and Szuyun-3SC and removed request for a team August 11, 2026 08:53
juwoncube and others added 3 commits August 11, 2026 14:48
Records the sharp edges this branch's work turned up: PRs target the active
release branch rather than master, and any per-redirect logic has to be applied
by pre-resolving the chain on a default session, because background URLSession
tasks follow redirects without a delegate and content downloads default to the
background session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…key-seam-pinned

# Conflicts:
#	ThunderCloud.xcodeproj/project.pbxproj
@juwoncube
juwoncube merged commit fddc47e into release/v4.3.4 Aug 11, 2026
1 check passed
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.

2 participants