diff --git a/.env.dev b/.env.dev index c74756ed2f..347bc0a359 100644 --- a/.env.dev +++ b/.env.dev @@ -9,7 +9,6 @@ TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=http://localhost:9090 # [synthetic] TRUSTED_SERVER__SYNTHETIC__COUNTER_STORE=counter_store -TRUSTED_SERVER__SYNTHETIC__OPID_STORE=opid_store # [proxy] # Disable TLS certificate verification for local dev with self-signed certs diff --git a/.env.example b/.env.example index 8a874e292c..6dcbfa9c4c 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,6 @@ TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com # Synthetic ID Settings # ============================================================================= TRUSTED_SERVER__SYNTHETIC__COUNTER_STORE=counter_store -TRUSTED_SERVER__SYNTHETIC__OPID_STORE=opid_store # Template variables: client_ip, user_agent, first_party_id, auth_user_id, publisher_domain, accept_language TRUSTED_SERVER__SYNTHETIC__TEMPLATE={{ client_ip }}:{{ user_agent }}:{{ first_party_id }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1123ef771..1b6a8a0e18 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,9 +62,6 @@ jobs: - name: Run inline control harness run: BID_DELAY=3 ./scripts/template-cache-local-test.sh inline - - name: Run cache purge harness - run: BID_DELAY=3 ./scripts/template-cache-local-test.sh purge - - name: Run config by host harness run: ./scripts/config-by-host-local-test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e017fb10d..b5dd83df1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The DataDome module asks its Protection API about a publisher's pages beneath `/admin/` like any other page. It skipped every path beneath `/admin/` and `/_ts/admin/`, which left a publisher's own `/admin/` pages out of bot protection. +- **Breaking:** `ts cache purge` asks the hosting platform to purge instead of calling the service. It takes `--service-id ` where it took `--service `, reads a Fastly API token from `FASTLY_API_TOKEN` where it read `TRUSTED_SERVER_ADMIN_PASSWORD`, and no longer takes `--username`. It runs `fastly service purge --key`, which needs Fastly CLI 14.0.0 or later, with the surrogate key it derives by the function the service tags its cached objects with, so `--all` and `--page ` purge exactly what they purged before. - **Breaking:** A module's change to a page runs only on the pages where a `[[fetch]]` or `[[serve]]` entry names its middleware. A deployment adds the entries when it upgrades. Until it does, a module changes no page, and startup logs a warning naming each middleware that no entry names. "Placing page changes" in the configuration guide lists the middleware each module supplies and the phase each runs in. To keep a page as it was, name the middleware of the modules a deployment selects in one `[[fetch]]` entry and one `[[serve]]` entry for `text/html`, in the order `trusted-server.example.toml` shows them, which is the order the modules' hooks ran in. - **Breaking:** The four page traits are removed, being `IntegrationAttributeRewriter`, `IntegrationScriptRewriter`, `IntegrationHtmlStreamProcessorFactory` and `IntegrationHeadInjector`, with their context types, `ScriptTextAccumulator`, `AttributeRewriteOutcome` and the registration methods that took them. A module that implemented one supplies a `Middleware` instead, whose action carries the same decisions. A browser module's attribute on the script bundle's tag is stated with `with_bundle_tag_attribute`. - A pushed configuration keeps DataDome's two deprecated secret store selectors, `server_side_key_secret_store` and `protection_test_bypass.credential_secret_store`, where they were removed as the settings were read. DataDome still accepts and discards both, so a configuration that carries one loads as before. @@ -42,7 +44,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias. - The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave. - **Breaking:** Auction providers and bidder routes now use the configuration-first `[auction.providers.]` and `[auction.bidders.]` maps. The removed `[auction].providers = [...]` list and removed server fields under `[integrations.prebid]` and `[integrations.aps]` are rejected even when those integrations are disabled, and `ts config push` rejects the old shape before publication. Move PBS `server_url` to provider `endpoint`, server timeout to provider `timeout_ms`, request controls and bidder-parameter overrides to the `prebid-server` `profile_config`, notification suppression to `notifications`, and each former server bidder to an `[auction.bidders.]` route. Move APS endpoint, timeout, account, inventory, debug, and creative controls to an `aps` provider and its `profile_config`. Browser Prebid settings remain under `[integrations.prebid]`; values such as timeout and debug that previously affected both browser and server behavior must now be configured for each owner. Provider endpoints must be absolute HTTPS URLs. Only bidder codes present in `[auction.bidders]` are folded into Trusted Server requests; unlisted publisher bids remain native browser demand. Provider response names now use the configured provider ID, such as `pbs-main`, instead of the legacy literal `prebid`; audit consumers that match `AuctionResponse.provider`. This schema has no mixed-version-safe deployment order: old binaries reject the maps and new binaries reject the retired fields, so activate the new binary and config blob together. Rollbacks must restore an old-schema blob together with the old binary. -- **Breaking** — Admin Basic-auth coverage now includes `GET /_ts/admin/ec`, `GET /_ts/admin/ec/{id}`, and `GET /_ts/admin/eids`. Existing configurations whose `[[handlers]]` patterns protect only the key-management endpoints now fail startup; broaden coverage before deploying, preferably with a namespace-boundary pattern such as `^/_ts/admin(?:/|$)`. Coverage of the dynamic `/_ts/admin/ec/{id}` route is no longer inferred from ID-shaped samples: the router accepts any segment after `/_ts/admin/ec/` and Basic Auth runs on the raw path before routing, so patterns anchored to the EC ID grammar (for example `^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$`) are rejected in favor of a prefix-level matcher. Placeholder and well-known weak handler passwords (`changeme`, `password`, `admin`, `replace-with-…`) now fail startup on every handler rather than only on handlers inferred to cover an admin endpoint, because first-match-wins handler selection lets a narrow handler shadow the admin namespace. - Prebid Server provider endpoints now normalize origin-only legacy `server_url` values to `/openrtb2/auction`. Query parameters are preserved, the canonical path loses a trailing slash, and configured non-root custom paths remain exact. - Publisher HTML uses the browser-only `Cache-Control: private, max-age=60` policy for successful GET document responses and their `304 Not Modified` revalidations when server-side ad templates are structurally inactive, while preserving origin `private`/`no-store` policies and request-scoped bot, prefetch, or consent-denied responses. The `private` directive prevents shared caches that use `Cache-Control` from storing the document. Cookie-bearing responses using the generated inactive policy are finalized as `private, max-age=0`; CDN-specific cache headers remain unchanged and continue to control supporting CDNs independently. Set `[creative_opportunities].enabled = false` to disable publisher HTML and SPA template delivery without disabling direct `POST /auction` callers; an absent configuration, an unmatched slot, or a disabled auction also make the stack structurally inactive. An explicit `enabled = false` is not compatible with older binaries: restore the default, re-push and finalize the config before rolling back. - **Breaking** — Replaced the legacy APS contextual integration with APS OpenRTB at `/e/pb/bid`. APS configuration now uses canonical `account_id` (`pub_id` remains a compatibility alias), no longer requires APS-specific slot IDs, and defaults script creative eligibility off. Operators must update the endpoint, disable native APS demand for Trusted Server cohorts, and prepare GAM/Universal Creative targeting for `hb_bidder=aps` before rollout. `aps` entries in Prebid bidder lists are logged and stripped. APS renderer winners now preserve the upstream bid `id`, omit `crid` when APS omits it, and carry `ext.trusted_server.renderer` instead of `adm`; external `/auction` consumers must support this response shape. @@ -51,15 +52,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking**, Integration configuration strings are no longer globally reinterpreted as JSON scalars. Operators upgrading should audit each module's settings table and use native TOML/typed-config booleans and numbers (for example, `rewrite_sdk = true`, not `rewrite_sdk = "true"`), and quoted numeric and boolean scalars now fail validation instead of silently converting. - **Breaking**, Sourcepoint browser module inclusion now requires selecting `sourcepoint` in `[cmp]`, so operators relying on the previous unconditional Sourcepoint module should select it before upgrading. - **Breaking** — Auction creative sanitization is now opt-in: the new `[auction].sanitize_creatives` defaults to `false` because unconditional sanitization blanked script-based creatives (the majority of programmatic display) while recording normal impressions. `[auction].rewrite_creatives` keeps its `true` default. The per-creative cap is now enforced on rewritten output as well as raw input and in every processing mode (1 MiB for auction `adm`; proxied HTML documents keep the proxy's own 10 MiB bound), rewriting fails closed on parser errors instead of emitting partial output and never turns a rejected creative into a runtime-only `adm`, and `hb_cache_host`/`hb_cache_path` are emitted only for bids that supplied no creative — any bid carrying its own `adm` ships without them, so a processed or rejected creative can never be re-fetched raw from PBS Cache. Creative markup with no `` token now receives the click-guard runtime, and bidder `` elements are stripped whenever rewriting is enabled. The creative iframe sandbox no longer grants `allow-same-origin`, restoring origin isolation; rewritten-click recovery from the resulting opaque-origin iframe uses the GET `/first-party/proxy-rebuild` navigation fallback, now registered in every adapter and documented alongside the POST JSON form. Inside those iframes, dynamic resource signing and CORS-mode subresources (ES modules, `crossorigin` fonts) are unavailable pending the constrained asset capability in [#982](https://github.com/IABTechLab/trusted-server/issues/982); ordinary image, script, and stylesheet loads are unaffected. Upgrading: binaries that predate `sanitize_creatives` reject a blob carrying it, so upgrade the binary first, then push the config. Rollback: non-default values (`sanitize_creatives = true`, `rewrite_creatives = false`) are serialized into the config blob and older binaries reject unknown fields — before rolling back to a binary that predates a field, restore its default, push the default-compatible blob, then roll back. -- The SPA re-auction endpoint moved from `/__ts/page-bids` to `/_ts/page-bids`, joining every other internal route in the `/_ts/` namespace. The old path stays registered as a deprecated alias so already-loaded bundles keep serving ads, and responses on it carry a `Link: …; rel="deprecation"` header so remaining traffic is measurable from edge logs; removal is tracked in [#970](https://github.com/IABTechLab/trusted-server/issues/970). Two deployment notes: audit `[[handlers]]` for patterns broad enough to cover `/_ts` (for example `^/_ts`), which would put this browser-facing endpoint behind Basic Auth and return `401` to every visitor — scope them to `^/_ts/admin`; and prefer rolling forward over rolling back, since a server reverted past this release does not register the canonical path. In both cases the shipped client falls back to the deprecated alias, so the exposure is bounded until that alias is removed. +- The SPA re-auction endpoint moved from `/__ts/page-bids` to `/_ts/page-bids`, joining every other internal route in the `/_ts/` namespace. The old path stays registered as a deprecated alias so already-loaded bundles keep serving ads, and responses on it carry a `Link: …; rel="deprecation"` header so remaining traffic is measurable from edge logs; removal is tracked in [#970](https://github.com/IABTechLab/trusted-server/issues/970). One deployment note: prefer rolling forward over rolling back, since a server reverted past this release does not register the canonical path. The shipped client falls back to the deprecated alias in that case, so the exposure is bounded until that alias is removed. - Added optional APS `inventory_domain` and `inventory_page_origin` overrides for deployments whose edge hostname differs from the APS-authorized inventory identity. - Preserved APS renderer capabilities through the client-side `trustedServer` Prebid adapter, allowing its generated `hb_adid` to render through GAM and Prebid Universal Creative instead of producing an empty creative. +### Removed + +- **Breaking:** HTTP Basic authentication is removed, with the `[[handlers]]` list that configured it. No request is asked for a password, and nothing is served beneath `/_ts/admin`. A configuration that still carries `[[handlers]]` is refused, with directions, when it is validated and when the service starts, so that no deployment believes a path is protected when it is not. A build before this change refuses a configuration without a handler covering `/_ts/admin`, and one with `[request_signing]` and no store ids, so no one configuration starts both builds: the new configuration and the new build go live together. A staged version (`ts deploy --staging`) reads the same configuration key from the config store it links, so a staging store of its own, written with `ts config push --staging`, lets the pair be checked together first. Put a password on a path of the publisher's own at its origin or in the hosting platform. The `handler_password` secret is no longer read, and its entries in `fastly.toml`, the Spin manifest and the smoke scripts are removed. +- **Breaking:** `POST /_ts/admin/cache/purge` is removed. It let anyone holding one shared password empty the service's cache over the publisher's own domain. Purge with `ts cache purge`, which asks the hosting platform. A request to the removed path is answered `404` by the server itself and is never forwarded to the origin. +- The Fastly adapter's management API client is removed. The deployed service no longer reads a Fastly API token from a secret store named `api-keys`, which it used to write its own config store and secret store when a key was rotated. A deployment that linked that store can unlink it and revoke the token. `fastly.toml` and the Viceroy template drop their `api-keys` entries. +- **Breaking:** `[request_signing]` does not take `config_store_id` or `secret_store_id`. Only the removed key rotation routes, and the checks that both were set, read them, and the service finds its keys in the stores linked as `jwks_store` and `signing_keys`. A configuration that still carries either key is refused, with directions, when it is validated and when the service starts. Remove both, and give the ids to `ts keys rotate` and `ts keys deactivate` as `--config-store-id` and `--secret-store-id`. Enabling request signing no longer requires them. +- **Breaking:** `POST /_ts/admin/keys/rotate` and `POST /_ts/admin/keys/deactivate` are removed. They let anyone holding one shared password replace the keys the service signs with, over the publisher's own domain. Rotate and retire keys with `ts keys rotate` and `ts keys deactivate`, which write the two signing stores through the `fastly` CLI with an operator's own token. A request to either removed path, or to the `/admin/keys/rotate` and `/admin/keys/deactivate` aliases, is answered `404` by the server itself and is never forwarded to the origin. +- **Breaking:** `GET /_ts/admin/ec`, `GET /_ts/admin/ec/{id}` and `GET /_ts/admin/eids` are removed. They read any reader's identity graph record, and the cookies of whoever called them, on the publisher's own domain behind a shared password. `GET /_ts/data` shows a reader what is held against that reader's own Edge Cookie, and looks up no record by id and echoes no cookie. A request to one of the removed paths is answered `404` by the server itself and is never forwarded to the origin, as for any other path beneath `/_ts/admin`. + ### Security - A module's table that cannot be read or fails its validation is refused naming the setting at fault and never its value, because at startup the table holds the module's secrets resolved from the secret store and each adapter logs a refusal whole. - `/first-party/sign` now rejects valid targets outside `proxy.allowed_domains` before minting a proxy token. The creative runtime keeps image and iframe assignments blocked after this `403` policy response instead of loading the rejected URL directly; fetch-time checks still cover the initial target and every redirect. -- Reserved the complete admin namespace at the publisher-fallback boundary. Percent-encoded separators (`/_ts/admin%2Fec`, `%2f`, and double-encoded forms) matched the `^/_ts/admin` Basic-auth handler but escaped the literal-slash namespace check, so an authenticated request fell through to publisher fallback and forwarded its `Authorization` header and body to the publisher origin. The reservation now spans the whole `/_ts/admin` prefix plus the retired `/admin/keys` aliases — including trailing, descendant, and encoded-separator forms — evaluated on the raw path and on each of its bounded percent-decodings, so multi-encoded separators such as `/admin%252Fkeys/rotate` cannot survive to fallback for a proxy or origin to decode again, and applies to every adapter. +- The `/_ts/admin` prefix and the `/admin/keys` aliases are closed at the publisher-fallback boundary on every adapter. A request to either, including trailing, descendant and encoded-separator forms, is answered `404` by the server itself, so a caller's `Authorization` header and body are never forwarded to the publisher origin. The path is evaluated as sent and on each of its bounded percent-decodings, so multi-encoded separators such as `/admin%252Fkeys/rotate` cannot survive to fallback for a proxy or origin to decode again. - Validate synthetic ID format on inbound values from the `x-synthetic-id` header and `synthetic_id` cookie; values that do not match the expected format (`64-hex-hmac.6-alphanumeric-suffix`) are discarded and a fresh ID is generated rather than forwarded to response headers, cookies, or third-party APIs ### Fixed @@ -76,7 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- One Fastly service can serve several publishers. A `TS__CONFIG__KEY` entry in the `trusted_server_config` store that contains `{host}` names an app-config blob for each host, pushed with `ts config push --local --key `, and each request reads the blob under its own host. Each host's blob names its permission policy file and pins its SHA-256, and the push keeps each file `--permissions` supplies once, in a shared entry under `permissions_`, written before the blob. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, a malformed selector is answered `500`, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. Without the entry, or with a selector that has no placeholder, the service reads one blob. `scripts/config-by-host-local-test.sh` runs it under Viceroy. +- `ts keys rotate` and `ts keys deactivate` rotate and retire the request signing keys from an operator's machine. They write the two stores the service links as `jwks_store` and `signing_keys` through the `fastly` CLI, with the token from `FASTLY_API_TOKEN`. A private key reaches `fastly` on its standard input and never in its arguments, and a key id no platform can store is refused before anything is written. A command that cannot read the config store writes nothing, and a delete that failed part way can be run again. +- One Fastly service can serve several publishers. A `TS__CONFIG__KEY` entry in the `trusted_server_config` store that contains `{host}` names an app-config blob for each host, pushed with `ts config push --key `, and each request reads the blob under its own host. Each host's blob names its permission policy file and pins its SHA-256, and the push keeps each file `--permissions` supplies once, in a shared entry under `permissions_`, written before the blob, which a live push writes a minute after it writes an entry. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, a malformed selector is answered `500`, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. Without the entry, or with a selector that has no placeholder, the service reads one blob. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. `trusted-server-modules` lists those middleware with `middleware()` and `middleware_for(...)`. - Page changes as middleware. A module registers a change it makes to a page with `.with_middleware(...)`, and the ordered `[[fetch]]` and `[[serve]]` entries of the settings say which pages each one runs on and in what order. A fetch middleware runs on the page as the origin sent it and what it writes is stored with a shared template. A serve middleware runs on each reader's copy, whether the page came from the store or from the origin, and nothing it writes is stored. Where text a middleware writes into a script would end the script, or begin ` bool { || path.starts_with("/first-party/") || path == "/.well-known/trusted-server.json" || path == "/verify-signature" - || path.starts_with("/admin/") - || path.starts_with("/_ts/admin/") || path == "/_ts/api/v1/identify" || path == "/_ts/api/v1/batch-sync" } @@ -950,6 +948,27 @@ mod tests { .is_some() } + #[test] + fn only_the_servers_own_paths_are_internal() { + for path in [ + "/static/tsjs=tsjs-core.min.js", + "/integrations/example/script.js", + "/first-party/proxy", + "/.well-known/trusted-server.json", + "/verify-signature", + "/_ts/api/v1/identify", + "/_ts/api/v1/batch-sync", + ] { + assert!(is_internal_path(path), "{path} should skip protection"); + } + for path in ["/", "/article", "/admin/", "/admin/login", "/wp-admin/"] { + assert!( + !is_internal_path(path), + "{path} is the publisher's own, so protection should be asked about it" + ); + } + } + #[test] fn protection_test_bypass_skips_api_suppresses_tag_and_strips_header() { let config = DataDomeConfig { diff --git a/crates/tag/google-tag-manager/src/lib.rs b/crates/tag/google-tag-manager/src/lib.rs index c97cd58aca..023c0449ae 100644 --- a/crates/tag/google-tag-manager/src/lib.rs +++ b/crates/tag/google-tag-manager/src/lib.rs @@ -3050,11 +3050,6 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= #[test] fn test_config_parsing() { let toml_str = r#" -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -3090,11 +3085,6 @@ assume_single_jurisdiction = true #[test] fn a_block_for_an_unnamed_integration_is_refused() { let toml_str = r#" -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 295d24b4f5..2012e502f1 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -872,11 +872,6 @@ mod tests { fn settings_with_probe(body: &str) -> Settings { Settings::from_toml(&format!( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -1120,11 +1115,6 @@ mod tests { fn validate_reports_not_selected_without_a_module_entry() { let settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 8ed805c155..a2d3dd0696 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -14,12 +14,9 @@ use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, }; use trusted_server_core::cache_policy::EdgeCacheHeader; +use trusted_server_core::closed_paths::closed_path_response; use trusted_server_core::ec::EcContext; -use trusted_server_core::ec::admin::{ - admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, -}; use trusted_server_core::ec::module::ensure_module_available; -use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::inspect::config::{CONFIG_JSON_PATH, CONFIG_PAGE_PATH, handle_config}; use trusted_server_core::inspect::data::{DATA_PAGE_PATH, handle_data}; @@ -48,7 +45,7 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::{PolicyOrigin, RuntimeServices}; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; +use crate::middleware::{FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::{ AxumPlatformConfigStore, AxumPlatformSecretStore, AxumPolicyFolder, build_runtime_services, }; @@ -286,23 +283,6 @@ pub(crate) fn http_error(report: &Report) -> Response { response } -/// Builds the local `404 Not Found` returned for legacy `/admin/keys/*` -/// aliases on the Axum dev server. -/// -/// These non-`/_ts` aliases are not matched by the `^/_ts/admin` basic-auth -/// handler, so they fail closed locally rather than fall through to the -/// publisher fallback — which would forward the caller's `Authorization` header -/// and key-management payload to the origin, leaking admin credentials. -fn legacy_admin_alias_denied() -> Response { - let mut response = Response::new(edgezero_core::body::Body::from("Not found\n")); - *response.status_mut() = StatusCode::NOT_FOUND; - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - // --------------------------------------------------------------------------- // Shared handler executor // --------------------------------------------------------------------------- @@ -371,8 +351,9 @@ async fn build_ec_context( /// Dispatches the fallback routes: tsjs assets, integration proxy routes, and /// the publisher origin. /// -/// The request arrives already prepared. Every route that reaches here is -/// registered to [`fallback_handler`], which runs the request through +/// The request arrives already prepared, and is not to a closed path. Every +/// route that reaches here is registered to [`fallback_handler`], which +/// answers a closed path itself and runs any other request through /// [`execute_handler`], and [`execute_handler`] calls /// `registry.prepare_request` before it calls this function, so there is /// nothing left to prepare here. @@ -381,10 +362,6 @@ async fn dispatch_fallback( services: &RuntimeServices, req: Request, ) -> Result> { - if let Some(response) = deny_admin_diagnostic_fallback(&req) { - return Ok(response); - } - let path = req.uri().path().to_string(); let method = req.method().clone(); @@ -452,7 +429,12 @@ async fn dispatch_fallback( fn fallback_handler( state: Arc, ) -> impl Fn(RequestContext) -> HandlerFuture + Clone + Send + Sync + 'static { - move |ctx: RequestContext| { + move |ctx: RequestContext| -> HandlerFuture { + // A closed path is answered before the modules' preparers run, as the + // other adapters answer it, so no preparer sees or answers it. + if let Some(response) = closed_path_response(ctx.request()) { + return Box::pin(async move { Ok::<_, EdgeError>(response) }); + } let state = Arc::clone(&state); Box::pin(execute_handler( state, @@ -473,13 +455,6 @@ enum NamedRouteHandler { Permissions, Config, Data, - AdminNotSupported, - CachePurgeNotSupported, - AdminEcNotSupported, - AdminEidsLookup, - /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never - /// reach the publisher fallback (which would leak admin credentials). - LegacyAdminDenied, Auction, PageBids, FirstPartyProxy, @@ -494,17 +469,7 @@ struct NamedRoute { handler: NamedRouteHandler, } -const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ - Method::GET, - Method::POST, - Method::HEAD, - Method::OPTIONS, - Method::PUT, - Method::PATCH, - Method::DELETE, -]; - -fn named_routes() -> [NamedRoute; 22] { +fn named_routes() -> [NamedRoute; 14] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -545,63 +510,6 @@ fn named_routes() -> [NamedRoute; 22] { primary_methods: &[Method::GET], handler: NamedRouteHandler::Data, }, - // Canonical admin key routes. These match `Settings::ADMIN_ENDPOINTS` - // and the production basic-auth handler regex (`^/_ts/admin`), so they - // are auth-gated under a production-shaped config. - NamedRoute { - path: "/_ts/admin/keys/rotate", - primary_methods: &[Method::POST], - handler: NamedRouteHandler::AdminNotSupported, - }, - NamedRoute { - path: "/_ts/admin/keys/deactivate", - primary_methods: &[Method::POST], - handler: NamedRouteHandler::AdminNotSupported, - }, - // Every method, for the same reason as the Fastly adapter: a method this route - // does not claim falls through to the publisher with the caller's `Authorization` - // header still attached. - NamedRoute { - path: "/_ts/admin/cache/purge", - primary_methods: LEGACY_ADMIN_DENY_METHODS, - handler: NamedRouteHandler::CachePurgeNotSupported, - }, - // Admin EC lookup routes. Registered explicitly (like the key routes - // above) so they never fall through to the publisher fallback, and - // they match `Settings::ADMIN_ENDPOINTS` for auth coverage. - NamedRoute { - path: "/_ts/admin/ec", - primary_methods: &[Method::GET], - handler: NamedRouteHandler::AdminEcNotSupported, - }, - NamedRoute { - path: "/_ts/admin/ec/{id}", - primary_methods: &[Method::GET], - handler: NamedRouteHandler::AdminEcNotSupported, - }, - // Admin EIDs echo: pure request inspection (no KV), so the dev - // server serves the real handler. - NamedRoute { - path: "/_ts/admin/eids", - primary_methods: &[Method::GET], - handler: NamedRouteHandler::AdminEidsLookup, - }, - // The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with - // a 404, matching the Fastly and Cloudflare adapters: the production - // basic-auth handler regex `^/_ts/admin` does not match them, and letting - // any publisher-fallback method fall through would forward the caller's - // `Authorization` header and key-management payload to the origin, - // leaking admin credentials. - NamedRoute { - path: "/admin/keys/rotate", - primary_methods: LEGACY_ADMIN_DENY_METHODS, - handler: NamedRouteHandler::LegacyAdminDenied, - }, - NamedRoute { - path: "/admin/keys/deactivate", - primary_methods: LEGACY_ADMIN_DENY_METHODS, - handler: NamedRouteHandler::LegacyAdminDenied, - }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -671,49 +579,6 @@ fn named_route_handler( } NamedRouteHandler::Config => Ok(handle_config(&state.settings, &req)), NamedRouteHandler::Data => handle_data(None, None, &req), - NamedRouteHandler::CachePurgeNotSupported => { - // The Axum dev server has no template cache to purge. 501 rather - // than a fallthrough 404, so a CMS webhook can tell "not supported - // here" from "endpoint does not exist". - let body = edgezero_core::body::Body::from( - "Template cache purge is not supported on the Axum dev server.\n\ - Use the Fastly adapter (via Viceroy or deployed) to purge.\n", - ); - let mut resp = Response::new(body); - *resp.status_mut() = StatusCode::NOT_IMPLEMENTED; - resp.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - Ok(resp) - } - NamedRouteHandler::AdminNotSupported => { - // Config/secret-store writes are backed by read-only env vars on the - // Axum dev server. Returning 501 is clearer than failing on the first - // store write. - let body = edgezero_core::body::Body::from( - "Admin key management is not supported on the Axum dev server.\n\ - Use the Fastly adapter (via Viceroy or deployed) to rotate or deactivate keys.\n", - ); - let mut resp = Response::new(body); - *resp.status_mut() = StatusCode::NOT_IMPLEMENTED; - resp.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - Ok(resp) - } - NamedRouteHandler::AdminEcNotSupported => { - // The EC identity graph is Fastly KV backed; the Axum - // dev server has no store to read. - Ok(admin_ec_lookup_not_supported()) - } - NamedRouteHandler::AdminEidsLookup => { - let partner_registry = - PartnerRegistry::from_config(&state.settings.ec.partners)?; - handle_admin_eids_lookup(&partner_registry, &req) - } - NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), NamedRouteHandler::Auction => { // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` @@ -915,8 +780,7 @@ fn build_router(state: &Arc) -> RouterService { // any middleware registered ahead of it would observe the // shared-secret authentication header. .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) - .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) - .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); + .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))); router = router.route("/health", Method::GET, |_ctx: RequestContext| async { Ok::( @@ -966,11 +830,6 @@ mod tests { /// inject, with the `[ec.acme]` block that module's settings live in. /// `acme` is a fictional vendor key. const UNINJECTED_MODULE_TOML: &str = r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -1058,8 +917,6 @@ mod tests { use super::*; use crate::platform::{PERMISSIONS_DIR_ENV_VAR, config_env_var}; - const HANDLER_PASSWORD_VAR: &str = - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD"; const PROXY_SECRET_VAR: &str = "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET"; @@ -1068,11 +925,6 @@ mod tests { fn startup_settings() -> Settings { Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "handler_password" - [publisher] domain = "publisher.example" cookie_domain = ".publisher.example" @@ -1113,10 +965,6 @@ mod tests { temp_env::with_vars( [ (config_var.as_str(), Some(blob)), - ( - HANDLER_PASSWORD_VAR, - Some("a-handler-password-for-the-startup-tests"), - ), ( PROXY_SECRET_VAR, Some("a-proxy-secret-for-the-startup-tests-32-bytes"), diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 15ffc037be..9bc715c820 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -5,7 +5,6 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; @@ -57,9 +56,8 @@ impl Middleware for SanitizeRequestMiddleware { /// Fastly-specific headers (`X-TS-Fastly-Version`, `X-TS-ENV`) are skipped /// because the corresponding env vars are not set in a local dev context. /// -/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of -/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected -/// ones — carries a consistent set of headers. +/// Registered directly inside [`SanitizeRequestMiddleware`], so that every +/// outgoing response carries a consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -81,44 +79,6 @@ impl Middleware for FinalizeResponseMiddleware { } } -// --------------------------------------------------------------------------- -// AuthMiddleware -// --------------------------------------------------------------------------- - -/// Inner middleware: enforces basic-auth before the handler runs. -/// -/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the -/// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). -/// - `Ok(None)` → no auth required or credentials accepted; continue the chain. -/// - `Err(report)` → internal error; log and convert to a 500 HTTP response. -pub struct AuthMiddleware { - settings: Arc, -} - -impl AuthMiddleware { - /// Creates a new [`AuthMiddleware`] with the given settings. - #[must_use] - pub fn new(settings: Arc) -> Self { - Self { settings } - } -} - -#[async_trait(?Send)] -impl Middleware for AuthMiddleware { - async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { - Ok(Some(response)) => return Ok(response), - Ok(None) => {} - Err(report) => { - log::error!("auth check failed: {:?}", report); - return Ok(crate::app::http_error(&report)); - } - } - - next.run(ctx).await - } -} - // --------------------------------------------------------------------------- // apply_finalize_headers — extracted for unit testing // --------------------------------------------------------------------------- @@ -185,11 +145,6 @@ mod tests { fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { let mut s = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 043b4d3d12..e33a5d43d6 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -22,11 +22,6 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = fn test_settings() -> trusted_server_core::settings::Settings { trusted_server_core::settings::Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -131,13 +126,6 @@ fn all_explicit_routes_are_registered() { ("GET", "/_ts/config"), ("GET", "/_ts/config.json"), ("GET", "/_ts/data"), - ("POST", "/_ts/admin/keys/rotate"), - ("POST", "/_ts/admin/keys/deactivate"), - ("GET", "/_ts/admin/ec"), - ("GET", "/_ts/admin/ec/{id}"), - ("GET", "/_ts/admin/eids"), - ("POST", "/admin/keys/rotate"), - ("POST", "/admin/keys/deactivate"), ("POST", "/auction"), // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both // paths are spelled out as literals rather than referencing @@ -162,29 +150,6 @@ fn all_explicit_routes_are_registered() { } } -/// Verify the legacy non-`/_ts` admin aliases ARE registered — to the local -/// deny handler — matching the Fastly and Cloudflare adapters. -/// -/// The production basic-auth handler regex (`^/_ts/admin`) does not match -/// `/admin/keys/*`, so these aliases are not auth-gated. Leaving them unrouted -/// would let them fall through to the publisher fallback, which forwards the -/// request (including `Authorization` and key body) to the origin, leaking admin -/// credentials. Registering them to a local 404 deny fails closed instead. This -/// guard pins the cross-adapter agreement so the divergence cannot silently -/// reappear. -#[test] -fn legacy_admin_aliases_are_registered_to_local_deny() { - let routes = registered_routes(); - for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { - for method in LEGACY_ADMIN_DENY_METHODS { - assert!( - routes.iter().any(|(m, p)| m == method && p == path), - "legacy {method} {path} must be registered (to the local deny) so it never reaches the publisher fallback; registered routes: {routes:?}" - ); - } - } -} - // --------------------------------------------------------------------------- // Route smoke tests — verify routing (not business logic correctness) // --------------------------------------------------------------------------- @@ -341,168 +306,16 @@ async fn finalize_middleware_sets_geo_unavailable_header() { } // --------------------------------------------------------------------------- -// Basic-auth parity tests +// Closed paths and the inspection endpoints // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_route_without_credentials_returns_401() { - let mut svc = make_service(); - let req = Request::builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(AxumBody::from("{}")) - .expect("should build request"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - assert_eq!( - resp.status().as_u16(), - 401, - "admin route must return 401 without credentials" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_ec_routes_return_501() { - // The EC identity graph is Fastly KV backed, so the Axum dev server - // answers the admin EC lookup routes locally with 501 instead of letting - // them fall through to the publisher fallback. - let sample_ec_id = format!("{}.abc123", "a".repeat(64)); - for path in [ - "/_ts/admin/ec".to_owned(), - format!("/_ts/admin/ec/{sample_ec_id}"), - ] { - let mut svc = make_service(); - let req = Request::builder() - .method("GET") - .uri(&path) - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .body(AxumBody::empty()) - .expect("should build request"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - assert_eq!( - resp.status().as_u16(), - 501, - "{path} should report that Axum EC lookup is unsupported" - ); - assert_eq!( - resp.headers() - .get("content-type") - .and_then(|v| v.to_str().ok()), - Some("application/json") - ); - assert_eq!( - resp.headers() - .get("cache-control") - .and_then(|v| v.to_str().ok()), - Some("no-store") - ); - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_ec_route_without_credentials_returns_401() { - let mut svc = make_service(); - let req = Request::builder() - .method("GET") - .uri("/_ts/admin/ec") - .body(AxumBody::empty()) - .expect("should build unauthenticated admin EC request"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - - assert_eq!(resp.status().as_u16(), 401); - assert!( - resp.headers().contains_key("www-authenticate"), - "admin EC 401 should include the Basic authentication challenge" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_eids_route_returns_200() { - // The EIDs echo is pure request inspection (no KV), so the dev server - // serves the real handler. - let mut svc = make_service(); - let req = Request::builder() - .method("GET") - .uri("/_ts/admin/eids") - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .body(AxumBody::empty()) - .expect("should build request"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - assert_eq!( - resp.status().as_u16(), - 200, - "/_ts/admin/eids should serve the real EIDs echo handler" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_diagnostic_fallback_is_denied_locally() { +async fn an_authenticated_request_to_a_closed_path_is_answered_here() { let ec_id = format!("{}.abc123", "a".repeat(64)); - let valid_paths = [ + for path in [ "/_ts/admin/ec".to_owned(), format!("/_ts/admin/ec/{ec_id}"), "/_ts/admin/eids".to_owned(), - ]; - - for path in valid_paths { - for method in ["POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] { - let request = Request::builder() - .method(method) - .uri(&path) - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .body(AxumBody::from("sensitive-admin-body")) - .expect("should build authenticated admin request"); - let response = make_service() - .ready() - .await - .expect("should be ready") - .call(request) - .await - .expect("should respond"); - - assert_eq!(response.status().as_u16(), 405); - assert_eq!( - response - .headers() - .get("allow") - .and_then(|v| v.to_str().ok()), - Some("GET") - ); - assert_eq!( - response - .headers() - .get("cache-control") - .and_then(|v| v.to_str().ok()), - Some("no-store") - ); - } - } - - for path in [ "/_ts/admin/ec/".to_owned(), format!("/_ts/admin/ec/{ec_id}/extra"), "/_ts/admin/eids/".to_owned(), @@ -510,14 +323,13 @@ async fn authenticated_admin_diagnostic_fallback_is_denied_locally() { "/_ts/admin/eids.json".to_owned(), "/_ts/admin/ec;foo".to_owned(), format!("/_ts/admin/ec%2F{ec_id}"), - // Percent-encoded separators match the `^/_ts/admin` basic-auth - // handler but not a literal-slash namespace check, so they must be - // reserved before publisher fallback forwards credentials upstream. + // A check for a literal slash would miss a percent-encoded separator, + // so these are closed before the publisher fallback forwards + // credentials upstream. "/_ts/admin%2Fec".to_owned(), "/_ts/admin%2fec".to_owned(), - // Retired non-`/_ts` alias namespace: only the two exact paths are - // routed to a local deny, so descendants and encoded separators must - // be reserved at the shared fallback boundary. + // The alias outside `/_ts`, with its descendants and encoded + // separators. "/admin/keys".to_owned(), "/admin/keys/rotate/extra".to_owned(), "/admin/keys%2Frotate".to_owned(), @@ -555,15 +367,19 @@ async fn authenticated_admin_diagnostic_fallback_is_denied_locally() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { - // Regression for the credential-leak finding: the production basic-auth regex - // `^/_ts/admin` does not match `/admin/keys/*`, so those aliases are not - // auth-gated. Any publisher-fallback method carrying an `Authorization` - // header must be denied locally with 404, never proxied to the publisher - // origin (which would leak the admin credentials and key body). A - // publisher-fallback proxy without a backend would surface as a 5xx, so 404 - // proves the local deny ran. - for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { +async fn key_administration_paths_are_answered_here_and_never_proxied() { + // A request to a key administration path carries an operator's + // credentials and a key payload. Every publisher-fallback method must be + // answered here with 404 and never proxied to the publisher origin, which + // would be handed both. A publisher-fallback proxy without a backend would + // surface as a 5xx, so a 404 proves the request was answered here. + for path in [ + "/_ts/admin/keys/rotate", + "/_ts/admin/keys/deactivate", + "/_ts/admin/cache/purge", + "/admin/keys/rotate", + "/admin/keys/deactivate", + ] { for method in LEGACY_ADMIN_DENY_METHODS { let mut svc = make_service(); let req = Request::builder() @@ -572,7 +388,7 @@ async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") .header("content-type", "application/json") .body(AxumBody::from("{\"key_id\":\"leak-me\"}")) - .expect("should build authorized legacy-alias request"); + .expect("should build authorized key administration request"); let resp = svc .ready() .await @@ -583,97 +399,12 @@ async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { assert_eq!( resp.status().as_u16(), 404, - "legacy {method} {path} with Authorization must be denied locally (404), not proxied to publisher" + "{method} {path} with Authorization must be answered here (404), not proxied to publisher" ); } } } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_route_without_credentials_includes_www_authenticate_header() { - let mut svc = make_service(); - let req = Request::builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(AxumBody::from("{}")) - .expect("should build request"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - assert_eq!( - resp.status().as_u16(), - 401, - "should be 401 before checking header" - ); - assert!( - resp.headers().contains_key("www-authenticate"), - "401 response must include WWW-Authenticate header" - ); - let www_auth = resp - .headers() - .get("www-authenticate") - .expect("should have www-authenticate header") - .to_str() - .expect("should be valid UTF-8"); - assert!( - www_auth.starts_with("Basic realm="), - "WWW-Authenticate must be Basic scheme, got: {www_auth}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_route_with_wrong_credentials_returns_401() { - use base64::Engine as _; - let creds = base64::engine::general_purpose::STANDARD.encode("admin:wrong-password"); - let mut svc = make_service(); - let req = Request::builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .header("authorization", format!("Basic {creds}")) - .body(AxumBody::from("{}")) - .expect("should build request"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - assert_eq!( - resp.status().as_u16(), - 401, - "admin route must reject wrong credentials with 401" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn discovery_endpoint_does_not_require_auth() { - let mut svc = make_service(); - let req = Request::builder() - .method("GET") - .uri("/.well-known/trusted-server.json") - .body(AxumBody::empty()) - .expect("should build request"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - assert_ne!( - resp.status().as_u16(), - 401, - "/.well-known/trusted-server.json must not require auth" - ); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn permissions_endpoint_answers_anyone_as_data_and_as_a_page() { for (path, content_type) in [ @@ -749,8 +480,8 @@ async fn config_endpoint_answers_anyone_and_masks_secrets() { "{path} should mask what it does not show" ); assert!( - !text.contains("admin-pass"), - "{path} should not carry the admin password" + !text.contains("integration-test-proxy-secret"), + "{path} should not carry the proxy secret" ); } } @@ -796,107 +527,6 @@ async fn data_endpoint_answers_a_browser_opening_the_page_and_nothing_else() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn auction_endpoint_does_not_require_auth() { - let mut svc = make_service(); - let req = Request::builder() - .method("POST") - .uri("/auction") - .header("content-type", "application/json") - .body(AxumBody::from(r#"{"adUnits":[]}"#)) - .expect("should build request"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - assert_ne!( - resp.status().as_u16(), - 401, - "/auction must not apply admin basic-auth gate" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_route_returns_non_404_non_5xx() { - let mut svc = make_service(); - - let req = Request::builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(AxumBody::from("{}")) - .expect("should build request"); - - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - let status = resp.status().as_u16(); - - assert_ne!(status, 404, "admin route must be routed"); - // The auth gate short-circuits with 401 before the handler; only an - // unhandled 500 indicates a panic or missing handler. - assert_ne!(status, 500, "admin route must not panic: got {status}"); -} - -// --------------------------------------------------------------------------- -// Admin key route full path coverage -// --------------------------------------------------------------------------- - -// Exercises the auth-fail path with a realistic key body (complements the -// generic `admin_route_without_credentials_returns_401` above). -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_rotate_key_auth_fail_returns_401() { - let mut svc = make_service(); - let req = Request::builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(AxumBody::from(r#"{"keyId":"test-key"}"#)) - .expect("should build request"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - assert_eq!( - resp.status().as_u16(), - 401, - "admin/keys/rotate without credentials must return 401" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_deactivate_key_auth_fail_returns_401() { - let mut svc = make_service(); - let req = Request::builder() - .method("POST") - .uri("/_ts/admin/keys/deactivate") - .header("content-type", "application/json") - .body(AxumBody::from(r#"{"keyId":"test-key"}"#)) - .expect("should build request"); - let resp = svc - .ready() - .await - .expect("should be ready") - .call(req) - .await - .expect("should respond"); - assert_eq!( - resp.status().as_u16(), - 401, - "admin/keys/deactivate without credentials must return 401" - ); -} - // --------------------------------------------------------------------------- // First-party route smoke tests // --------------------------------------------------------------------------- @@ -1021,11 +651,6 @@ async fn first_party_proxy_rebuild_is_routed() { /// inject, with the `[ec.acme]` block that module's settings live in. /// `acme` is a fictional vendor key. const UNINJECTED_MODULE_TOML: &str = r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 2a065c1e82..1ffc1b8355 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -58,11 +58,6 @@ assume_single_jurisdiction = true }; Settings::from_toml(&format!( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -275,16 +270,15 @@ async fn every_route_prepares_the_request_exactly_once() { let settings = settings_with(PROBE_BLOCK); let mut service = service_with(settings, &[seam_probe::builder()]); - // `/admin/keys/rotate` is a named route (the legacy alias denied locally - // with a 404), reached through `named_route_handler`, and it is not - // covered by the `^/_ts/admin` auth handler, so the request reaches the - // preparer rather than being turned back with a 401. - let named = get_counted(&mut service, "/admin/keys/rotate", "named-route").await; + // `/first-party/proxy` is a named route, reached through + // `named_route_handler`, whose handler refuses a request naming no target, + // so the request reaches the preparer and no backend. + let named = get_counted(&mut service, "/first-party/proxy", "named-route").await; assert_eq!( named.status().as_u16(), - 404, - "the named route should serve the local deny, not fall through to the fallback" + 502, + "the named route should refuse a request that names no target" ); assert_eq!( seam_probe::prepare_runs_for("named-route"), @@ -312,6 +306,27 @@ async fn every_route_prepares_the_request_exactly_once() { ); } +/// A closed path is answered before any module's preparer runs, as the other +/// adapters answer it, so no preparer sees or answers a request to it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_closed_path_is_answered_before_any_preparer_runs() { + let settings = settings_with(PROBE_BLOCK); + let mut service = service_with(settings, &[seam_probe::builder()]); + + let response = get_counted(&mut service, "/_ts/admin/keys/rotate", "closed-path").await; + + assert_eq!( + response.status().as_u16(), + 404, + "the closed path should be answered here" + ); + assert_eq!( + seam_probe::prepare_runs_for("closed-path"), + 0, + "no preparer should run for a closed path" + ); +} + // --------------------------------------------------------------------------- // 3. Deploy validation reaches a vendor's own rule // --------------------------------------------------------------------------- @@ -641,11 +656,6 @@ fn deploy_validation_accepts_a_modules_ad_server_only_with_its_builder() { fn settings_selecting_module(extra: &str) -> Settings { Settings::from_toml(&format!( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index dfee0ec5b6..a56c5f8a9e 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use edgezero_core::app::Hooks; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, header}; +use edgezero_core::http::{HeaderValue, Method, Request, Response, header}; use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; @@ -13,17 +13,13 @@ use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, }; use trusted_server_core::cache_policy::EdgeCacheHeader; +use trusted_server_core::closed_paths::closed_path_response; #[cfg(target_arch = "wasm32")] use trusted_server_core::config_payload::{ DEFAULT_SECRET_STORE_ID, settings_from_config_blob_with, }; use trusted_server_core::ec::EcContext; -use trusted_server_core::ec::admin::{ - admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, - deny_admin_diagnostic_fallback, handle_admin_eids_lookup, -}; use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; -use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::inspect::config::{CONFIG_PATHS, handle_config}; use trusted_server_core::inspect::data::{DATA_PAGE_PATH, handle_data}; @@ -48,7 +44,7 @@ use trusted_server_core::request_signing::{ }; use trusted_server_core::settings::Settings; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; +use crate::middleware::{FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::build_runtime_services; // --------------------------------------------------------------------------- @@ -452,55 +448,6 @@ pub(crate) fn http_error(report: &Report) -> Response { response } -fn admin_key_management_not_supported() -> Response { - let body = edgezero_core::body::Body::from( - "Admin key management is not supported on Cloudflare Workers.\n\ - Use the Fastly adapter (via Viceroy or deployed) to rotate or deactivate keys.\n", - ); - let mut response = Response::new(body); - *response.status_mut() = StatusCode::NOT_IMPLEMENTED; - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - -fn cache_purge_not_supported() -> Response { - let body = edgezero_core::body::Body::from( - "Template cache purge is not supported on Cloudflare Workers.\n\ - Use the Fastly adapter (via Viceroy or deployed) to purge.\n", - ); - let mut response = Response::new(body); - *response.status_mut() = StatusCode::NOT_IMPLEMENTED; - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - -fn admin_ec_lookup_not_supported() -> Response { - core_admin_ec_lookup_not_supported() -} - -/// Builds the local `404 Not Found` returned for legacy `/admin/keys/*` -/// aliases on the Cloudflare adapter. -/// -/// These non-`/_ts` aliases are not matched by the `^/_ts/admin` basic-auth -/// handler, so they fail closed locally rather than fall through to the -/// publisher fallback — which would forward the caller's `Authorization` header -/// and key-management payload to the origin, leaking admin credentials. -fn legacy_admin_alias_denied() -> Response { - let mut response = Response::new(edgezero_core::body::Body::from("Not found\n")); - *response.status_mut() = edgezero_core::http::StatusCode::NOT_FOUND; - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - // --------------------------------------------------------------------------- // Startup error fallback // --------------------------------------------------------------------------- @@ -650,7 +597,7 @@ fn build_router(state: &Arc) -> RouterService { ) -> Result { let services = state.services_for_request(&ctx); let mut req = ctx.into_request(); - if let Some(response) = deny_admin_diagnostic_fallback(&req) { + if let Some(response) = closed_path_response(&req) { return Ok(response); } if let Err(error) = state.registry.prepare_request(&state.settings, &mut req) { @@ -753,7 +700,6 @@ fn build_router(state: &Arc) -> RouterService { // shared-secret authentication header. .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) - .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) .get( "/.well-known/trusted-server.json", make_handler(Arc::clone(&state), |s, services, req| async move { @@ -766,42 +712,6 @@ fn build_router(state: &Arc) -> RouterService { handle_verify_signature(&s.settings, &services, req) }), ) - // Canonical admin key routes. These match `Settings::ADMIN_ENDPOINTS` - // and the production basic-auth handler regex (`^/_ts/admin`), so they - // are auth-gated under a production-shaped config. - // - // The legacy non-`/_ts` aliases (`/admin/keys/*`) are registered - // below to a local 404 for every publisher-fallback method: the - // production handler regex `^/_ts/admin` does not match them, and - // letting them fall through would forward the caller's - // `Authorization` header and key-management payload to the origin, - // leaking admin credentials. - .post("/_ts/admin/keys/rotate", |_ctx: RequestContext| async { - Ok::(admin_key_management_not_supported()) - }) - .post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async { - Ok::(admin_key_management_not_supported()) - }) - // Admin EC lookup routes. Registered explicitly (like the key - // routes above) so they never fall through to the publisher - // fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth - // coverage. The EC identity graph is Fastly KV backed, so this - // adapter has no store to read. - .get("/_ts/admin/ec", |_ctx: RequestContext| async { - Ok::(admin_ec_lookup_not_supported()) - }) - .get("/_ts/admin/ec/{id}", |_ctx: RequestContext| async { - Ok::(admin_ec_lookup_not_supported()) - }) - // Admin EIDs echo: pure request inspection (no KV), so this - // adapter serves the real handler. - .get( - "/_ts/admin/eids", - make_handler(Arc::clone(&state), |s, _services, req| async move { - let partner_registry = PartnerRegistry::from_config(&s.settings.ec.partners)?; - handle_admin_eids_lookup(&partner_registry, &req) - }), - ) .post( "/auction", make_handler(Arc::clone(&state), |s, services, req| async move { @@ -915,31 +825,6 @@ fn build_router(state: &Arc) -> RouterService { }); router = router.route(DATA_PAGE_PATH, Method::GET, data); - let cache_purge_unsupported = - make_handler(Arc::clone(&state), |_s, _services, _req| async move { - Ok(cache_purge_not_supported()) - }); - for method in publisher_fallback_methods() { - router = router.route( - "/_ts/admin/cache/purge", - method, - cache_purge_unsupported.clone(), - ); - } - - let legacy_admin_deny = - make_handler(Arc::clone(&state), |_s, _services, _req| async move { - Ok(legacy_admin_alias_denied()) - }); - for method in publisher_fallback_methods() { - router = router.route( - "/admin/keys/rotate", - method.clone(), - legacy_admin_deny.clone(), - ); - router = router.route("/admin/keys/deactivate", method, legacy_admin_deny.clone()); - } - for method in publisher_fallback_methods() { router = router.route("/", method.clone(), fallback.clone()); router = router.route("/{*rest}", method, fallback.clone()); @@ -960,11 +845,6 @@ mod tests { /// inject, with the `[ec.acme]` block that module's settings live in. /// `acme` is a fictional vendor key. const UNINJECTED_MODULE_TOML: &str = r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -1026,11 +906,6 @@ mod tests { fn aps_profile_settings() -> Settings { let mut settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-password" - [publisher] domain = "publisher.example" cookie_domain = ".publisher.example" @@ -1136,11 +1011,6 @@ mod tests { fn disabled_startup_accepts_dormant_multi_provider_auction_plan() { let mut settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-password" - [publisher] domain = "publisher.example" cookie_domain = ".publisher.example" @@ -1167,11 +1037,6 @@ mod tests { fn startup_rejects_multi_provider_auction_plan() { let mut settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-password" - [publisher] domain = "publisher.example" cookie_domain = ".publisher.example" diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 58077685a6..3a58cd63d5 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -5,7 +5,6 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; @@ -56,9 +55,8 @@ impl Middleware for SanitizeRequestMiddleware { /// (injected by the Cloudflare Workers runtime). On the native host target the /// header is absent, so `X-Geo-Info-Available: false` is emitted. /// -/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of -/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected -/// ones — carries a consistent set of headers. +/// Registered directly inside [`SanitizeRequestMiddleware`], so that every +/// outgoing response carries a consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -88,44 +86,6 @@ impl Middleware for FinalizeResponseMiddleware { } } -// --------------------------------------------------------------------------- -// AuthMiddleware -// --------------------------------------------------------------------------- - -/// Inner middleware: enforces basic-auth before the handler runs. -/// -/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the -/// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). -/// - `Ok(None)` → no auth required or credentials accepted; continue the chain. -/// - `Err(report)` → internal error; log and convert to a 500 HTTP response. -pub struct AuthMiddleware { - settings: Arc, -} - -impl AuthMiddleware { - /// Creates a new [`AuthMiddleware`] with the given settings. - #[must_use] - pub fn new(settings: Arc) -> Self { - Self { settings } - } -} - -#[async_trait(?Send)] -impl Middleware for AuthMiddleware { - async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { - Ok(Some(response)) => return Ok(response), - Ok(None) => {} - Err(report) => { - log::error!("auth check failed: {:?}", report); - return Ok(crate::app::http_error(&report)); - } - } - - next.run(ctx).await - } -} - // --------------------------------------------------------------------------- // apply_finalize_headers — extracted for unit testing // --------------------------------------------------------------------------- @@ -198,11 +158,6 @@ mod tests { // builds its own. let mut s = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index dac49ac4a3..79877360d4 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -19,17 +19,9 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// application, and `TrustedServerApp::routes()`, which cannot read the store, /// serves the startup error router on every route (and its route table is the /// fallback-only set). -/// The handler regex is the production-shaped `^/_ts/admin`, matching -/// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical -/// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. fn test_router() -> RouterService { let settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -82,11 +74,6 @@ fn assert_route_registered(method: &str, path: &str) { fn make_router() -> RouterService { let settings = trusted_server_core::settings::Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -119,8 +106,8 @@ fn routes_build_without_panic() { } // --------------------------------------------------------------------------- -// Middleware regression tests — verify FinalizeResponseMiddleware and -// AuthMiddleware are wired so they cannot be removed silently. +// Middleware regression tests, which verify FinalizeResponseMiddleware is +// wired so it cannot be removed silently. // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -144,42 +131,19 @@ async fn finalize_middleware_injects_geo_header() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn auth_middleware_runs_in_chain_for_protected_routes() { - // Verifies that AuthMiddleware is wired by asserting the 401 + WWW-Authenticate - // challenge on a protected route (/_ts/admin/keys/rotate). Only AuthMiddleware - // short-circuits with this response — FinalizeResponseMiddleware alone would not. - let router = test_router(); - - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - - let resp = route(router, req).await; - - assert_eq!( - resp.status().as_u16(), - 401, - "AuthMiddleware must short-circuit with 401 on protected routes without credentials" - ); - assert!( - resp.headers().contains_key("www-authenticate"), - "AuthMiddleware must include WWW-Authenticate on 401 responses" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { - // Regression for the credential-leak finding: the production basic-auth regex - // `^/_ts/admin` does not match `/admin/keys/*`, so those aliases are not - // auth-gated. Any publisher-fallback method carrying an `Authorization` - // header must be denied locally with 404, never proxied to the publisher - // origin (which would leak the admin credentials and key body). A - // publisher-fallback proxy without a backend would surface as a 5xx, so 404 - // proves the local deny ran. - for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { +async fn key_administration_paths_are_answered_here_and_never_proxied() { + // A request to a key administration path carries an operator's + // credentials and a key payload. Every publisher-fallback method must be + // answered here with 404 and never proxied to the publisher origin, which + // would be handed both. A publisher-fallback proxy without a backend would + // surface as a 5xx, so a 404 proves the request was answered here. + for path in [ + "/_ts/admin/keys/rotate", + "/_ts/admin/keys/deactivate", + "/_ts/admin/cache/purge", + "/admin/keys/rotate", + "/admin/keys/deactivate", + ] { for method in LEGACY_ADMIN_DENY_METHODS { let router = test_router(); let req = request_builder() @@ -188,14 +152,14 @@ async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") .header("content-type", "application/json") .body(edgezero_core::body::Body::from("{\"key_id\":\"leak-me\"}")) - .expect("should build authorized legacy-alias request"); + .expect("should build authorized key administration request"); let resp = route(router, req).await; assert_eq!( resp.status().as_u16(), 404, - "legacy {method} {path} with Authorization must be denied locally (404), not proxied to publisher" + "{method} {path} with Authorization must be answered here (404), not proxied to publisher" ); } } @@ -274,11 +238,6 @@ fn all_explicit_routes_are_registered() { ("GET", "/_ts/config"), ("GET", "/_ts/config.json"), ("GET", "/_ts/data"), - ("POST", "/_ts/admin/keys/rotate"), - ("POST", "/_ts/admin/keys/deactivate"), - ("GET", "/_ts/admin/ec"), - ("GET", "/_ts/admin/ec/{id}"), - ("GET", "/_ts/admin/eids"), ("POST", "/auction"), // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both // paths are spelled out as literals rather than referencing @@ -301,155 +260,19 @@ fn all_explicit_routes_are_registered() { for (method, path) in expected { assert_route_registered(method, path); } - - for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { - for method in LEGACY_ADMIN_DENY_METHODS { - assert_route_registered(method, path); - } - } } // --------------------------------------------------------------------------- -// Basic-auth parity tests +// Closed paths and the inspection endpoints // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_routes_return_501() { - for (path, body) in [ - ("/_ts/admin/keys/rotate", "{}"), - ( - "/_ts/admin/keys/deactivate", - r#"{"kid":"test-key","delete":false}"#, - ), - ] { - let req = request_builder() - .method("POST") - .uri(path) - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from(body)) - .expect("should build request"); - let resp = route(test_router(), req).await; - - assert_eq!( - resp.status().as_u16(), - 501, - "{path} should report that Cloudflare key management is unsupported" - ); - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_ec_routes_return_501() { - // The EC identity graph is Fastly KV backed, so Cloudflare answers the - // admin EC lookup routes locally with 501 instead of letting them fall - // through to the publisher fallback. - let sample_ec_id = format!("{}.abc123", "a".repeat(64)); - for path in [ - "/_ts/admin/ec".to_owned(), - format!("/_ts/admin/ec/{sample_ec_id}"), - ] { - let req = request_builder() - .method("GET") - .uri(&path) - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let resp = route(test_router(), req).await; - - assert_eq!( - resp.status().as_u16(), - 501, - "{path} should report that Cloudflare EC lookup is unsupported" - ); - assert_eq!( - resp.headers() - .get("content-type") - .and_then(|v| v.to_str().ok()), - Some("application/json") - ); - assert_eq!( - resp.headers() - .get("cache-control") - .and_then(|v| v.to_str().ok()), - Some("no-store") - ); - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_ec_route_without_credentials_returns_401() { - let req = request_builder() - .method("GET") - .uri("/_ts/admin/ec") - .body(edgezero_core::body::Body::empty()) - .expect("should build unauthenticated admin EC request"); - let resp = route(test_router(), req).await; - - assert_eq!(resp.status().as_u16(), 401); - assert!( - resp.headers().contains_key("www-authenticate"), - "admin EC 401 should include the Basic authentication challenge" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_eids_route_returns_200() { - // The EIDs echo is pure request inspection (no KV), so this adapter - // serves the real handler. - let req = request_builder() - .method("GET") - .uri("/_ts/admin/eids") - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let resp = route(test_router(), req).await; - - assert_eq!( - resp.status().as_u16(), - 200, - "/_ts/admin/eids should serve the real EIDs echo handler" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_diagnostic_fallback_is_denied_locally() { +async fn an_authenticated_request_to_a_closed_path_is_answered_here() { let ec_id = format!("{}.abc123", "a".repeat(64)); - let valid_paths = [ + for path in [ "/_ts/admin/ec".to_owned(), format!("/_ts/admin/ec/{ec_id}"), "/_ts/admin/eids".to_owned(), - ]; - - for path in valid_paths { - for method in ["POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] { - let request = request_builder() - .method(method) - .uri(&path) - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .body(edgezero_core::body::Body::from("sensitive-admin-body")) - .expect("should build authenticated admin request"); - let response = route(test_router(), request).await; - - assert_eq!(response.status().as_u16(), 405); - assert_eq!( - response - .headers() - .get("allow") - .and_then(|v| v.to_str().ok()), - Some("GET") - ); - assert_eq!( - response - .headers() - .get("cache-control") - .and_then(|v| v.to_str().ok()), - Some("no-store") - ); - } - } - - for path in [ "/_ts/admin/ec/".to_owned(), format!("/_ts/admin/ec/{ec_id}/extra"), "/_ts/admin/eids/".to_owned(), @@ -457,14 +280,13 @@ async fn authenticated_admin_diagnostic_fallback_is_denied_locally() { "/_ts/admin/eids.json".to_owned(), "/_ts/admin/ec;foo".to_owned(), format!("/_ts/admin/ec%2F{ec_id}"), - // Percent-encoded separators match the `^/_ts/admin` basic-auth - // handler but not a literal-slash namespace check, so they must be - // reserved before publisher fallback forwards credentials upstream. + // A check for a literal slash would miss a percent-encoded separator, + // so these are closed before the publisher fallback forwards + // credentials upstream. "/_ts/admin%2Fec".to_owned(), "/_ts/admin%2fec".to_owned(), - // Retired non-`/_ts` alias namespace: only the two exact paths are - // routed to a local deny, so descendants and encoded separators must - // be reserved at the shared fallback boundary. + // The alias outside `/_ts`, with its descendants and encoded + // separators. "/admin/keys".to_owned(), "/admin/keys/rotate/extra".to_owned(), "/admin/keys%2Frotate".to_owned(), @@ -495,90 +317,6 @@ async fn authenticated_admin_diagnostic_fallback_is_denied_locally() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_route_without_credentials_returns_401() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - let resp = route(router, req).await; - assert_eq!( - resp.status().as_u16(), - 401, - "admin route must return 401 without credentials" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_route_without_credentials_includes_www_authenticate_header() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - let resp = route(router, req).await; - assert_eq!( - resp.status().as_u16(), - 401, - "should be 401 before checking header" - ); - assert!( - resp.headers().contains_key("www-authenticate"), - "401 response must include WWW-Authenticate header" - ); - let www_auth = resp - .headers() - .get("www-authenticate") - .expect("should have www-authenticate header") - .to_str() - .expect("should be valid UTF-8"); - assert!( - www_auth.starts_with("Basic realm="), - "WWW-Authenticate must be Basic scheme, got: {www_auth}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_route_with_wrong_credentials_returns_401() { - use base64::Engine as _; - let creds = base64::engine::general_purpose::STANDARD.encode("admin:wrong-password"); - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .header("authorization", format!("Basic {creds}")) - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - let resp = route(router, req).await; - assert_eq!( - resp.status().as_u16(), - 401, - "admin route must reject wrong credentials with 401" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn discovery_endpoint_does_not_require_auth() { - let router = test_router(); - let req = request_builder() - .method("GET") - .uri("/.well-known/trusted-server.json") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let resp = route(router, req).await; - assert_ne!( - resp.status().as_u16(), - 401, - "/.well-known/trusted-server.json must not require auth" - ); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn permissions_endpoint_answers_anyone_as_data_and_as_a_page() { for (path, content_type) in [ @@ -638,8 +376,8 @@ async fn config_endpoint_answers_anyone_and_masks_secrets() { "{path} should mask what it does not show" ); assert!( - !text.contains("admin-pass"), - "{path} should not carry the admin password" + !text.contains("route-test-proxy-secret"), + "{path} should not carry the proxy secret" ); } } @@ -676,105 +414,6 @@ async fn data_endpoint_answers_a_browser_opening_the_page_and_nothing_else() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn auction_endpoint_does_not_require_auth() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/auction") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from(r#"{"adUnits":[]}"#)) - .expect("should build request"); - let resp = route(router, req).await; - assert_ne!( - resp.status().as_u16(), - 401, - "/auction must not apply admin basic-auth gate" - ); -} - -// --------------------------------------------------------------------------- -// Admin key route full path coverage -// --------------------------------------------------------------------------- - -// Exercises the auth-fail path with a realistic key body (complements the -// generic `admin_route_without_credentials_returns_401` above). -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_rotate_key_auth_fail_returns_401() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from(r#"{"keyId":"test-key"}"#)) - .expect("should build request"); - let resp = route(router, req).await; - assert_eq!( - resp.status().as_u16(), - 401, - "admin/keys/rotate without credentials must return 401" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_deactivate_key_auth_fail_returns_401() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/deactivate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from(r#"{"keyId":"test-key"}"#)) - .expect("should build request"); - let resp = route(router, req).await; - assert_eq!( - resp.status().as_u16(), - 401, - "admin/keys/deactivate without credentials must return 401" - ); -} - -#[tokio::test] -async fn legacy_admin_rotate_alias_returns_404() { - // The legacy non-`/_ts` alias is denied locally rather than routed to the - // admin handler or publisher fallback. - let router = make_router(); - - let req = request_builder() - .method("POST") - .uri("/admin/keys/rotate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - - let resp = route(router, req).await; - - assert_eq!( - resp.status().as_u16(), - 404, - "legacy admin key rotation alias must return local 404" - ); -} - -#[tokio::test] -async fn legacy_admin_deactivate_alias_returns_404() { - let router = make_router(); - - let req = request_builder() - .method("POST") - .uri("/admin/keys/deactivate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - - let resp = route(router, req).await; - - assert_eq!( - resp.status().as_u16(), - 404, - "legacy admin key deactivation alias must return local 404" - ); -} - #[tokio::test] async fn tsjs_route_prefix_is_handled_not_5xx() { // `/static/tsjs=` is a GET catch-all path. The handler returns 404 for an @@ -805,11 +444,6 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { /// inject, with the `[ec.acme]` block that module's settings live in. /// `acme` is a fictional vendor key. const UNINJECTED_MODULE_TOML: &str = r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -971,11 +605,6 @@ mod module_endpoints { fn settings_with(extra: &str) -> Settings { Settings::from_toml(&format!( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml index 6c05702a1d..b8806f5989 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -20,7 +20,6 @@ id = "ci-local-config" # Fictitious integration-only secret values. `worker::Env::secret` reads these # string bindings in local Wrangler runs; production values are provisioned with # `wrangler secret put` instead of being committed to a manifest. -integration_admin_password = "integration-admin-password-32-bytes-ok" integration_proxy_secret = "integration-test-proxy-secret-32-bytes-ok" integration_ec_passphrase = "integration-test-ec-secret-padded-32" integration_partner_token_alpha = "integration-test-token-alpha-32-bytes-ok" diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 271c4468cf..d50065ee5d 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -19,7 +19,6 @@ reusable-sandbox = [] [dependencies] async-trait = { workspace = true } -base64 = { workspace = true } bytes = { workspace = true } chrono = { workspace = true } derive_more = { workspace = true } @@ -43,7 +42,6 @@ trusted-server-permission-signal-tcf = { workspace = true } trusted-server-permission-signal-us-privacy = { workspace = true } trusted-server-device-fastly = { workspace = true } trusted-server-geo-fastly = { workspace = true } -url = { workspace = true } urlencoding = { workspace = true } [dev-dependencies] diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index b866c93553..aa8903bf17 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1,8 +1,8 @@ //! Full `EdgeZero` application wiring for Trusted Server. //! //! Registers all routes for Trusted Server into a -//! [`RouterService`]. On successful startup, attaches [`FinalizeResponseMiddleware`] -//! (outermost) and [`AuthMiddleware`] (inner). When startup fails, +//! [`RouterService`]. On successful startup, attaches +//! [`FinalizeResponseMiddleware`]. When startup fails, //! [`startup_error_router`] returns a bare router without middleware. //! Builds the [`AppState`] once per Wasm instance. //! @@ -20,11 +20,6 @@ //! |--------|-------------|---------| //! | GET | `/.well-known/trusted-server.json` | [`handle_trusted_server_discovery`] | //! | POST | `/verify-signature` | [`handle_verify_signature`] | -//! | POST | `/_ts/admin/keys/rotate` | [`handle_rotate_key`] | -//! | POST | `/_ts/admin/keys/deactivate` | [`handle_deactivate_key`] | -//! | GET | `/_ts/admin/ec` | [`handle_admin_ec_lookup`] | -//! | GET | `/_ts/admin/ec/{id}` | [`handle_admin_ec_lookup`] | -//! | GET | `/_ts/admin/eids` | [`handle_admin_eids_lookup`] | //! | POST | `/_ts/api/v1/batch-sync` | [`handle_batch_sync`] | //! | GET | `/_ts/api/v1/identify` | [`handle_identify`] | //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | @@ -56,7 +51,7 @@ //! `route_request` (tracked in issue #495): //! //! - [`build_ec_request_state`] runs before every dispatched route (except -//! batch-sync, which uses Bearer auth, and the read-only admin diagnostics) +//! batch-sync, which uses Bearer auth) //! and reproduces the legacy //! pre-routing prelude: device signals, bot gate, `ts-eids`/`sharedid` //! cookie capture, geo lookup, [`EcContext`] creation, and KV-graph gating. @@ -69,11 +64,6 @@ //! //! ## Intentional deviations from legacy //! -//! - **401 auth challenges**: [`AuthMiddleware`] short-circuits before the -//! handler runs, so no EC state is built and `ec_finalize_response` does not -//! run on these responses. Legacy ran EC finalization on its own auth -//! challenges. Like the 401 geo-skip, this is privacy-conservative: no EC -//! cookies are issued to unauthenticated callers. //! - **Publisher responses** keep Fastly origin bodies streaming through the //! `EdgeZero` response body when the body is processable or pass-through. //! Adapters without streaming-body support still use the bounded buffered @@ -108,11 +98,9 @@ use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, }; use trusted_server_core::cache_policy::EdgeCacheHeader; +use trusted_server_core::closed_paths::closed_path_response; use trusted_server_core::config_payload::DEFAULT_SECRET_STORE_ID; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; -use trusted_server_core::ec::admin::{ - deny_admin_diagnostic_fallback, handle_admin_ec_lookup, handle_admin_eids_lookup, -}; use trusted_server_core::ec::batch_sync::handle_batch_sync; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; @@ -148,8 +136,7 @@ use trusted_server_core::publisher::{ publisher_response_into_streaming_response, }; use trusted_server_core::request_signing::{ - handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, - handle_verify_signature, + handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::{ProxyAssetRoute, Settings}; use trusted_server_core::settings_data::{ @@ -158,7 +145,7 @@ use trusted_server_core::settings_data::{ use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}; use trusted_server_device_fastly::FastlyHostSignals; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::FinalizeResponseMiddleware; use crate::platform::{ FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformHttpClient, FastlyPlatformSecretStore, FastlyPolicyEntries, UnavailableKvStore, @@ -749,8 +736,8 @@ enum PreRoute { /// Runs the integration request-filter pipeline before route dispatch. /// -/// Mirrors the legacy `route_request` ordering: filters run after auth -/// (`AuthMiddleware` on this path) and before route matching. Request header +/// Mirrors the legacy `route_request` ordering: filters run before route +/// matching. Request header /// mutations are applied to `req` so the routed handler observes them; response /// effects are returned for the entry point to apply after EC finalization. A /// filter that responds (e.g. a `DataDome` challenge) short-circuits routing. @@ -826,48 +813,6 @@ async fn execute_named( return Ok(run_batch_sync(&state, &services, req)); } - // An operator cache purge is not a reader request: running the EC lifecycle would - // attach finalization state and could ingest the operator's cookies into KV. - if matches!(handler, NamedRouteHandler::AdminCachePurge) { - let principal = trusted_server_core::auth::authenticated_username(&req); - let response = trusted_server_core::cache_purge::handle_cache_purge( - &services, - req, - principal.as_deref(), - ) - .await - .unwrap_or_else(|error| http_error(&error)); - return Ok(response); - } - - // These diagnostics are read-only. Running the normal EC lifecycle would - // attach finalization state and could ingest request cookies into KV after - // the handler returns, violating that contract. - if matches!( - handler, - NamedRouteHandler::AdminEcLookup | NamedRouteHandler::AdminEidsLookup - ) { - let response = PartnerRegistry::from_config(&state.settings.ec.partners) - .and_then(|registry| match handler { - NamedRouteHandler::AdminEcLookup => { - // Deliberately do not use an EC request-state graph: that - // copy is bot-gated, while operators use curl for this - // authenticated diagnostic. - let kv = crate::maybe_identity_graph(&state.settings); - // The selected module decides which identifiers this - // deployment recognizes, so build it here rather than - // assuming the built-in HMAC shape. The read-only - // diagnostic builds no EC request state to borrow it from. - let module = request_module(&state.settings.ec, &services)?; - handle_admin_ec_lookup(kv.as_ref(), ®istry, module.as_deref(), &req) - } - NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req), - _ => unreachable!("admin diagnostics should use early dispatch"), - }) - .unwrap_or_else(|error| http_error(&error)); - return Ok(response); - } - // Read only, answered before the Edge Cookie lifecycle so nothing is written. if matches!(handler, NamedRouteHandler::Permissions) { let response = handle_permissions(&state.settings, &services, &req) @@ -936,15 +881,6 @@ async fn run_named_route( NamedRouteHandler::VerifySignature => { handle_verify_signature(&state.settings, services, req) } - NamedRouteHandler::RotateKey => handle_rotate_key(&state.settings, services, req), - NamedRouteHandler::DeactivateKey => handle_deactivate_key(&state.settings, services, req), - NamedRouteHandler::AdminEcLookup | NamedRouteHandler::AdminEidsLookup => { - unreachable!("admin diagnostics should be handled before EC setup") - } - NamedRouteHandler::AdminCachePurge => { - unreachable!("cache purge should be handled before EC setup") - } - NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), NamedRouteHandler::BatchSync => { // Dispatched by execute_named before EC state is built. unreachable!("batch-sync should be handled by run_batch_sync") @@ -1091,7 +1027,7 @@ async fn dispatch_fallback( services: &RuntimeServices, mut req: Request, ) -> Response { - if let Some(response) = deny_admin_diagnostic_fallback(&req) { + if let Some(response) = closed_path_response(&req) { return response; } @@ -1339,23 +1275,6 @@ pub(crate) fn http_error(report: &Report) -> Response { response } -/// Builds the local `404 Not Found` returned for legacy `/admin/keys/*` -/// aliases on the `EdgeZero` path. -/// -/// These non-`/_ts` aliases are not matched by the `^/_ts/admin` basic-auth -/// handler, so they fail closed locally rather than fall through to the -/// publisher fallback — which would forward the caller's `Authorization` header -/// and key-management payload to the origin, leaking admin credentials. -fn legacy_admin_alias_denied() -> Response { - let mut response = Response::new(edgezero_core::body::Body::from("Not found\n")); - *response.status_mut() = StatusCode::NOT_FOUND; - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - // --------------------------------------------------------------------------- // Startup error fallback // --------------------------------------------------------------------------- @@ -1397,14 +1316,6 @@ fn startup_error_router(e: &Report) -> RouterService { enum NamedRouteHandler { TrustedServerDiscovery, VerifySignature, - RotateKey, - DeactivateKey, - AdminEcLookup, - AdminEidsLookup, - AdminCachePurge, - /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never - /// reach the publisher fallback (which would leak admin credentials). - LegacyAdminDenied, BatchSync, Identify, SetTester, @@ -1427,21 +1338,6 @@ struct NamedRoute { handler: NamedRouteHandler, } -/// Every method an admin route must claim to keep non-primary methods from falling -/// through to the publisher with the `Authorization` header still attached. -/// -/// Named for the legacy `/admin/*` aliases it was introduced for, and reused by every -/// route with the same requirement here and in the Axum and Spin adapters. -const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ - Method::GET, - Method::POST, - Method::HEAD, - Method::OPTIONS, - Method::PUT, - Method::PATCH, - Method::DELETE, -]; - const NAMED_ROUTES: &[NamedRoute] = &[ NamedRoute { path: "/.well-known/trusted-server.json", @@ -1453,60 +1349,6 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::POST], handler: NamedRouteHandler::VerifySignature, }, - NamedRoute { - path: "/_ts/admin/keys/rotate", - primary_methods: &[Method::POST], - handler: NamedRouteHandler::RotateKey, - }, - NamedRoute { - path: "/_ts/admin/keys/deactivate", - primary_methods: &[Method::POST], - handler: NamedRouteHandler::DeactivateKey, - }, - // Every method is claimed, not just POST. A method this route did not claim would - // fall through to the publisher, and `enforce_basic_auth` leaves the `Authorization` - // header in place, so a GET would ship the shared admin credential to the origin. - // The handler answers the non-POST methods with 405 itself. - NamedRoute { - path: "/_ts/admin/cache/purge", - primary_methods: LEGACY_ADMIN_DENY_METHODS, - handler: NamedRouteHandler::AdminCachePurge, - }, - // Admin EC lookup: the bare route reads the EC ID from the caller's - // `ts-ec` cookie; the parameterized route takes an explicit EC ID. - NamedRoute { - path: "/_ts/admin/ec", - primary_methods: &[Method::GET], - handler: NamedRouteHandler::AdminEcLookup, - }, - NamedRoute { - path: "/_ts/admin/ec/{id}", - primary_methods: &[Method::GET], - handler: NamedRouteHandler::AdminEcLookup, - }, - // Admin EIDs echo: decodes the request's ts-eids/sharedId cookies with - // an ingestion preview. Pure request inspection — no KV access. - NamedRoute { - path: "/_ts/admin/eids", - primary_methods: &[Method::GET], - handler: NamedRouteHandler::AdminEidsLookup, - }, - // The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with a - // 404 instead of executing key operations: the production basic-auth handler - // regex `^/_ts/admin` does not match them, and letting them fall through to - // publisher fallback for any fallback method would forward the caller's - // `Authorization` header and key-management payload to the origin, leaking - // admin credentials. - NamedRoute { - path: "/admin/keys/rotate", - primary_methods: LEGACY_ADMIN_DENY_METHODS, - handler: NamedRouteHandler::LegacyAdminDenied, - }, - NamedRoute { - path: "/admin/keys/deactivate", - primary_methods: LEGACY_ADMIN_DENY_METHODS, - handler: NamedRouteHandler::LegacyAdminDenied, - }, NamedRoute { path: "/_ts/api/v1/batch-sync", primary_methods: &[Method::POST], @@ -1657,13 +1499,11 @@ impl TrustedServerApp { } fn routes_for_state(state: &Arc) -> RouterService { - let mut router = RouterService::builder() - .middleware(FinalizeResponseMiddleware::new( - Arc::clone(&state.settings), - build_geo_module(&state.settings, Arc::new(FastlyPlatformGeo)), - build_finalize_services(&state.settings, Arc::clone(&state.default_kv_store)), - )) - .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); + let mut router = RouterService::builder().middleware(FinalizeResponseMiddleware::new( + Arc::clone(&state.settings), + build_geo_module(&state.settings, Arc::new(FastlyPlatformGeo)), + build_finalize_services(&state.settings, Arc::clone(&state.default_kv_store)), + )); let fallback_handler = fallback_route_handler(Arc::clone(state)); @@ -1731,19 +1571,14 @@ mod seam_probe_tests; #[cfg(test)] mod tests { - use std::collections::HashMap; - use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; - use std::time::Duration; use super::{ - AppContext, AppState, AuctionDispatch, DEFAULT_CONFIG_STORE_ID, EcContext, EdgeCacheHeader, - EidSyncSource, HandlerFuture, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, - PAGE_BIDS_PATH, RuntimeStoreConfig, TrustedServerApp, blob_carries_its_policy, - build_orchestrator_with_plan, build_per_request_services, build_state_from_settings, - handle_publisher_request, publisher_response_into_streaming_response, startup_error_router, + AppState, DEFAULT_CONFIG_STORE_ID, EidSyncSource, NAMED_ROUTES, NamedRouteHandler, + PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, RuntimeStoreConfig, TrustedServerApp, + blob_carries_its_policy, build_per_request_services, build_state_from_settings, + startup_error_router, }; - use base64::Engine as _; use bytes::Bytes; use edgezero_core::app::Hooks as _; use edgezero_core::body::Body; @@ -1769,9 +1604,7 @@ mod tests { use trusted_server_core::platform::{ ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, PlatformResponse, - PlatformSelectResult, PlatformTemplateCache, PlatformTemplateCacheReservation, - RuntimeServices, TemplateCacheError, TemplateCacheKey, TemplateCacheLookup, - TemplateCacheMiss, TemplateCacheReservation, TemplateEntry, TemplateMetadata, + PlatformSelectResult, RuntimeServices, }; use trusted_server_core::settings::Settings; @@ -1849,16 +1682,6 @@ mod tests { fn test_settings() -> Settings { Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - - [[handlers]] - path = "^/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -1879,8 +1702,6 @@ mod tests { [request_signing] enabled = false - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" [auction] enabled = true @@ -2216,174 +2037,6 @@ mod tests { // Full EdgeZero dispatch-path tests // --------------------------------------------------------------------------- - #[test] - fn dispatch_auth_rejected_401_carries_finalize_headers() { - // Verifies FinalizeResponseMiddleware is outermost: an auth-rejected 401 - // must still carry standard TS headers before reaching the client. - // - // Test settings protect `^/(_ts/)?admin` with basic-auth. Sending the - // request without an Authorization header causes AuthMiddleware to - // short-circuit with a 401, which then bubbles through - // FinalizeResponseMiddleware for header injection. - // - // This is safe to run without Viceroy: enforce_basic_auth is pure Rust - // (reads settings + request headers only) and FastlyPlatformGeo.lookup(None) - // short-circuits without calling any Fastly ABI. - let router = test_router(); - let req = empty_request(Method::POST, "/_ts/admin/keys/rotate"); - - let response = route(&router, req); - - assert_eq!( - response.status(), - StatusCode::UNAUTHORIZED, - "request without credentials should be rejected" - ); - assert_eq!( - response - .headers() - .get(HEADER_X_GEO_INFO_AVAILABLE) - .and_then(|v| v.to_str().ok()), - Some("false"), - "FinalizeResponseMiddleware must run even for auth-rejected responses" - ); - } - - #[test] - fn legacy_admin_aliases_route_to_local_deny_not_key_handlers() { - // Security guard for the legacy non-`/_ts` admin aliases. They must be - // registered to the local `LegacyAdminDenied` 404 handler — not the - // rotate/deactivate key handlers, and not left unrouted. Leaving them - // unrouted would fall through to the publisher fallback, which forwards - // the request (including the `Authorization` header and key-management - // payload) to the origin, leaking admin credentials. Mapping them to the - // key handlers would expose key operations, since the production - // basic-auth regex `^/_ts/admin` does not match `/admin/keys/*`. - let handler_for = |path: &str| { - NAMED_ROUTES - .iter() - .find(|route| route.path == path) - .map(|route| route.handler) - }; - let methods_for = |path: &str| { - NAMED_ROUTES - .iter() - .find(|route| route.path == path) - .map(|route| route.primary_methods) - .unwrap_or(&[]) - }; - - assert!( - matches!( - handler_for("/_ts/admin/keys/rotate"), - Some(NamedRouteHandler::RotateKey) - ), - "canonical /_ts/admin/keys/rotate must map to the rotate handler" - ); - assert!( - matches!( - handler_for("/_ts/admin/keys/deactivate"), - Some(NamedRouteHandler::DeactivateKey) - ), - "canonical /_ts/admin/keys/deactivate must map to the deactivate handler" - ); - assert!( - matches!( - handler_for("/admin/keys/rotate"), - Some(NamedRouteHandler::LegacyAdminDenied) - ), - "legacy /admin/keys/rotate must map to the local deny handler, not the key handler" - ); - assert!( - matches!( - handler_for("/admin/keys/deactivate"), - Some(NamedRouteHandler::LegacyAdminDenied) - ), - "legacy /admin/keys/deactivate must map to the local deny handler, not the key handler" - ); - - for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { - for method in super::publisher_fallback_methods() { - assert!( - methods_for(path).contains(&method), - "legacy {method} {path} must route to the local deny handler, not publisher fallback" - ); - } - } - } - - #[test] - fn cache_purge_claims_every_method_that_could_reach_the_publisher() { - // The guard this route exists behind. `enforce_basic_auth` authenticates on the raw - // path and leaves the `Authorization` header attached, so any method this route does - // not claim falls through to the publisher fallback carrying the shared admin - // credential to the origin. Asserted against the fallback list itself rather than a - // copy of it, so a method added there cannot quietly open a hole here. - let route = NAMED_ROUTES - .iter() - .find(|route| route.path == "/_ts/admin/cache/purge") - .expect("cache purge must be a named route"); - - for method in super::publisher_fallback_methods() { - assert!( - route.primary_methods.contains(&method), - "{method} /_ts/admin/cache/purge must be claimed, or it reaches the publisher \ - with the admin credential attached" - ); - } - assert!(matches!(route.handler, NamedRouteHandler::AdminCachePurge)); - } - - #[test] - fn cache_purge_has_no_legacy_unauthenticated_alias() { - // The production basic-auth regex is `^/_ts/admin`. An `/admin/...` spelling would - // not match it, so it must not exist at all. - assert!( - !NAMED_ROUTES - .iter() - .any(|route| route.path == "/admin/cache/purge"), - "an /admin-prefixed alias would sit outside the basic-auth regex" - ); - } - - #[test] - fn admin_ec_lookup_routes_are_registered() { - // Both lookup shapes must be explicitly routed to the admin EC - // handler: the bare cookie-based route and the parameterized route. - // Leaving either unrouted would fall through to the publisher - // fallback, forwarding the caller's `Authorization` header to the - // origin. - for path in ["/_ts/admin/ec", "/_ts/admin/ec/{id}"] { - let route = NAMED_ROUTES - .iter() - .find(|route| route.path == path) - .unwrap_or_else(|| panic!("{path} must be a named route")); - assert!( - matches!(route.handler, NamedRouteHandler::AdminEcLookup), - "{path} must map to the admin EC lookup handler" - ); - assert_eq!( - route.primary_methods, - &[Method::GET], - "{path} must have GET as its only primary method" - ); - } - - let eids_route = NAMED_ROUTES - .iter() - .find(|route| route.path == "/_ts/admin/eids") - .expect("should register /_ts/admin/eids as a named route"); - assert!( - matches!(eids_route.handler, NamedRouteHandler::AdminEidsLookup), - "/_ts/admin/eids must map to the admin EIDs lookup handler" - ); - assert_eq!( - eids_route.primary_methods, - &[Method::GET], - "/_ts/admin/eids must have GET as its only primary method" - ); - } - #[test] fn page_bids_serves_canonical_path_and_deprecated_alias() { // The SPA re-auction endpoint lives at the canonical single-underscore @@ -2427,22 +2080,15 @@ mod tests { } #[test] - fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { - // Regression for the credential-leak finding: with a production-shaped - // config (only `^/_ts/admin` is auth-gated, so `/admin/keys/*` is NOT - // matched by any handler), any publisher-fallback method to a legacy - // alias carrying an `Authorization` header must be denied locally with - // 404 — never proxied to the publisher origin (which would leak the - // admin credentials and the key-management body). A publisher-fallback - // proxy without a backend would surface as a 5xx, so a 404 proves the - // deny route ran instead. + fn key_administration_paths_are_answered_here_and_never_proxied() { + // A request to a key administration path carries an operator's + // credentials and a key payload. Every publisher-fallback method must + // be answered here with 404 and never proxied to the publisher origin, + // which would be handed both. A publisher-fallback proxy without a + // backend would surface as a 5xx, so a 404 proves the request was + // answered here. let settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -2464,72 +2110,40 @@ mod tests { let state = build_state_from_settings(settings).expect("should build state"); let router = TrustedServerApp::routes_for_state(&state); - for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { + for path in [ + "/_ts/admin/keys/rotate", + "/_ts/admin/keys/deactivate", + "/_ts/admin/cache/purge", + "/admin/keys/rotate", + "/admin/keys/deactivate", + ] { for method in super::publisher_fallback_methods() { let req = request_builder() .method(method.clone()) .uri(format!("https://test-publisher.com{path}")) .header(header::AUTHORIZATION, "Basic YWRtaW46YWRtaW4tcGFzcw==") .body(Body::from("{\"key_id\":\"leak-me\"}")) - .expect("should build authorized legacy-alias request"); + .expect("should build authorized key administration request"); let response = route(&router, req); assert_eq!( response.status(), StatusCode::NOT_FOUND, - "{method} {path} with Authorization must be denied locally (404), not proxied to publisher" + "{method} {path} with Authorization must be answered here (404), not proxied to publisher" ); } } } #[test] - fn authenticated_admin_diagnostic_fallback_is_denied_locally() { + fn an_authenticated_request_to_a_closed_path_is_answered_here() { let router = test_router(); let ec_id = format!("{}.abc123", "a".repeat(64)); - let valid_paths = [ + for path in [ "/_ts/admin/ec".to_owned(), format!("/_ts/admin/ec/{ec_id}"), "/_ts/admin/eids".to_owned(), - ]; - - for path in valid_paths { - for method in [ - Method::POST, - Method::HEAD, - Method::OPTIONS, - Method::PUT, - Method::PATCH, - Method::DELETE, - ] { - let request = request_builder() - .method(method.clone()) - .uri(format!("https://test-publisher.com{path}")) - .header(header::AUTHORIZATION, "Basic YWRtaW46YWRtaW4tcGFzcw==") - .body(Body::from("sensitive-admin-body")) - .expect("should build authenticated admin request"); - let response = route(&router, request); - - assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); - assert_eq!( - response - .headers() - .get(header::ALLOW) - .and_then(|v| v.to_str().ok()), - Some("GET") - ); - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()), - Some("no-store") - ); - } - } - - for path in [ "/_ts/admin/ec/".to_owned(), format!("/_ts/admin/ec/{ec_id}/extra"), "/_ts/admin/eids/".to_owned(), @@ -2537,18 +2151,21 @@ mod tests { "/_ts/admin/eids.json".to_owned(), "/_ts/admin/ec;foo".to_owned(), format!("/_ts/admin/ec%2F{ec_id}"), - // Percent-encoded separators match the `^/_ts/admin` basic-auth - // handler but not a literal-slash namespace check, so they must be - // reserved before publisher fallback forwards credentials upstream. + // A check for a literal slash would miss a percent-encoded separator, + // so these are closed before the publisher fallback forwards + // credentials upstream. "/_ts/admin%2Fec".to_owned(), "/_ts/admin%2fec".to_owned(), - // Retired non-`/_ts` alias namespace: only the two exact paths are - // routed to a local deny, so descendants and encoded separators must - // be reserved at the shared fallback boundary. + // The alias outside `/_ts`, with its descendants and encoded + // separators. "/admin/keys".to_owned(), "/admin/keys/rotate/extra".to_owned(), "/admin/keys%2Frotate".to_owned(), "/admin%2fkeys/rotate".to_owned(), + // Multi-encoded separators survive a single decode, so the reservation + // decodes to a fixed point before the publisher fallback runs. + "/admin%252Fkeys/rotate".to_owned(), + "/_ts%252Fadmin/ec".to_owned(), ] { for method in [Method::GET, Method::POST] { let request = request_builder() @@ -3096,103 +2713,6 @@ mod tests { ); } - #[test] - fn admin_eids_diagnostic_skips_ec_finalization() { - let router = test_router(); - let ec_id = format!("{}.abc123", "a".repeat(64)); - let eids = serde_json::json!([{ - "source": "example.com", - "uids": [{ "id": "example-uid", "atype": 1 }] - }]); - let eids_cookie = base64::engine::general_purpose::STANDARD.encode(eids.to_string()); - let mut request = request_builder() - .method(Method::GET) - .uri("https://test-publisher.com/_ts/admin/eids") - .header(header::AUTHORIZATION, "Basic YWRtaW46YWRtaW4tcGFzcw==") - .header( - header::COOKIE, - format!("ts-ec={ec_id}; ts-eids={eids_cookie}; sharedId=example-shared-id"), - ) - .body(Body::empty()) - .expect("should build authenticated EIDs diagnostic request"); - request.extensions_mut().insert(DeviceSignals::derive( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", - Some("t13d1516h2_8daaf6152771_b186095e22b6"), - Some("1:65536;2:0;4:6291456;6:262144"), - )); - - let response = route(&router, request); - - assert_eq!(response.status(), StatusCode::OK); - assert!( - response - .extensions() - .get::() - .is_none(), - "admin EIDs diagnostics should not attach EC finalization state" - ); - } - - #[test] - fn admin_ec_diagnostic_skips_ec_finalization() { - let router = test_router(); - let ec_id = format!("{}.abc123", "a".repeat(64)); - let eids = serde_json::json!([{ - "source": "example.com", - "uids": [{ "id": "example-uid", "atype": 1 }] - }]); - let eids_cookie = base64::engine::general_purpose::STANDARD.encode(eids.to_string()); - let mut request = request_builder() - .method(Method::GET) - .uri(format!("https://test-publisher.com/_ts/admin/ec/{ec_id}")) - .header(header::AUTHORIZATION, "Basic YWRtaW46YWRtaW4tcGFzcw==") - .header( - header::COOKIE, - format!("ts-ec={ec_id}; ts-eids={eids_cookie}; sharedId=example-shared-id"), - ) - .body(Body::empty()) - .expect("should build authenticated EC diagnostic request"); - request.extensions_mut().insert(DeviceSignals::derive( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \ - (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", - Some("t13d1516h2_8daaf6152771_b186095e22b6"), - Some("1:65536;2:0;4:6291456;6:262144"), - )); - - let response = route(&router, request); - - assert_eq!( - response.status(), - StatusCode::NOT_IMPLEMENTED, - "configured admin EC handler should run and report the unavailable test KV graph" - ); - assert!( - response - .extensions() - .get::() - .is_none(), - "admin EC diagnostics should not attach EC finalization state" - ); - assert!( - response.headers().get(header::SET_COOKIE).is_none(), - "admin EC diagnostics should not mutate the EC cookie" - ); - } - - #[test] - fn admin_ec_route_without_credentials_returns_401() { - let router = test_router(); - - let response = route(&router, empty_request(Method::GET, "/_ts/admin/ec")); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - response.headers().contains_key(header::WWW_AUTHENTICATE), - "admin EC 401 should include the Basic authentication challenge" - ); - } - #[test] fn dispatch_head_on_named_get_route_falls_through_to_publisher_fallback() { // Regression guard: HEAD /first-party/proxy must reach the publisher @@ -3255,11 +2775,6 @@ mod tests { // ran instead of the publisher fallback (which always attaches one). let settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -3277,8 +2792,6 @@ mod tests { [request_signing] enabled = false - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" [proxy] @@ -3383,315 +2896,6 @@ mod tests { .build() } - #[derive(Default)] - struct DispatchTemplateCache { - entries: Arc>>, - } - - struct DispatchTemplateReservation { - entries: Arc>>, - key: TemplateCacheKey, - } - - impl PlatformTemplateCacheReservation for DispatchTemplateReservation { - fn insert( - self: Box, - metadata: &TemplateMetadata, - body: Vec, - _max_age: Duration, - ) -> Result<(), TemplateCacheError> { - self.entries.lock().expect("should lock entries").insert( - self.key.to_cache_key(), - TemplateEntry { - metadata: metadata.clone(), - body, - }, - ); - Ok(()) - } - - fn cancel(self: Box) -> Result<(), TemplateCacheError> { - Ok(()) - } - } - - #[async_trait::async_trait(?Send)] - impl PlatformTemplateCache for DispatchTemplateCache { - async fn lookup_or_reserve( - &self, - key: &TemplateCacheKey, - ) -> Result { - if let Some(entry) = self - .entries - .lock() - .expect("should lock entries") - .get(&key.to_cache_key()) - .cloned() - { - return Ok(TemplateCacheLookup::Hit(entry)); - } - Ok(TemplateCacheLookup::Reserved( - TemplateCacheReservation::new(Box::new(DispatchTemplateReservation { - entries: Arc::clone(&self.entries), - key: key.clone(), - })), - )) - } - - async fn get(&self, key: &TemplateCacheKey) -> Result { - self.entries - .lock() - .expect("should lock entries") - .get(&key.to_cache_key()) - .cloned() - .ok_or(TemplateCacheMiss::NotFound) - } - - async fn put( - &self, - key: &TemplateCacheKey, - metadata: &TemplateMetadata, - body: Vec, - _max_age: Duration, - ) -> Result<(), TemplateCacheError> { - self.entries.lock().expect("should lock entries").insert( - key.to_cache_key(), - TemplateEntry { - metadata: metadata.clone(), - body, - }, - ); - Ok(()) - } - - async fn purge_url(&self, key: &TemplateCacheKey) -> Result<(), TemplateCacheError> { - self.entries - .lock() - .expect("should lock entries") - .remove(&key.to_cache_key()); - Ok(()) - } - - /// A no-op beyond succeeding: this double stores by cache key, so it cannot - /// resolve a surrogate key to entries the way the platform does. - async fn purge_url_surrogate_key(&self, _key: &str) -> Result<(), TemplateCacheError> { - Ok(()) - } - - async fn purge_all(&self) -> Result<(), TemplateCacheError> { - self.entries.lock().expect("should lock entries").clear(); - Ok(()) - } - } - - #[derive(Default)] - struct DispatchOriginClient { - calls: AtomicUsize, - } - - #[async_trait::async_trait(?Send)] - impl PlatformHttpClient for DispatchOriginClient { - async fn send( - &self, - _request: PlatformHttpRequest, - ) -> Result> { - self.calls.fetch_add(1, Ordering::Relaxed); - let response = edgezero_core::http::response_builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .header(header::CACHE_CONTROL, "public, max-age=300") - .body(Body::from( - b"origin".as_ref(), - )) - .map_err(|_| Report::new(PlatformError::HttpClient))?; - Ok(PlatformResponse::new(response)) - } - - async fn send_async( - &self, - _request: PlatformHttpRequest, - ) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } - - async fn select( - &self, - _pending_requests: Vec, - ) -> Result> { - Err(Report::new(PlatformError::Unsupported)) - } - } - - #[test] - fn dispatch_edge_authenticated_esi_request_stores_then_hits_template() { - let settings = Arc::new( - Settings::from_toml( - r#" - [[handlers]] - path = "^/secure" - username = "user" - password = "pass" - - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - - [publisher] - domain = "test-publisher.com" - cookie_domain = ".test-publisher.com" - origin_url = "https://origin.test-publisher.com" - proxy_secret = "unit-test-proxy-secret" - - [ec] - passphrase = "test-secret-key-32-bytes-minimum" - - # The deprecated passphrase migrates to the hmac module, so - # single-jurisdiction operation is acknowledged because no - # geo module is selected. - [geo] - assume_single_jurisdiction = true - - [auction] - enabled = true - - [creative_opportunities] - gam_network_id = "99999" - assembly_mode = "esi" - - [[creative_opportunities.slot]] - id = "test-slot" - page_patterns = ["/secure/article"] - formats = [{ width = 728, height = 90 }] - "#, - ) - .expect("should parse dispatch cache settings"), - ); - let cache = Arc::new(DispatchTemplateCache::default()); - let origin = Arc::new(DispatchOriginClient::default()); - let services = RuntimeServices::builder() - .config_store(Arc::new(crate::platform::FastlyPlatformConfigStore)) - .secret_store(Arc::new(crate::platform::FastlyPlatformSecretStore)) - .kv_store(Arc::new(NoopKvStore) as Arc) - .template_cache(Arc::clone(&cache) as Arc) - .template_assembler(Arc::new(crate::esi_assembly::FastlyTemplateAssembler)) - .backend(Arc::new(FixedBackend)) - .http_client(Arc::clone(&origin) as Arc) - .geo(Arc::new(crate::platform::FastlyPlatformGeo)) - .client_info(ClientInfo::default()) - .permission_policy(trusted_server_core::permissions::PermissionPolicy::sample()) - .build(); - let stock = trusted_server_modules::builders(); - let plan = Arc::new( - compile_auction_plan_with(&settings, &stock).expect("should compile auction plan"), - ); - let registry = Arc::new( - IntegrationRegistry::with_plan_and_registrations(&settings, Arc::clone(&plan), &stock) - .expect("should build integration registry"), - ); - let orchestrator = Arc::new( - build_orchestrator_with_plan(plan).expect("should build auction orchestrator"), - ); - - let handler = { - let settings = Arc::clone(&settings); - let services = services.clone(); - let registry = Arc::clone(®istry); - let orchestrator = Arc::clone(&orchestrator); - move |ctx: RequestContext| { - let settings = Arc::clone(&settings); - let services = services.clone(); - let registry = Arc::clone(®istry); - let orchestrator = Arc::clone(&orchestrator); - Box::pin(async move { - let request = ctx.into_request(); - let method = request.method().clone(); - let mut ec_context = - match EcContext::read_from_request(&settings, &request, &services) { - Ok(context) => context, - Err(report) => return Ok(super::http_error(&report)), - }; - let response = match handle_publisher_request( - AppContext { - settings: &settings, - integration_registry: registry.as_ref(), - }, - &services, - None, - &mut ec_context, - AuctionDispatch { - orchestrator: &orchestrator, - slots: settings.creative_opportunity_slots(), - registry: None, - }, - request, - EdgeCacheHeader::SurrogateControl, - ) - .await - { - Ok(response) => response, - Err(report) => return Ok(super::http_error(&report)), - }; - match publisher_response_into_streaming_response( - response, - &method, - Arc::clone(&settings), - ®istry, - orchestrator, - services, - ) - .await - { - Ok(response) => Ok(response), - Err(report) => Ok(super::http_error(&report)), - } - }) as HandlerFuture - } - }; - let router = RouterService::builder() - .middleware(crate::middleware::AuthMiddleware::new(Arc::clone( - &settings, - ))) - .route("/secure/article", Method::GET, handler) - .build(); - let authorized_request = || { - request_builder() - .method(Method::GET) - .uri("https://test-publisher.com/secure/article") - .header(header::HOST, "test-publisher.com") - .header(header::AUTHORIZATION, "Basic dXNlcjpwYXNz") - .header("sec-fetch-dest", "document") - .header("sec-fetch-mode", "navigate") - .body(Body::empty()) - .expect("should build authorized navigation") - }; - - let cold = route(&router, authorized_request()); - assert_eq!( - cold.headers() - .get("x-ts-template-cache") - .and_then(|value| value.to_str().ok()), - Some("miss-stored") - ); - block_on(cold.into_body().into_bytes_bounded(1024 * 1024)) - .expect("should drain cold response"); - - let warm = route(&router, authorized_request()); - assert_eq!( - warm.headers() - .get("x-ts-template-cache") - .and_then(|value| value.to_str().ok()), - Some("hit") - ); - block_on(warm.into_body().into_bytes_bounded(1024 * 1024)) - .expect("should drain warm response"); - assert_eq!( - origin.calls.load(Ordering::Relaxed), - 1, - "the warm dispatch must not fetch the publisher origin" - ); - } - #[test] fn dispatch_asset_fallback_streams_origin_body_without_buffering() { // Regression guard for the EdgeZero asset streaming cutover: a successful @@ -3701,11 +2905,6 @@ mod tests { // pipe straight to the client via `stream_to_client`. let settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -3723,8 +2922,6 @@ mod tests { [request_signing] enabled = false - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" [proxy] diff --git a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs index 8ea5d2bd5d..e564d6a076 100644 --- a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs @@ -66,11 +66,6 @@ fn settings_with(extra: &str) -> Settings { }; Settings::from_toml(&format!( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -224,15 +219,14 @@ fn a_named_route_and_the_fallback_each_prepare_the_request_exactly_once() { &[seam_probe::builder()], ); - // `/admin/keys/rotate` is a named route that answers with a local 404, - // and the `^/_ts/admin` handler does not cover it, so the request reaches - // the preparer rather than being turned back with a 401. - let named = get(&router, "/admin/keys/rotate", Some("fastly-named-route")); + // `/first-party/proxy` is a named route whose handler refuses a request + // naming no target, so the request reaches the preparer and no backend. + let named = get(&router, "/first-party/proxy", Some("fastly-named-route")); assert_eq!( named.status().as_u16(), - 404, - "the named route should serve the local deny" + 502, + "the named route should refuse a request that names no target" ); assert_eq!( seam_probe::prepare_runs_for("fastly-named-route"), diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs index 0151cbb165..d61d090ca8 100644 --- a/crates/trusted-server-adapter-fastly/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -2,7 +2,6 @@ use std::time::Duration; use error_stack::{Report, ResultExt as _}; use fastly::backend::Backend; -use url::Url; use trusted_server_core::error::TrustedServerError; use trusted_server_core::platform::{BackendNamingPolicy, PlatformBackendSpec, PredictedBackend}; @@ -273,93 +272,6 @@ impl<'a> BackendConfig<'a> { } } } - - /// Parse an origin URL into its (scheme, host, port) components. - /// - /// Centralises URL parsing so that [`from_url`](Self::from_url) and - /// [`from_url_with_first_byte_timeout`](Self::from_url_with_first_byte_timeout) - /// share one code-path. - fn parse_origin( - origin_url: &str, - ) -> Result<(String, String, Option), Report> { - let parsed_url = Url::parse(origin_url).change_context(TrustedServerError::Proxy { - message: format!("Invalid origin_url: {origin_url}"), - })?; - - let scheme = parsed_url.scheme().to_owned(); - let host = parsed_url - .host_str() - .ok_or_else(|| { - Report::new(TrustedServerError::Proxy { - message: "Missing host in origin_url".to_owned(), - }) - })? - .to_owned(); - let port = parsed_url.port(); - - Ok((scheme, host, port)) - } - - /// Parse an origin URL and ensure a dynamic backend exists for it. - /// - /// This is a convenience constructor that parses the URL, extracts scheme, - /// host, and port, then calls [`ensure`](Self::ensure) with the default - /// 15 s first-byte timeout. - /// - /// # Errors - /// - /// Returns an error if the URL cannot be parsed or lacks a host, or if - /// backend creation fails. - pub fn from_url( - origin_url: &str, - certificate_check: bool, - ) -> Result> { - Self::from_url_with_first_byte_timeout( - origin_url, - certificate_check, - DEFAULT_FIRST_BYTE_TIMEOUT, - ) - } - - /// Parse an origin URL and ensure a dynamic backend with a custom - /// first-byte timeout. - /// - /// For latency-sensitive paths (e.g. auction bid requests) callers should - /// pass the remaining auction budget so that individual requests don't hang - /// longer than the overall deadline allows. - /// - /// # Errors - /// - /// Returns an error if the URL cannot be parsed or lacks a host, or if - /// backend creation fails. - pub fn from_url_with_first_byte_timeout( - origin_url: &str, - certificate_check: bool, - first_byte_timeout: Duration, - ) -> Result> { - Self::from_url_with_first_byte_timeout_and_host_header_override( - origin_url, - certificate_check, - first_byte_timeout, - None, - ) - } - - fn from_url_with_first_byte_timeout_and_host_header_override( - origin_url: &str, - certificate_check: bool, - first_byte_timeout: Duration, - host_header_override: Option<&str>, - ) -> Result> { - let (scheme, host, port) = Self::parse_origin(origin_url)?; - - BackendConfig::new(&scheme, &host) - .port(port) - .certificate_check(certificate_check) - .first_byte_timeout(first_byte_timeout) - .host_header_override(host_header_override) - .ensure() - } } #[cfg(test)] @@ -426,14 +338,10 @@ mod tests { #[test] fn url_derived_ipv6_host_uses_bare_tls_identity_without_sni() { - let (scheme, url_host, port) = - BackendConfig::parse_origin("https://[2001:db8::7]:8443/openrtb") - .expect("should parse IPv6 provider URL"); - assert_eq!(scheme, "https"); - assert_eq!(url_host, "[2001:db8::7]"); - assert_eq!(port, Some(8443)); - - let normalized = normalize_backend_host(&url_host); + // The bracketed form `url::Url::host_str` gives for an IPv6 literal. + let (scheme, url_host, port) = ("https", "[2001:db8::7]", Some(8443)); + + let normalized = normalize_backend_host(url_host); assert_eq!(normalized.identity, "2001:db8::7"); assert_eq!(normalized.authority, "[2001:db8::7]"); assert!( @@ -441,11 +349,11 @@ mod tests { "IP literals must omit TLS SNI while retaining a bare certificate identity" ); - let from_url_name = BackendConfig::new(&scheme, &url_host) + let from_url_name = BackendConfig::new(scheme, url_host) .port(port) .predict_name() .expect("should predict URL-derived IPv6 backend name"); - let from_bare_name = BackendConfig::new(&scheme, "2001:db8::7") + let from_bare_name = BackendConfig::new(scheme, "2001:db8::7") .port(port) .predict_name() .expect("should predict bare IPv6 backend name"); diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 9e8448f3f1..93d6bb6a45 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -50,7 +50,6 @@ mod config_selection; mod ec_kv; mod esi_assembly; mod logging; -mod management_api; mod middleware; mod platform; mod rate_limiter; @@ -1162,11 +1161,6 @@ mod tests { fn test_settings() -> Settings { Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -1184,8 +1178,6 @@ mod tests { [request_signing] enabled = false - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" "#, ) .expect("should parse test settings") diff --git a/crates/trusted-server-adapter-fastly/src/management_api.rs b/crates/trusted-server-adapter-fastly/src/management_api.rs deleted file mode 100644 index a8469bcca7..0000000000 --- a/crates/trusted-server-adapter-fastly/src/management_api.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! Fastly management API transport for store write operations. -//! -//! Provides [`FastlyManagementApiClient`], which wraps the Fastly REST -//! management API for write operations on config and secret stores. -//! Used by [`super::platform::FastlyPlatformConfigStore`] and -//! [`super::platform::FastlyPlatformSecretStore`] to back store write methods. -//! -//! # Credentials -//! -//! The Fastly API token is read from the `api-keys` secret store under the -//! `api_key` entry. The token must have config-store write and secret-store -//! write permissions only — no service-level admin or purge permissions. -//! -//! # Security -//! -//! Credential values are never logged. Log messages include store IDs and -//! operation names only. - -use std::io::Read; - -use base64::{Engine as _, engine::general_purpose}; -use error_stack::{Report, ResultExt}; -use fastly::http::{Method, StatusCode}; -use fastly::{Request, Response}; -use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreName}; - -use crate::backend::BackendConfig; -use crate::platform::FastlyPlatformSecretStore; - -const FASTLY_API_HOST: &str = "https://api.fastly.com"; -const API_KEYS_STORE: &str = "api-keys"; -const API_KEY_ENTRY: &str = "api_key"; -const ERROR_BODY_LIMIT: usize = 200; - -fn encode_path_segment(value: &str) -> String { - urlencoding::encode(value).into_owned() -} - -pub(crate) fn build_config_item_payload(value: &str) -> String { - format!("item_value={}", urlencoding::encode(value)) -} - -pub(crate) fn build_config_item_path(store_id: &str, key: &str) -> String { - format!( - "/resources/stores/config/{}/item/{}", - encode_path_segment(store_id), - encode_path_segment(key) - ) -} - -fn build_secret_collection_path(store_id: &str) -> String { - format!( - "/resources/stores/secret/{}/secrets", - encode_path_segment(store_id) - ) -} - -fn build_secret_path(store_id: &str, secret_name: &str) -> String { - format!( - "/resources/stores/secret/{}/secrets/{}", - encode_path_segment(store_id), - encode_path_segment(secret_name) - ) -} - -fn build_secret_payload(secret_name: &str, secret_value: &str) -> String { - serde_json::json!({ - "name": secret_name, - "secret": general_purpose::STANDARD.encode(secret_value.as_bytes()), - "method": "create_or_recreate", - }) - .to_string() -} - -fn truncate_error_body(body: &str) -> String { - body.trim().chars().take(ERROR_BODY_LIMIT).collect() -} - -fn check_response( - response: &mut Response, - error_kind: fn() -> PlatformError, - operation: &str, - entity_description: &str, - store_id: &str, -) -> Result<(), Report> { - if response.get_status().is_success() { - return Ok(()); - } - - let mut body = String::new(); - response - .get_body_mut() - .read_to_string(&mut body) - .change_context(error_kind())?; - - Err(Report::new(error_kind()).attach(format!( - "{} failed with HTTP {} - {} for {} in store '{}'", - operation, - response.get_status(), - truncate_error_body(&body), - entity_description, - store_id - ))) -} - -/// HTTP client for Fastly management API write operations. -/// -/// Backs the `put`/`delete` methods of -/// [`super::platform::FastlyPlatformConfigStore`] and the `create`/`delete` -/// methods of [`super::platform::FastlyPlatformSecretStore`]. -pub(crate) struct FastlyManagementApiClient { - api_key: String, - base_url: &'static str, - backend_name: String, -} - -impl FastlyManagementApiClient { - /// Initialize the client by reading the API token from the `api-keys` secret store. - /// - /// # Errors - /// - /// Returns [`PlatformError::Backend`] if the management API backend cannot - /// be registered, or [`PlatformError::SecretStore`] if the API key cannot - /// be read. - pub(crate) fn new() -> Result> { - let backend_name = BackendConfig::from_url(FASTLY_API_HOST, true) - .change_context(PlatformError::Backend) - .attach("failed to register Fastly management API backend")?; - - let api_key = FastlyPlatformSecretStore - .get_string(&StoreName::from(API_KEYS_STORE), API_KEY_ENTRY) - .change_context(PlatformError::SecretStore) - .attach("failed to read Fastly API key from secret store")?; - - log::debug!("FastlyManagementApiClient: initialized for management API operations"); - - Ok(Self { - api_key, - base_url: FASTLY_API_HOST, - backend_name, - }) - } - - /// Construct a client from explicit components for tests. - #[cfg(test)] - pub(crate) fn with_components( - api_key: String, - base_url: &'static str, - backend_name: String, - ) -> Self { - Self { - api_key, - base_url, - backend_name, - } - } - - fn make_request( - &self, - method: Method, - path: &str, - body: Option, - content_type: &str, - error_kind: fn() -> PlatformError, - ) -> Result> { - let url = format!("{}{}", self.base_url, path); - - let mut request = Request::new(method, &url); - - request = request - .with_header("Fastly-Key", &self.api_key) - .with_header("Accept", "application/json"); - - if let Some(body_content) = body { - request = request - .with_header("Content-Type", content_type) - .with_body(body_content); - } - - request.send(&self.backend_name).map_err(|e| { - Report::new(error_kind()).attach(format!("management API request failed: {}", e)) - }) - } - - /// Update or create a config store item. - /// - /// # Errors - /// - /// Returns an error if the API request fails or returns a non-OK status. - pub(crate) fn update_config_item( - &self, - store_id: &str, - key: &str, - value: &str, - ) -> Result<(), Report> { - let path = build_config_item_path(store_id, key); - let payload = build_config_item_payload(value); - - let mut response = self.make_request( - Method::PUT, - &path, - Some(payload), - "application/x-www-form-urlencoded", - || PlatformError::ConfigStore, - )?; - - let entity_description = format!("key '{}'", key); - check_response( - &mut response, - || PlatformError::ConfigStore, - "config item update", - &entity_description, - store_id, - )?; - - log::debug!( - "FastlyManagementApiClient: updated config key '{}' in store '{}'", - key, - store_id - ); - Ok(()) - } - - /// Delete a config store item. - /// - /// Returns `Ok(())` if the item does not exist (404), so retries after - /// partial failures converge without error. - /// - /// # Errors - /// - /// Returns an error if the API request fails or returns an unexpected non-2xx status. - pub(crate) fn delete_config_item( - &self, - store_id: &str, - key: &str, - ) -> Result<(), Report> { - let path = build_config_item_path(store_id, key); - - let mut response = - self.make_request(Method::DELETE, &path, None, "application/json", || { - PlatformError::ConfigStore - })?; - - if response.get_status() == StatusCode::NOT_FOUND { - return Ok(()); - } - - let entity_description = format!("key '{}'", key); - check_response( - &mut response, - || PlatformError::ConfigStore, - "config item delete", - &entity_description, - store_id, - )?; - - log::debug!( - "FastlyManagementApiClient: deleted config key '{}' from store '{}'", - key, - store_id - ); - Ok(()) - } - - /// Create or overwrite a secret store entry. - /// - /// # Errors - /// - /// Returns an error if the API request fails or returns a non-OK status. - pub(crate) fn create_secret( - &self, - store_id: &str, - secret_name: &str, - secret_value: &str, - ) -> Result<(), Report> { - let path = build_secret_collection_path(store_id); - let payload = build_secret_payload(secret_name, secret_value); - - let mut response = self.make_request( - Method::POST, - &path, - Some(payload), - "application/json", - || PlatformError::SecretStore, - )?; - - let entity_description = format!("name '{}'", secret_name); - check_response( - &mut response, - || PlatformError::SecretStore, - "secret upsert", - &entity_description, - store_id, - )?; - - log::debug!( - "FastlyManagementApiClient: upserted secret '{}' in store '{}'", - secret_name, - store_id - ); - Ok(()) - } - - /// Delete a secret store entry. - /// - /// Returns `Ok(())` if the secret does not exist (404), so retries after - /// partial failures converge without error. - /// - /// # Errors - /// - /// Returns an error if the API request fails or returns an unexpected non-2xx status. - pub(crate) fn delete_secret( - &self, - store_id: &str, - secret_name: &str, - ) -> Result<(), Report> { - let path = build_secret_path(store_id, secret_name); - - let mut response = - self.make_request(Method::DELETE, &path, None, "application/json", || { - PlatformError::SecretStore - })?; - - if response.get_status() == StatusCode::NOT_FOUND { - return Ok(()); - } - - let entity_description = format!("name '{}'", secret_name); - check_response( - &mut response, - || PlatformError::SecretStore, - "secret delete", - &entity_description, - store_id, - )?; - - log::debug!( - "FastlyManagementApiClient: deleted secret '{}' from store '{}'", - secret_name, - store_id - ); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_config_item_payload_url_encodes_reserved_characters() { - let payload = build_config_item_payload(r#"value with spaces + symbols &= {"kid":"a+b"}"#); - - assert_eq!( - payload, - "item_value=value%20with%20spaces%20%2B%20symbols%20%26%3D%20%7B%22kid%22%3A%22a%2Bb%22%7D", - "should URL-encode config item values in form payloads" - ); - } - - #[test] - fn build_config_item_path_url_encodes_store_id_and_key() { - let path = build_config_item_path("store/id", "current?kid+#1"); - - assert_eq!( - path, "/resources/stores/config/store%2Fid/item/current%3Fkid%2B%231", - "should percent-encode reserved path characters" - ); - } - - #[test] - fn build_secret_payload_base64_encodes_raw_secret_value() { - let payload = build_secret_payload("signing-key", "raw-secret-value"); - let json: serde_json::Value = - serde_json::from_str(&payload).expect("should serialize secret payload as JSON"); - - assert_eq!(json["name"], "signing-key"); - assert_eq!( - json["secret"], - base64::engine::general_purpose::STANDARD.encode("raw-secret-value"), - "should base64-encode the secret payload for the Fastly API" - ); - assert_eq!( - json["method"], "create_or_recreate", - "should request upsert semantics so re-rotation of the same kid succeeds" - ); - } - - #[test] - fn truncate_error_body_limits_length_after_trimming() { - let body = format!(" {} ", "a".repeat(250)); - - let truncated = truncate_error_body(&body); - - assert_eq!(truncated.len(), 200, "should cap error bodies at 200 chars"); - assert_eq!(truncated, "a".repeat(200), "should trim before truncating"); - } - - #[test] - fn create_secret_uses_secret_store_error_for_transport_failures() { - let client = FastlyManagementApiClient::with_components( - "test-api-key".to_string(), - FASTLY_API_HOST, - "missing-management-backend".to_string(), - ); - - let err = client - .create_secret("store-id", "secret-name", "secret-value") - .expect_err("should fail when the management API backend is unavailable"); - - assert!( - matches!(err.current_context(), &PlatformError::SecretStore), - "should classify secret transport failures as secret-store errors" - ); - } -} diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index f5d07e438b..2bab1b58a3 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -1,13 +1,8 @@ //! Middleware implementations for the `EdgeZero` entry point. //! -//! Provides two middleware types used by the `EdgeZero` entry point: -//! -//! - [`FinalizeResponseMiddleware`] — geo lookup and standard TS header injection -//! - [`AuthMiddleware`] — basic-auth enforcement via [`enforce_basic_auth`] -//! -//! Registration order in [`crate::app`]: `FinalizeResponseMiddleware` outermost, -//! then `AuthMiddleware`. This ensures auth-rejected responses also receive the -//! standard TS headers before being returned to the client. +//! Provides the middleware the `EdgeZero` entry point registers, +//! [`FinalizeResponseMiddleware`], which looks the reader's location up and +//! injects the standard TS headers. use std::sync::Arc; @@ -18,7 +13,6 @@ use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response, StatusCode}; use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::response::IntoResponse; -use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_FASTLY_VERSION, @@ -36,9 +30,8 @@ pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; /// Outermost middleware: performs geo lookup and injects all standard TS response headers. /// -/// Registered first in the middleware chain so that it wraps all inner middleware -/// (including [`AuthMiddleware`]) and the handler. This guarantees every registered-route -/// response — including auth-rejected ones — carries a consistent set of headers. +/// Registered first in the middleware chain so that it wraps the handler. This +/// guarantees every registered-route response carries a consistent set of headers. /// /// Router-level 405/404 responses for unregistered HTTP methods (e.g. TRACE) bypass the /// middleware chain. Those are covered by a second call to [`apply_finalize_headers`] at @@ -120,51 +113,6 @@ impl Middleware for FinalizeResponseMiddleware { } } -// --------------------------------------------------------------------------- -// AuthMiddleware -// --------------------------------------------------------------------------- - -/// Inner middleware: enforces basic-auth before the handler runs. -/// -/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the -/// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). -/// - `Ok(None)` → no auth required or credentials accepted; continue the chain. -/// - `Err(report)` → internal error; log and convert to an HTTP response via -/// [`crate::app::http_error`] using the error's documented status code. -/// -/// # Errors -/// -/// When [`enforce_basic_auth`] returns an error report, converts it to an HTTP -/// response via [`crate::app::http_error`] (preserving the error's status code) -/// so that [`FinalizeResponseMiddleware`] can still inject standard TS headers -/// before the response reaches the client. -pub struct AuthMiddleware { - settings: Arc, -} - -impl AuthMiddleware { - /// Creates a new [`AuthMiddleware`] with the given settings. - pub fn new(settings: Arc) -> Self { - Self { settings } - } -} - -#[async_trait(?Send)] -impl Middleware for AuthMiddleware { - async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { - Ok(Some(response)) => return Ok(response), - Ok(None) => {} - Err(report) => { - log::error!("auth check failed: {:?}", report); - return Ok(crate::app::http_error(&report)); - } - } - - next.run(ctx).await - } -} - // --------------------------------------------------------------------------- // Shared geo resolution helper // --------------------------------------------------------------------------- @@ -352,11 +300,6 @@ mod tests { fn test_settings() -> Settings { Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -374,8 +317,6 @@ mod tests { [request_signing] enabled = false - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" "#, ) .expect("should parse test settings") @@ -880,10 +821,6 @@ mod tests { ); } - // --------------------------------------------------------------------------- - // AuthMiddleware::handle tests - // --------------------------------------------------------------------------- - #[test] fn finalize_handle_preserves_duplicate_set_cookie_headers() { // Regression guard: FinalizeResponseMiddleware must not drop duplicate @@ -915,25 +852,6 @@ mod tests { ); } - #[test] - fn auth_handle_passes_through_when_auth_not_configured() { - let settings = test_settings(); - let middleware = AuthMiddleware::new(Arc::new(settings)); - let handler = - Arc::new( - |_ctx: RequestContext| async move { Ok::(empty_response()) }, - ); - - let response = block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) - .expect("should pass through when auth is not configured"); - - assert_eq!( - response.status(), - StatusCode::OK, - "should reach the handler when auth is not required" - ); - } - #[test] fn version_headers_split_git_and_fastly_versions() { let settings = settings_with_response_headers(vec![]); diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 7892402f94..ac2edc447f 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -27,17 +27,9 @@ use trusted_server_core::settings_data::resolve_fastly_chunk_pointer; /// Fastly [`ConfigStore`]-backed implementation of [`PlatformConfigStore`]. /// -/// Stateless — the store name is supplied per call, matching the trait -/// signature. This replaces the store-name-at-construction pattern of -/// the legacy `FastlyConfigStore` (removed). -/// -/// # Write cost -/// -/// `put` and `delete` each perform a synchronous outbound HTTPS request to the -/// Fastly management API (`api.fastly.com`). Callers that issue many writes in -/// one request pay one round-trip per call. The `"api-keys"` secret store is -/// opened per call to read the management token; the Fastly Compute SDK caches -/// the open handle so that cost is negligible. +/// Reads take the store name on each call. The service never writes to a +/// config store, so `put` and `delete` are refused. Signing keys are written +/// by `ts keys`. pub struct FastlyPlatformConfigStore; /// The shared policy entries of a config store, from which a blob the keyed @@ -102,14 +94,14 @@ impl PlatformConfigStore for FastlyPlatformConfigStore { }) } - fn put(&self, store_id: &StoreId, key: &str, value: &str) -> Result<(), Report> { - let client = crate::management_api::FastlyManagementApiClient::new()?; - client.update_config_item(store_id.as_ref(), key, value) + fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore) + .attach("the service does not write to config stores")) } - fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report> { - let client = crate::management_api::FastlyManagementApiClient::new()?; - client.delete_config_item(store_id.as_ref(), key) + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::ConfigStore) + .attach("the service does not write to config stores")) } } @@ -119,15 +111,9 @@ impl PlatformConfigStore for FastlyPlatformConfigStore { /// Fastly [`SecretStore`]-backed implementation of [`PlatformSecretStore`]. /// -/// Stateless — the store name is supplied per call. This replaces the -/// store-name-at-construction pattern of the legacy `FastlySecretStore` -/// (removed). -/// -/// # Write cost -/// -/// `create` and `delete` have the same per-call -/// [`crate::management_api::FastlyManagementApiClient`] cost described on -/// [`FastlyPlatformConfigStore`]. +/// Reads take the store name on each call. The service never writes to a +/// secret store, so `create` and `delete` are refused. Signing keys are +/// written by `ts keys`. pub struct FastlyPlatformSecretStore; impl PlatformSecretStore for FastlyPlatformSecretStore { @@ -163,19 +149,14 @@ impl PlatformSecretStore for FastlyPlatformSecretStore { }) } - fn create( - &self, - store_id: &StoreId, - name: &str, - value: &str, - ) -> Result<(), Report> { - let client = crate::management_api::FastlyManagementApiClient::new()?; - client.create_secret(store_id.as_ref(), name, value) + fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore) + .attach("the service does not write to secret stores")) } - fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report> { - let client = crate::management_api::FastlyManagementApiClient::new()?; - client.delete_secret(store_id.as_ref(), name) + fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::SecretStore) + .attach("the service does not write to secret stores")) } } diff --git a/crates/trusted-server-adapter-fastly/src/sandbox.rs b/crates/trusted-server-adapter-fastly/src/sandbox.rs index 0227a46210..37cfad885e 100644 --- a/crates/trusted-server-adapter-fastly/src/sandbox.rs +++ b/crates/trusted-server-adapter-fastly/src/sandbox.rs @@ -569,11 +569,6 @@ mod tests { fn test_settings() -> Settings { Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example" cookie_domain = ".test-publisher.example" diff --git a/crates/trusted-server-adapter-fastly/src/template_cache.rs b/crates/trusted-server-adapter-fastly/src/template_cache.rs index e6f3ba2c08..58fbc2714d 100644 --- a/crates/trusted-server-adapter-fastly/src/template_cache.rs +++ b/crates/trusted-server-adapter-fastly/src/template_cache.rs @@ -18,8 +18,7 @@ use fastly::cache::core::{CacheKey, Found, Transaction}; use std::io::Write as _; use std::time::Duration; use trusted_server_core::platform::{ - PlatformTemplateCache, PlatformTemplateCacheReservation, - TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY, TemplateCacheError, TemplateCacheKey, + PlatformTemplateCache, PlatformTemplateCacheReservation, TemplateCacheError, TemplateCacheKey, TemplateCacheLookup, TemplateCacheMiss, TemplateCacheReservation, TemplateEntry, TemplateMetadata, }; @@ -279,16 +278,6 @@ impl PlatformTemplateCache for FastlyTemplateCache { fastly::http::purge::purge_surrogate_key(&key.url_surrogate_key()) .map_err(|e| backend_error(format!("purging invalid template failed: {e:?}"))) } - - async fn purge_url_surrogate_key(&self, key: &str) -> Result<(), TemplateCacheError> { - fastly::http::purge::purge_surrogate_key(key) - .map_err(|e| backend_error(format!("purging surrogate key {key} failed: {e:?}"))) - } - - async fn purge_all(&self) -> Result<(), TemplateCacheError> { - fastly::http::purge::purge_surrogate_key(TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY) - .map_err(|e| backend_error(format!("purging templates failed: {e:?}"))) - } } #[cfg(test)] @@ -296,7 +285,9 @@ mod tests { use super::*; use std::io; use trusted_server_core::creative_opportunities::AssemblyMode; - use trusted_server_core::platform::TEMPLATE_SCHEMA_VERSION; + use trusted_server_core::platform::{ + TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY, TEMPLATE_SCHEMA_VERSION, + }; struct FailingReader { returned_prefix: bool, @@ -552,9 +543,9 @@ mod tests { } #[test] - fn purge_all_clears_stored_templates() { - // The rollback lever. Without this, backing out a bad template means waiting - // for the TTL. + fn purging_the_key_every_template_carries_clears_stored_templates() { + // The rollback lever, which `ts cache purge --all` asks the platform to pull. + // Without it, backing out a bad template means waiting for the TTL. let cache = cache(); let key = key("https://example.com/purge"); let body = b"template".to_vec(); @@ -562,7 +553,8 @@ mod tests { .expect("should store"); run(cache.get(&key)).expect("should be present before purge"); - run(cache.purge_all()).expect("should purge"); + fastly::http::purge::purge_surrogate_key(TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY) + .expect("should purge"); assert!( run(cache.get(&key)).is_err(), @@ -570,6 +562,36 @@ mod tests { ); } + #[test] + fn purging_a_pages_reader_key_clears_that_pages_template() { + // The key `ts cache purge --page` asks the platform to purge. The + // helper gives every key one reader page, so each gets its own here. + let cache = cache(); + let mut purged = key("https://origin.example.com/purge-one"); + purged.request_path = "/purge-one".to_string(); + let mut kept = key("https://origin.example.com/keep-one"); + kept.request_path = "/keep-one".to_string(); + assert_ne!( + purged.reader_url_surrogate_key(), + kept.reader_url_surrogate_key(), + "the two pages should have reader keys of their own" + ); + for stored in [&purged, &kept] { + let body = b"template".to_vec(); + run(cache.put(stored, &metadata_for(&body), body, Duration::from_secs(60))) + .expect("should store"); + } + + fastly::http::purge::purge_surrogate_key(&purged.reader_url_surrogate_key()) + .expect("should purge"); + + assert!( + run(cache.get(&purged)).is_err(), + "should clear the page whose key was purged" + ); + run(cache.get(&kept)).expect("should leave another page's template in place"); + } + #[test] fn a_second_put_on_a_fresh_entry_is_a_no_op() { // Exercises the `must_insert_or_update` early return: a concurrent writer diff --git a/crates/trusted-server-adapter-spin/spin.toml b/crates/trusted-server-adapter-spin/spin.toml index 7055f9563d..d1f3f26593 100644 --- a/crates/trusted-server-adapter-spin/spin.toml +++ b/crates/trusted-server-adapter-spin/spin.toml @@ -19,15 +19,13 @@ version = "0.1.0" # each private signing key. Public signing metadata remains in the KV store. [variables] # These declared variables match the example config's secret key names. Regenerate -# or extend them for deployment-specific keys, including handler key names such as -# `admin_password` or `api_handler_password`. Replace the empty defaults with values +# or extend them for deployment-specific keys. Replace the empty defaults with values # supplied by the deployment's secret provider; never commit values here. v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret = { default = "", secret = true } v_trusted_x5fserver_x5fsecrets_v_trusted_x5fclient_x5fip_x5fshared_x5fsecret = { default = "", secret = true } v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase = { default = "", secret = true } v_trusted_x5fserver_x5fsecrets_v_partner_x5fapi_x5ftoken = { default = "", secret = true } v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken = { default = "", secret = true } -v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword = { default = "", secret = true } [[trigger.http]] route = "/..." @@ -49,7 +47,6 @@ v_trusted_x5fserver_x5fsecrets_v_trusted_x5fclient_x5fip_x5fshared_x5fsecret = " v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase = "{{ v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase }}" v_trusted_x5fserver_x5fsecrets_v_partner_x5fapi_x5ftoken = "{{ v_trusted_x5fserver_x5fsecrets_v_partner_x5fapi_x5ftoken }}" v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken = "{{ v_trusted_x5fserver_x5fsecrets_v_partner_x5fts_x5fpull_x5ftoken }}" -v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword = "{{ v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword }}" [component.trusted-server.build] command = "cargo build --target wasm32-wasip1 --release -p trusted-server-adapter-spin --features spin" diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 6d4a8b06d9..5b9c8e4b66 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -17,15 +17,11 @@ use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan_with, }; use trusted_server_core::cache_policy::EdgeCacheHeader; +use trusted_server_core::closed_paths::closed_path_response; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use trusted_server_core::config_payload::settings_from_config_blob_with; use trusted_server_core::ec::EcContext; -use trusted_server_core::ec::admin::{ - admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, - deny_admin_diagnostic_fallback, handle_admin_eids_lookup, -}; use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; -use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; use trusted_server_core::inspect::config::{CONFIG_JSON_PATH, CONFIG_PAGE_PATH, handle_config}; @@ -56,7 +52,7 @@ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::{default_config_key, default_secret_store_name}; use crate::middleware::{ - AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware, SanitizeRequestMiddleware, + FinalizeResponseMiddleware, NormalizeMiddleware, SanitizeRequestMiddleware, }; use crate::platform::build_runtime_services; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -387,17 +383,7 @@ fn publisher_fallback_methods() -> [Method; 7] { // registry and rate limiter) that the portability adapters do not yet wire. // On Spin these paths fall through to the publisher/integration fallback, // identical to the other non-Fastly adapters. -const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ - Method::GET, - Method::POST, - Method::HEAD, - Method::OPTIONS, - Method::PUT, - Method::PATCH, - Method::DELETE, -]; - -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 22] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 14] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -406,14 +392,6 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 22] { (CONFIG_PAGE_PATH, &[Method::GET]), (CONFIG_JSON_PATH, &[Method::GET]), (DATA_PAGE_PATH, &[Method::GET]), - ("/_ts/admin/keys/rotate", &[Method::POST]), - ("/_ts/admin/keys/deactivate", &[Method::POST]), - ("/_ts/admin/ec", &[Method::GET]), - ("/_ts/admin/ec/{id}", &[Method::GET]), - ("/_ts/admin/eids", &[Method::GET]), - ("/_ts/admin/cache/purge", LEGACY_ADMIN_DENY_METHODS), - ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), - ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), ("/auction", &[Method::POST]), (PAGE_BIDS_PATH, &[Method::GET, Method::OPTIONS]), (PAGE_BIDS_LEGACY_PATH, &[Method::GET, Method::OPTIONS]), @@ -617,38 +595,6 @@ async fn build_ec_context( EcContext::read_from_request_resolving_geo(settings, req, services).await } -fn cache_purge_not_supported() -> Response { - let body = edgezero_core::body::Body::from( - "Template cache purge is not supported on Spin.\n\ - Use the Fastly adapter (via Viceroy or deployed) to purge.\n", - ); - let mut response = Response::new(body); - *response.status_mut() = StatusCode::NOT_IMPLEMENTED; - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - -fn admin_key_management_not_supported() -> Response { - let body = edgezero_core::body::Body::from( - "Admin key management is not supported on Fermyon Spin.\n\ - Use the Fastly adapter (via Viceroy or deployed) to rotate or deactivate keys.\n", - ); - let mut response = Response::new(body); - *response.status_mut() = StatusCode::NOT_IMPLEMENTED; - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - -fn admin_ec_lookup_not_supported() -> Response { - core_admin_ec_lookup_not_supported() -} - // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -668,23 +614,6 @@ pub(crate) fn http_error(report: &Report) -> Response { response } -/// Builds the local `404 Not Found` returned for legacy `/admin/keys/*` -/// aliases on the Spin adapter. -/// -/// These non-`/_ts` aliases are not matched by the `^/_ts/admin` basic-auth -/// handler, so they fail closed locally rather than fall through to the -/// publisher fallback, which would forward the caller's `Authorization` header -/// and key-management payload to the origin. -fn legacy_admin_alias_denied() -> Response { - let mut response = Response::new(edgezero_core::body::Body::from("Not found\n")); - *response.status_mut() = StatusCode::NOT_FOUND; - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - // --------------------------------------------------------------------------- // Startup error fallback // --------------------------------------------------------------------------- @@ -880,30 +809,6 @@ fn build_router(state: &Arc) -> RouterService { } }; - let admin_not_supported_handler = |_ctx: RequestContext| async { - Ok::(admin_key_management_not_supported()) - }; - - let cache_purge_unsupported_handler = - |_ctx: RequestContext| async { Ok::(cache_purge_not_supported()) }; - - let admin_ec_not_supported_handler = |_ctx: RequestContext| async { - Ok::(admin_ec_lookup_not_supported()) - }; - - // Admin EIDs echo: pure request inspection (no KV), so this adapter - // serves the real handler. - let s = Arc::clone(&state); - let admin_eids_handler = move |ctx: RequestContext| { - let s = Arc::clone(&s); - async move { - let req = ctx.into_request(); - let result = PartnerRegistry::from_config(&s.settings.ec.partners) - .and_then(|registry| handle_admin_eids_lookup(®istry, &req)); - Ok::(result.unwrap_or_else(|e| http_error(&e))) - } - }; - // /auction let s = Arc::clone(&state); let auction_handler = move |ctx: RequestContext| { @@ -1044,7 +949,7 @@ fn build_router(state: &Arc) -> RouterService { ) -> Result { let services = state.services_for_request(&ctx); let mut req = ctx.into_request(); - if let Some(response) = deny_admin_diagnostic_fallback(&req) { + if let Some(response) = closed_path_response(&req) { return Ok(response); } if let Err(error) = state.registry.prepare_request(&state.settings, &mut req) { @@ -1137,8 +1042,6 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); dispatch(s, ctx) }; - let legacy_admin_deny = - |_ctx: RequestContext| async { Ok::(legacy_admin_alias_denied()) }; let mut builder = RouterService::builder() // Outermost middleware: strips the configured trusted-client-IP @@ -1147,12 +1050,10 @@ fn build_router(state: &Arc) -> RouterService { // shared-secret authentication header. .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) - .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) // Innermost middleware: normalize every routed request (strip // spoofable forwarded headers, derive the trusted Host/scheme/client-IP // from Spin's synthetic runtime headers) so no handler can opt out of - // the de-spoofing invariant. Runs after auth so the basic-auth gate - // continues to see the original request, matching prior behaviour. + // the de-spoofing invariant. .middleware(NormalizeMiddleware::new()) // Cheap liveness probe, matching the Fastly/Axum adapters. Registered // explicitly so it is not absorbed by the publisher `/{*rest}` fallback. @@ -1166,25 +1067,6 @@ fn build_router(state: &Arc) -> RouterService { .get(CONFIG_PAGE_PATH, config_handler.clone()) .get(CONFIG_JSON_PATH, config_handler) .get(DATA_PAGE_PATH, data_handler) - // Canonical admin key routes. These match `Settings::ADMIN_ENDPOINTS` - // and the production basic-auth handler regex (`^/_ts/admin`), so they - // are auth-gated under a production-shaped config. - // - // The legacy non-`/_ts` aliases (`/admin/keys/*`) are registered below - // to a local 404 for every publisher-fallback method: the production - // handler regex `^/_ts/admin` does not match them, and letting them - // fall through to the publisher fallback would forward admin - // credentials and key-management payloads to the origin. - .post("/_ts/admin/keys/rotate", admin_not_supported_handler) - .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) - // Admin EC lookup routes. Registered explicitly (like the key - // routes above) so they never fall through to the publisher - // fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth - // coverage. The EC identity graph is Fastly KV backed, so this - // adapter has no store to read. - .get("/_ts/admin/ec", admin_ec_not_supported_handler) - .get("/_ts/admin/ec/{id}", admin_ec_not_supported_handler) - .get("/_ts/admin/eids", admin_eids_handler) .post("/auction", auction_handler) .get(PAGE_BIDS_PATH, page_bids_handler.clone()) .route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler) @@ -1205,19 +1087,6 @@ fn build_router(state: &Arc) -> RouterService { .get("/first-party/proxy-rebuild", fp_rebuild_handler) .post("/first-party/proxy-rebuild", fp_rebuild_post_handler); - for method in LEGACY_ADMIN_DENY_METHODS { - builder = builder.route( - "/_ts/admin/cache/purge", - method.clone(), - cache_purge_unsupported_handler, - ); - } - - for method in LEGACY_ADMIN_DENY_METHODS { - builder = builder.route("/admin/keys/rotate", method.clone(), legacy_admin_deny); - builder = builder.route("/admin/keys/deactivate", method.clone(), legacy_admin_deny); - } - // Mirror the Fastly/Axum publisher fallback: every supported method that is // not a named route's primary method falls through to the publisher origin // (e.g. HEAD /, OPTIONS /page preflight, HEAD /first-party/proxy) instead of @@ -1248,11 +1117,6 @@ mod tests { fn multi_provider_settings() -> Settings { let mut settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-password" - [publisher] domain = "publisher.example" cookie_domain = ".publisher.example" @@ -1400,11 +1264,6 @@ mod tests { /// inject, with the `[ec.acme]` block that module's settings live in. /// `acme` is a fictional vendor key. const UNINJECTED_MODULE_TOML: &str = r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 6deb3cdb99..4e5140a654 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -5,7 +5,6 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; @@ -55,9 +54,8 @@ impl Middleware for SanitizeRequestMiddleware { /// Spin does not expose geo headers to the application, so /// `X-Geo-Info-Available: false` is emitted for every response. /// -/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of -/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected -/// ones — carries a consistent set of headers. +/// Registered directly inside [`SanitizeRequestMiddleware`], so that every +/// outgoing response carries a consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -81,44 +79,6 @@ impl Middleware for FinalizeResponseMiddleware { } } -// --------------------------------------------------------------------------- -// AuthMiddleware -// --------------------------------------------------------------------------- - -/// Inner middleware: enforces basic-auth before the handler runs. -/// -/// - `Ok(Some(response))` from [`enforce_basic_auth`] → auth failed; return the -/// challenge response (bubbles through [`FinalizeResponseMiddleware`] for header injection). -/// - `Ok(None)` → no auth required or credentials accepted; continue the chain. -/// - `Err(report)` → internal error; log and convert to a 500 HTTP response. -pub struct AuthMiddleware { - settings: Arc, -} - -impl AuthMiddleware { - /// Creates a new [`AuthMiddleware`] with the given settings. - #[must_use] - pub fn new(settings: Arc) -> Self { - Self { settings } - } -} - -#[async_trait(?Send)] -impl Middleware for AuthMiddleware { - async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { - Ok(Some(response)) => return Ok(response), - Ok(None) => {} - Err(report) => { - log::error!("auth check failed: {:?}", report); - return Ok(crate::app::http_error(&report)); - } - } - - next.run(ctx).await - } -} - // --------------------------------------------------------------------------- // NormalizeMiddleware // --------------------------------------------------------------------------- @@ -132,9 +92,6 @@ impl Middleware for AuthMiddleware { /// structurally rather than by per-handler convention. A future route, or a /// signing handler that begins deriving an issuer/audience from `RequestInfo`, /// cannot silently trust spoofable input by forgetting to opt in. -/// -/// Registered after [`AuthMiddleware`] (innermost) so the basic-auth gate still -/// evaluates the original request, preserving prior behaviour. #[derive(Default)] pub struct NormalizeMiddleware; @@ -225,11 +182,6 @@ mod tests { // test builds its own. let mut s = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index f575149dac..83c36a0319 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -18,17 +18,9 @@ use trusted_server_core::settings::Settings; /// which a test does not have, so `TrustedServerApp::routes()` would turn /// every route into a startup error page (and its route table into the /// fallback-only set). -/// The handler regex is the production-shaped `^/_ts/admin`, matching -/// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical -/// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. fn test_router() -> RouterService { let settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -92,147 +84,17 @@ fn edgezero_manifest_loads_and_resolves_spin_stores() { } // --------------------------------------------------------------------------- -// Middleware regression tests — verify FinalizeResponseMiddleware and -// AuthMiddleware are wired so they cannot be removed silently. +// Middleware regression tests, which verify FinalizeResponseMiddleware is +// wired so it cannot be removed silently. // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_routes_return_501() { - for (path, body) in [ - ("/_ts/admin/keys/rotate", "{}"), - ( - "/_ts/admin/keys/deactivate", - r#"{"kid":"test-key","delete":false}"#, - ), - ] { - let req = request_builder() - .method("POST") - .uri(path) - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from(body)) - .expect("should build request"); - let resp = route(test_router(), req).await; - - assert_eq!( - resp.status().as_u16(), - 501, - "{path} should report that Spin key management is unsupported" - ); - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_ec_routes_return_501() { - // The EC identity graph is Fastly KV backed, so Spin answers the admin - // EC lookup routes locally with 501 instead of letting them fall through - // to the publisher fallback. - let sample_ec_id = format!("{}.abc123", "a".repeat(64)); - for path in [ - "/_ts/admin/ec".to_owned(), - format!("/_ts/admin/ec/{sample_ec_id}"), - ] { - let req = request_builder() - .method("GET") - .uri(&path) - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let resp = route(test_router(), req).await; - - assert_eq!( - resp.status().as_u16(), - 501, - "{path} should report that Spin EC lookup is unsupported" - ); - assert_eq!( - resp.headers() - .get("content-type") - .and_then(|v| v.to_str().ok()), - Some("application/json") - ); - assert_eq!( - resp.headers() - .get("cache-control") - .and_then(|v| v.to_str().ok()), - Some("no-store") - ); - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_ec_route_without_credentials_returns_401() { - let req = request_builder() - .method("GET") - .uri("/_ts/admin/ec") - .body(edgezero_core::body::Body::empty()) - .expect("should build unauthenticated admin EC request"); - let resp = route(test_router(), req).await; - - assert_eq!(resp.status().as_u16(), 401); - assert!( - resp.headers().contains_key("www-authenticate"), - "admin EC 401 should include the Basic authentication challenge" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_eids_route_returns_200() { - // The EIDs echo is pure request inspection (no KV), so this adapter - // serves the real handler. - let req = request_builder() - .method("GET") - .uri("/_ts/admin/eids") - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let resp = route(test_router(), req).await; - - assert_eq!( - resp.status().as_u16(), - 200, - "/_ts/admin/eids should serve the real EIDs echo handler" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn authenticated_admin_diagnostic_fallback_is_denied_locally() { +async fn an_authenticated_request_to_a_closed_path_is_answered_here() { let ec_id = format!("{}.abc123", "a".repeat(64)); - let valid_paths = [ + for path in [ "/_ts/admin/ec".to_owned(), format!("/_ts/admin/ec/{ec_id}"), "/_ts/admin/eids".to_owned(), - ]; - - for path in valid_paths { - for method in ["POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] { - let request = request_builder() - .method(method) - .uri(&path) - .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") - .body(edgezero_core::body::Body::from("sensitive-admin-body")) - .expect("should build authenticated admin request"); - let response = route(test_router(), request).await; - - assert_eq!(response.status().as_u16(), 405); - assert_eq!( - response - .headers() - .get("allow") - .and_then(|v| v.to_str().ok()), - Some("GET") - ); - assert_eq!( - response - .headers() - .get("cache-control") - .and_then(|v| v.to_str().ok()), - Some("no-store") - ); - } - } - - for path in [ "/_ts/admin/ec/".to_owned(), format!("/_ts/admin/ec/{ec_id}/extra"), "/_ts/admin/eids/".to_owned(), @@ -240,14 +102,13 @@ async fn authenticated_admin_diagnostic_fallback_is_denied_locally() { "/_ts/admin/eids.json".to_owned(), "/_ts/admin/ec;foo".to_owned(), format!("/_ts/admin/ec%2F{ec_id}"), - // Percent-encoded separators match the `^/_ts/admin` basic-auth - // handler but not a literal-slash namespace check, so they must be - // reserved before publisher fallback forwards credentials upstream. + // A check for a literal slash would miss a percent-encoded separator, + // so these are closed before the publisher fallback forwards + // credentials upstream. "/_ts/admin%2Fec".to_owned(), "/_ts/admin%2fec".to_owned(), - // Retired non-`/_ts` alias namespace: only the two exact paths are - // routed to a local deny, so descendants and encoded separators must - // be reserved at the shared fallback boundary. + // The alias outside `/_ts`, with its descendants and encoded + // separators. "/admin/keys".to_owned(), "/admin/keys/rotate/extra".to_owned(), "/admin/keys%2Frotate".to_owned(), @@ -323,14 +184,9 @@ async fn finalize_middleware_injects_geo_header() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn auth_middleware_runs_in_chain_for_protected_routes() { - // Verifies that AuthMiddleware is wired into the middleware chain for auction - // requests. Without it, FinalizeResponseMiddleware would still run but auth - // challenges would be skipped silently. - // - // CI settings may not have basic_auth configured, so this test does not - // assert 401 — it asserts that both middleware layers ran (X-Geo-Info-Available - // present) and that the route is actually reached (status != 404). +async fn the_middleware_chain_runs_for_a_routed_request() { + // Asserts that the finalizing middleware ran (X-Geo-Info-Available present) + // and that the route is actually reached (status != 404). let router = test_router(); let req = request_builder() @@ -344,7 +200,7 @@ async fn auth_middleware_runs_in_chain_for_protected_routes() { assert!( resp.headers().contains_key("x-geo-info-available"), - "middleware chain must inject X-Geo-Info-Available even on auth-rejected responses" + "middleware chain must inject X-Geo-Info-Available" ); assert_ne!( resp.status().as_u16(), @@ -449,46 +305,19 @@ async fn verify_signature_put_falls_through_to_publisher_fallback() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_rotate_key_is_routed() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - let resp = route(router, req).await; - assert_ne!( - resp.status().as_u16(), - 404, - "/admin/keys/rotate must be routed" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_deactivate_key_is_routed() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/deactivate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - let resp = route(router, req).await; - assert_ne!( - resp.status().as_u16(), - 404, - "/admin/keys/deactivate must be routed" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { - // The production basic-auth regex `^/_ts/admin` does not match - // `/admin/keys/*`, so those aliases are not auth-gated. Any - // publisher-fallback method carrying an `Authorization` header must be - // denied locally with 404, never proxied to the publisher origin. - for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { +async fn key_administration_paths_are_answered_here_and_never_proxied() { + // A request to a key administration path carries an operator's + // credentials and a key payload. Every publisher-fallback method must be + // answered here with 404 and never proxied to the publisher origin, which + // would be handed both. A publisher-fallback proxy without a backend would + // surface as a 5xx, so a 404 proves the request was answered here. + for path in [ + "/_ts/admin/keys/rotate", + "/_ts/admin/keys/deactivate", + "/_ts/admin/cache/purge", + "/admin/keys/rotate", + "/admin/keys/deactivate", + ] { for method in ["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] { let router = test_router(); let req = request_builder() @@ -497,18 +326,18 @@ async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") .header("content-type", "application/json") .body(edgezero_core::body::Body::from("{\"key_id\":\"leak-me\"}")) - .expect("should build authorized legacy-alias request"); + .expect("should build authorized key administration request"); let resp = route(router, req).await; assert_eq!( resp.status().as_u16(), 404, - "legacy {method} {path} with Authorization must be denied locally (404), not proxied to publisher" + "{method} {path} with Authorization must be answered here (404), not proxied to publisher" ); assert!( !resp.headers().contains_key("www-authenticate"), - "legacy {method} {path} must not issue an admin auth challenge" + "{method} {path} must not issue an admin auth challenge" ); } } @@ -851,11 +680,6 @@ mod module_endpoints { fn settings_with(extra: &str) -> Settings { Settings::from_toml(&format!( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -1163,77 +987,9 @@ async fn first_party_proxy_round_trip_through_spin_router() { } // --------------------------------------------------------------------------- -// Basic-auth parity tests +// Closed paths and the inspection endpoints // --------------------------------------------------------------------------- -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_route_without_credentials_returns_401() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - let resp = route(router, req).await; - assert_eq!( - resp.status().as_u16(), - 401, - "admin route must return 401 without credentials" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_route_without_credentials_includes_www_authenticate_header() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - let resp = route(router, req).await; - assert_eq!( - resp.status().as_u16(), - 401, - "should be 401 before checking header" - ); - assert!( - resp.headers().contains_key("www-authenticate"), - "401 response must include WWW-Authenticate header" - ); - let www_auth = resp - .headers() - .get("www-authenticate") - .expect("should have www-authenticate header") - .to_str() - .expect("should be valid UTF-8"); - assert!( - www_auth.starts_with("Basic realm="), - "WWW-Authenticate must be Basic scheme, got: {www_auth}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_route_with_wrong_credentials_returns_401() { - use base64::Engine as _; - let creds = base64::engine::general_purpose::STANDARD.encode("admin:wrong-password"); - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .header("authorization", format!("Basic {creds}")) - .body(edgezero_core::body::Body::from("{}")) - .expect("should build request"); - let resp = route(router, req).await; - assert_eq!( - resp.status().as_u16(), - 401, - "admin route must reject wrong credentials with 401" - ); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn permissions_endpoint_answers_anyone_as_data_and_as_a_page() { for (path, content_type) in [ @@ -1293,8 +1049,8 @@ async fn config_endpoint_answers_anyone_and_masks_secrets() { "{path} should mask what it does not show" ); assert!( - !text.contains("admin-pass"), - "{path} should not carry the admin password" + !text.contains("route-test-proxy-secret"), + "{path} should not carry the proxy secret" ); } } @@ -1331,77 +1087,6 @@ async fn data_endpoint_answers_a_browser_opening_the_page_and_nothing_else() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn discovery_endpoint_does_not_require_auth() { - let router = test_router(); - let req = request_builder() - .method("GET") - .uri("/.well-known/trusted-server.json") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let resp = route(router, req).await; - assert_ne!( - resp.status().as_u16(), - 401, - "/.well-known/trusted-server.json must not require auth" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn auction_endpoint_does_not_require_auth() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/auction") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from(r#"{"adUnits":[]}"#)) - .expect("should build request"); - let resp = route(router, req).await; - assert_ne!( - resp.status().as_u16(), - 401, - "/auction must not apply admin basic-auth gate" - ); -} - -// --------------------------------------------------------------------------- -// Admin key route full path coverage -// --------------------------------------------------------------------------- - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_rotate_key_auth_fail_returns_401() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/rotate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from(r#"{"keyId":"test-key"}"#)) - .expect("should build request"); - let resp = route(router, req).await; - assert_eq!( - resp.status().as_u16(), - 401, - "admin/keys/rotate without credentials must return 401" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_deactivate_key_auth_fail_returns_401() { - let router = test_router(); - let req = request_builder() - .method("POST") - .uri("/_ts/admin/keys/deactivate") - .header("content-type", "application/json") - .body(edgezero_core::body::Body::from(r#"{"keyId":"test-key"}"#)) - .expect("should build request"); - let resp = route(router, req).await; - assert_eq!( - resp.status().as_u16(), - 401, - "admin/keys/deactivate without credentials must return 401" - ); -} - // --------------------------------------------------------------------------- // Edge Cookie module availability // --------------------------------------------------------------------------- @@ -1410,11 +1095,6 @@ async fn admin_deactivate_key_auth_fail_returns_401() { /// inject, with the `[ec.acme]` block that module's settings live in. /// `acme` is a fictional vendor key. const UNINJECTED_MODULE_TOML: &str = r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index 93762a011a..bb81eef875 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -2882,10 +2882,6 @@ mod tests { trusted_server_core::settings::Settings::from_toml( &draft .toml - .replace( - "password = \"handler_password\"", - "password = \"test-admin-password-32-bytes-minimum\"", - ) .replace( "passphrase = \"ec_passphrase\"", "passphrase = \"test-ec-passphrase-32-bytes-minimum\"", @@ -4029,10 +4025,6 @@ mod tests { /// values, so both source validation and runtime loading can be exercised. fn loadable_config() -> String { EXAMPLE_CONFIG - .replace( - "password = \"handler_password\"", - "password = \"test-admin-password-32-bytes-minimum\"", - ) .replace( "passphrase = \"ec_passphrase\"", "passphrase = \"test-ec-passphrase-32-bytes-minimum\"", @@ -4475,10 +4467,6 @@ mod tests { fs::write(&manifest_path, "[app]\nname = \"trusted-server\"\n") .expect("should write manifest"); let config = EXAMPLE_CONFIG - .replace( - "password = \"handler_password\"", - "password = \"test-admin-password-32-bytes-minimum\"", - ) .replace( "passphrase = \"ec_passphrase\"", "passphrase = \"test-ec-passphrase-32-bytes-minimum\"", @@ -4552,7 +4540,7 @@ mod tests { assert!(output.starts_with("--- configured creative opportunities\n")); assert!(output.contains("+++ generated creative opportunities\n")); assert!( - !output.contains("test-admin-password-32-bytes-minimum"), + !output.contains("test-proxy-secret-32-bytes-minimum"), "dry run must not expose unrelated secrets" ); assert!( diff --git a/crates/trusted-server-cli/src/commands/cache/mod.rs b/crates/trusted-server-cli/src/commands/cache/mod.rs index 889852e10e..79bdb2b8ff 100644 --- a/crates/trusted-server-cli/src/commands/cache/mod.rs +++ b/crates/trusted-server-cli/src/commands/cache/mod.rs @@ -1,63 +1,54 @@ //! `ts cache` — operator control over shared template and origin caches. -pub mod purge; +pub(crate) mod purge; use clap::Subcommand; use crate::error::CliResult; +use crate::fastly_cli::FastlyProcess; /// Subcommands under `ts cache`. #[derive(Debug, Subcommand)] -pub enum CacheCommand { - /// Purge cached templates and tagged origin responses through a deployed service's admin endpoint. +pub(crate) enum CacheCommand { + /// Purge cached templates and tagged origin responses through the hosting platform's purge API. Purge(PurgeArgs), } /// Arguments for `ts cache purge`. /// -/// # Why this calls the service rather than the Fastly purge API +/// The platform is asked, so the credential that can empty the cache stays off the +/// publisher's domain. /// -/// The recorded Fastly API token is config- and secret-store write only, with no purge -/// permission (`adapter-fastly/src/management_api.rs`). The service's own admin endpoint -/// purges from inside the running service, where the platform SDK needs no API token at -/// all, so this path works with the credentials an operator already has. -/// -/// It also leaves one implementation of the purge. The surrogate key is derived -/// server-side from the URL, so the CLI cannot drift out of agreement with the cache it is -/// purging — a class of bug that a second client-side derivation would reintroduce. +/// The surrogate key is derived here by the function the service tags its cached objects +/// with, so the command cannot drift out of agreement with the cache it is purging. #[derive(Debug, clap::Args)] -pub struct PurgeArgs { - /// HTTPS base URL of the service; HTTP is permitted only for loopback development. - #[arg(long)] - pub service: String, +pub(crate) struct PurgeArgs { + /// The Fastly service whose cache is purged. + #[arg(long, value_name = "ID")] + pub(crate) service_id: String, /// Purge every cached template and tagged origin response. #[arg(long, conflicts_with = "page")] - pub all: bool, + pub(crate) all: bool, /// Purge one reader-facing page URL, including its exact scheme, host, and port. #[arg(long, conflicts_with = "all")] - pub page: Option, - - /// Admin username for the service's `^/_ts/admin` basic auth. - #[arg(long, default_value = "admin")] - pub username: String, + pub(crate) page: Option, } -/// Environment variable carrying the admin password. -/// -/// Read from the environment and never accepted as a flag: an argument is visible to every -/// other process on the host through `ps`, and lands in shell history. -pub const ADMIN_PASSWORD_ENVIRONMENT_VARIABLE: &str = "TRUSTED_SERVER_ADMIN_PASSWORD"; - -/// Run a `ts cache` subcommand. +/// Run a `ts cache` subcommand with the token from [`crate::fastly_cli::TOKEN_VARIABLE`]. /// /// # Errors /// -/// Returns an error when neither scope is given, when the admin password is absent from -/// the environment, when the service cannot be reached, or when it refuses the purge. -pub fn run(command: CacheCommand, out: &mut impl std::io::Write) -> CliResult<()> { +/// Returns an error when neither scope is given, when the token is absent from the +/// environment, or when the platform refuses the purge. +pub(crate) fn run(command: &CacheCommand, out: &mut impl std::io::Write) -> CliResult<()> { match command { - CacheCommand::Purge(args) => purge::run_purge(&args, out), + CacheCommand::Purge(args) => { + // Checked before the token is asked for, so a mistyped command is corrected + // without one. + purge::purge_target(args)?; + purge::run_purge(args, &FastlyProcess::from_environment()?, out) + } } } diff --git a/crates/trusted-server-cli/src/commands/cache/purge.rs b/crates/trusted-server-cli/src/commands/cache/purge.rs index dcfa38880b..ec41dbf2a9 100644 --- a/crates/trusted-server-cli/src/commands/cache/purge.rs +++ b/crates/trusted-server-cli/src/commands/cache/purge.rs @@ -1,268 +1,279 @@ -//! Calling a deployed service's cache-purge endpoint. +//! Asking the hosting platform to purge a surrogate key. -use std::time::Duration; +use trusted_server_core::platform::{ + TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY, reader_url_surrogate_key, +}; -use serde::Deserialize; - -use crate::commands::cache::{ADMIN_PASSWORD_ENVIRONMENT_VARIABLE, PurgeArgs}; +use crate::commands::cache::PurgeArgs; use crate::error::{CliResult, cli_error}; +use crate::fastly_cli::{FastlyRunner, args}; -const PURGE_PATH: &str = "/_ts/admin/cache/purge"; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// What one purge names: the scope it is reported under and the surrogate key purged. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct PurgeTarget { + scope: &'static str, + surrogate_key: String, +} -/// The purge request body, as the endpoint's parser expects it. +/// Works out what `purge` asks for. +/// +/// # Errors /// -/// `scope: "all"` must carry no `url` field: the endpoint refuses that combination, on the -/// grounds that an operator who meant one page and mistyped the scope should not be handed -/// a silent full flush. -fn request_body(args: &PurgeArgs) -> CliResult { - match (args.all, args.page.as_deref()) { - (true, _) => Ok(r#"{"scope":"all"}"#.to_owned()), +/// Returns a message when neither scope is given, when `--page` is not an absolute +/// http(s) URL, or when the service id could be read as anything but an id. +pub(crate) fn purge_target(purge: &PurgeArgs) -> CliResult { + // A Fastly service id holds letters and digits only. + if purge.service_id.is_empty() || !purge.service_id.bytes().all(|b| b.is_ascii_alphanumeric()) { + return cli_error("--service-id must be a Fastly service id, of letters and digits only"); + } + match (purge.all, purge.page.as_deref()) { + (true, _) => Ok(PurgeTarget { + scope: "all", + surrogate_key: TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY.to_owned(), + }), + // A page that is not an absolute URL names a key nothing was stored with. (false, Some(page)) => { - if !reqwest::Url::parse(page).is_ok_and(|parsed| { + if !url::Url::parse(page).is_ok_and(|parsed| { matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some() }) { return cli_error("--page must be an absolute http(s) URL with a host"); } - Ok(serde_json::json!({ "scope": "url", "url": page }).to_string()) + Ok(PurgeTarget { + scope: "url", + surrogate_key: reader_url_surrogate_key(page), + }) } + // A bare `ts cache purge` purges nothing. (false, None) => cli_error("specify --all or --page "), } } -/// Join the service base URL and the purge path without doubling or dropping a slash. -fn purge_endpoint(service: &str) -> CliResult { - let mut url = - reqwest::Url::parse(service).map_err(|_| "--service must be an absolute HTTPS URL")?; - crate::url_guard::require_credential_safe_transport(&url, "--service")?; - if url.host().is_none() - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - || !url.path().trim_matches('/').is_empty() - { - return cli_error( - "--service must be a base HTTPS URL without credentials, a path, query, or fragment", - ); - } - url.set_path(PURGE_PATH); - Ok(url.to_string()) -} - -/// The acknowledgment returned by the purge endpoint. -#[derive(Deserialize)] -struct PurgeAcknowledgment { - purged: bool, - scope: String, - surrogate_key: Option, -} - -/// Execute `ts cache purge`. +/// Execute `ts cache purge` through `runner`. /// /// # Errors /// -/// Returns an error when no scope is given, the admin password is missing from the -/// environment, the service cannot be reached, or the service answers with a non-success -/// status, redirects, or does not acknowledge the requested purge scope. -pub fn run_purge(args: &PurgeArgs, out: &mut impl std::io::Write) -> CliResult<()> { - let body = request_body(args)?; - let endpoint = purge_endpoint(&args.service)?; - - let password = std::env::var(ADMIN_PASSWORD_ENVIRONMENT_VARIABLE).map_err(|_| { - format!( - "set {ADMIN_PASSWORD_ENVIRONMENT_VARIABLE} to the service's admin password \ - (it is read from the environment, never a flag, so it does not reach `ps` \ - output or shell history)" +/// Returns an error when `purge` names nothing to purge, when the platform refuses the +/// purge, or when the result cannot be written. +pub(crate) fn run_purge( + purge: &PurgeArgs, + runner: &dyn FastlyRunner, + out: &mut impl std::io::Write, +) -> CliResult<()> { + let target = purge_target(purge)?; + + // By surrogate key for both scopes, never `fastly service purge --all`. + runner + .run( + &args(&[ + "service", + "purge", + &format!("--key={}", target.surrogate_key), + &format!("--service-id={}", purge.service_id), + "--non-interactive", + ]), + None, ) - })?; - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|error| format!("failed to start the HTTP runtime: {error}"))?; - - // Without this the first HTTPS request panics with "No provider set". - crate::tls::install_crypto_provider(); - - let (status, response_body) = runtime.block_on(async { - let client = reqwest::Client::builder() - .timeout(REQUEST_TIMEOUT) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| format!("failed to build the HTTP client: {error}"))?; - let response = client - .post(&endpoint) - .basic_auth(&args.username, Some(&password)) - .header("content-type", "application/json") - .body(body) - .send() - .await - .map_err(|error| format!("could not reach {endpoint}: {error}"))?; - let status = response.status(); - let text = response - .text() - .await - .map_err(|error| format!("could not read the purge acknowledgment: {error}"))?; - Ok::<_, String>((status, text)) - })?; - - if status.is_success() { - let acknowledgment: PurgeAcknowledgment = serde_json::from_str(&response_body) - .map_err(|error| format!("invalid purge acknowledgment: {error}"))?; - let expected_scope = if args.all { "all" } else { "url" }; - if !acknowledgment.purged || acknowledgment.scope != expected_scope { - return cli_error(format!( - "service did not acknowledge a successful {expected_scope} purge" - )); - } - if !args.all - && acknowledgment - .surrogate_key - .as_deref() - .is_none_or(|key| key.trim().is_empty()) - { - return cli_error("URL purge acknowledgment is missing its surrogate key"); - } - writeln!(out, "{response_body}") - .map_err(|error| format!("failed to write the purge result: {error}"))?; - return Ok(()); - } + .map_err(|message| format!("purge failed: {message}"))?; - // Named individually, because each one sends the operator somewhere different and a - // purge is usually run mid-incident. - let hint = match status.as_u16() { - 401 => " — check the admin username and $TRUSTED_SERVER_ADMIN_PASSWORD", - 404 => " — this service may predate the purge endpoint", - 501 => " — the template cache is Fastly-backed; this adapter cannot purge", - _ => "", - }; - cli_error(format!( - "purge failed: {status}{hint}\n{}", - response_body.trim() - )) + // A success acknowledges invalidation of the key, not that an object existed. + let report = serde_json::json!({ + "purged": true, + "scope": target.scope, + "surrogate_key": target.surrogate_key, + "service_id": purge.service_id, + }); + writeln!(out, "{report}").map_err(|error| format!("failed to write the purge result: {error}")) } #[cfg(test)] mod tests { + use std::sync::Mutex; + use super::*; - fn args(all: bool, page: Option<&str>) -> PurgeArgs { + const SERVICE: &str = "ExampleServiceId0123456"; + + /// Plays the `fastly` CLI, recording every invocation and failing when told to. + #[derive(Debug, Default)] + struct FakeFastly { + calls: Mutex>>, + failure: Option<&'static str>, + } + + impl FakeFastly { + fn calls(&self) -> Vec> { + self.calls.lock().expect("should lock").clone() + } + } + + impl FastlyRunner for FakeFastly { + fn run(&self, args: &[String], stdin: Option<&str>) -> Result { + assert!(stdin.is_none(), "a purge sends nothing on standard input"); + self.calls.lock().expect("should lock").push(args.to_vec()); + self.failure + .map_or_else(|| Ok(String::new()), |message| Err(message.to_owned())) + } + } + + fn purge_args(all: bool, page: Option<&str>) -> PurgeArgs { PurgeArgs { - service: "https://edge.example.com".to_owned(), + service_id: SERVICE.to_owned(), all, page: page.map(str::to_owned), - username: "admin".to_owned(), } } + /// The value joined to `name` with `=`. + fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter() + .find_map(|arg| arg.strip_prefix(name)?.strip_prefix('=')) + } + + fn purge(fake: &FakeFastly, purge: &PurgeArgs) -> CliResult { + let mut out = Vec::new(); + run_purge(purge, fake, &mut out)?; + Ok(serde_json::from_slice(&out).expect("should report JSON")) + } + #[test] - fn invalid_page_urls_are_rejected_locally() { - for page in [ - "/article", - "example.com/article", - "ftp://example.com/article", - "", - ] { - assert!( - request_body(&args(false, Some(page))).is_err(), - "should reject {page:?}" - ); - } + fn purge_all_names_the_key_every_cached_object_is_tagged_with() { + let fake = FakeFastly::default(); + + let report = purge(&fake, &purge_args(true, None)).expect("should purge"); + + let calls = fake.calls(); + assert_eq!(calls.len(), 1, "one purge is one call: {calls:?}"); + assert_eq!(calls[0][..2], ["service", "purge"]); + assert_eq!(flag(&calls[0], "--key"), Some("ts-template")); + assert_eq!(flag(&calls[0], "--service-id"), Some(SERVICE)); + assert!( + !calls[0].iter().any(|arg| arg == "--all"), + "a purge of everything the service cached would reach untagged objects: {calls:?}" + ); assert!( - request_body(&args(false, Some("http://example.com/article"))).is_ok(), - "should allow HTTP reader URLs" + calls[0].iter().any(|arg| arg == "--non-interactive"), + "fastly must never wait on a prompt: {calls:?}" ); + assert_eq!(report["purged"], true); + assert_eq!(report["scope"], "all"); + assert_eq!(report["surrogate_key"], "ts-template"); } #[test] - fn purge_all_sends_no_url_field() { - // The endpoint refuses scope "all" carrying a url, so emitting one would make - // every --all run fail. + fn purge_page_names_the_key_the_service_tags_that_page_with() { + let fake = FakeFastly::default(); + let page = "https://example.com/article?b=2&a=1"; + + let report = purge(&fake, &purge_args(false, Some(page))).expect("should purge"); + + let expected = reader_url_surrogate_key(page); assert_eq!( - request_body(&args(true, None)).expect("should build"), - r#"{"scope":"all"}"# + flag(&fake.calls()[0], "--key"), + Some(expected.as_str()), + "should purge the key the service derives for the page" ); + assert_eq!(report["scope"], "url"); + assert_eq!(report["surrogate_key"], expected.as_str()); } #[test] - fn purge_page_sends_the_url_verbatim() { - // Canonicalization is the endpoint's job. Normalizing here too would give the two - // sides separate rules to drift apart. - let body = request_body(&args(false, Some("https://example.com/a?b=2&a=1"))) - .expect("should build"); + fn two_spellings_of_one_page_purge_one_key() { + let key_of = |page: &str| { + purge_target(&purge_args(false, Some(page))) + .expect("should name a key") + .surrogate_key + }; + assert_eq!( - body, - r#"{"scope":"url","url":"https://example.com/a?b=2&a=1"}"# + key_of("https://EXAMPLE.com:443/article/?b=2&a=1"), + key_of("https://example.com/article?a=1&b=2"), + "should canonicalize as the service does when it tags the page" + ); + assert_ne!( + key_of("http://example.com/article"), + key_of("https://example.com/article"), + "the scheme is part of the page a reader addresses" ); } #[test] - fn a_url_with_json_punctuation_is_escaped_rather_than_breaking_the_body() { - let body = request_body(&args(false, Some(r#"https://example.com/"; drop"#))) - .expect("should build"); - let parsed: serde_json::Value = serde_json::from_str(&body).expect("should stay valid"); - assert_eq!(parsed["url"], r#"https://example.com/"; drop"#); - } + fn invalid_page_urls_are_rejected_before_fastly_is_run() { + for page in [ + "/article", + "example.com/article", + "ftp://example.com/article", + "", + ] { + let fake = FakeFastly::default(); - #[test] - fn neither_scope_is_an_error_rather_than_a_default() { - // Defaulting to --all would make a bare `ts cache purge` flush production. - assert!(request_body(&args(false, None)).is_err()); - } + let error = + purge(&fake, &purge_args(false, Some(page))).expect_err("should refuse the page"); - #[test] - fn the_endpoint_url_survives_a_trailing_slash() { - for service in [ - "https://edge.example.com", - "https://edge.example.com/", - "https://edge.example.com///", - ] { - assert_eq!( - purge_endpoint(service).expect("should accept a secure base URL"), - "https://edge.example.com/_ts/admin/cache/purge", - "{service} must resolve to one well-formed endpoint" + assert!( + error.contains("--page"), + "should name the flag for {page:?}: {error}" ); + assert!(fake.calls().is_empty(), "nothing should be asked of fastly"); } } #[test] - fn only_loopback_services_may_use_plaintext_http() { - for service in [ - "http://127.0.0.1:8080", - "http://[::1]:8080", - "http://localhost:8080", - ] { - assert!( - purge_endpoint(service).is_ok(), - "should permit loopback development: {service}" - ); - } - for service in [ - "http://192.0.2.1", - "http://[2001:db8::1]", - "http://localhost.example.com", - ] { + fn every_value_is_joined_to_its_flag() { + let fake = FakeFastly::default(); + + purge( + &fake, + &purge_args(false, Some("https://example.com/article")), + ) + .expect("should purge"); + + for arg in &fake.calls()[0][2..] { assert!( - purge_endpoint(service).is_err(), - "should refuse remote plaintext transport: {service}" + arg.starts_with("--"), + "a value standing alone can be read as a flag: {:?}", + fake.calls() ); } } #[test] - fn service_urls_cannot_redirect_the_purge_path_or_embed_credentials() { - for service in [ - "not a URL", - "https://example.com/path", - "https://example.com?query=1", - "https://example.com#fragment", - "https://user:example-password@example.com", - ] { - assert!( - purge_endpoint(service).is_err(), - "should require an unambiguous service base URL" - ); + fn neither_scope_is_an_error_rather_than_a_default() { + let fake = FakeFastly::default(); + + let error = purge(&fake, &purge_args(false, None)).expect_err("should refuse"); + + assert!(error.contains("--all"), "should name the choices: {error}"); + assert!(fake.calls().is_empty(), "nothing should be asked of fastly"); + } + + #[test] + fn a_service_id_that_fastly_could_read_as_a_flag_is_refused() { + for service_id in ["", "--all", "-s", "abc def", "abc/def"] { + let fake = FakeFastly::default(); + let refused = PurgeArgs { + service_id: service_id.to_owned(), + all: true, + page: None, + }; + + let error = purge(&fake, &refused).expect_err("should refuse the service id"); + + assert!(error.contains("--service-id"), "{error}"); + assert!(fake.calls().is_empty(), "nothing should be asked of fastly"); } } + + #[test] + fn a_refused_purge_reports_what_the_platform_said_and_prints_no_result() { + let fake = FakeFastly { + failure: Some("`fastly service purge` failed: 403 Forbidden"), + ..FakeFastly::default() + }; + let mut out = Vec::new(); + + let error = run_purge(&purge_args(true, None), &fake, &mut out).expect_err("should fail"); + + assert!(error.contains("403 Forbidden"), "{error}"); + assert!(out.is_empty(), "should not report a successful purge"); + } } diff --git a/crates/trusted-server-cli/src/commands/config/keyed_push.rs b/crates/trusted-server-cli/src/commands/config/keyed_push.rs index 198f6cded5..be2058b22c 100644 --- a/crates/trusted-server-cli/src/commands/config/keyed_push.rs +++ b/crates/trusted-server-cli/src/commands/config/keyed_push.rs @@ -6,23 +6,30 @@ //! validates the blob as `ts config validate` does, wraps it in `EdgeZero`'s //! envelope and splits a long one into `EdgeZero`'s chunk format, so the //! runtime reads it as it reads any pushed blob. `EdgeZero` keeps that format -//! private to its crate, so the constants below copy it. The writer writes -//! only the local server's store, in the `fastly.toml` the manifest names. +//! private to its crate, so the constants below copy it. With `--local` the +//! writer writes the local server's store, in the `fastly.toml` the manifest +//! names. Otherwise it writes the live store that +//! `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` selects, through +//! the `fastly` CLI, as `ts keys` writes signing keys. //! //! A host's blob names its permission policy file and pins the file's //! SHA-256, and carries no copy of the file. Each file `--permissions` //! supplies is kept once, in a shared entry under its digest, written before -//! the blob, and an entry that already holds the file is not written again. +//! the blob, and an entry that already holds the file is not written again. A +//! live push that writes a policy entry waits +//! [`POLICY_PROPAGATION_MARGIN_SECONDS`] before it writes the blob. use std::collections::BTreeMap; use std::fmt::Write as _; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use std::time::Duration; use edgezero_cli::args::{ConfigPushArgs, ConfigValidateArgs}; use edgezero_core::app_config::{self, AppConfigLoadOptions}; use edgezero_core::blob_envelope::BlobEnvelope; +use edgezero_core::env_config::EnvConfig; use edgezero_core::manifest::{Manifest, ManifestLoader}; use error_stack::Report; use serde::Serialize; @@ -38,6 +45,7 @@ use trusted_server_core::platform::{ use trusted_server_core::settings_data::resolve_fastly_chunk_pointer; use super::policy::{self, PolicyText, PushArgs}; +use crate::fastly_cli::{self, FastlyProcess, FastlyRunner}; /// The adapter whose keys this writer writes. const ADAPTER: &str = "fastly"; @@ -51,6 +59,11 @@ const KEY_LIMIT: usize = 255; const CHUNK_KEY_INFIX: &str = ".__edgezero_chunks."; /// Marks an entry as a pointer to the chunks of one blob. const POINTER_KIND: &str = "fastly_config_chunks"; +/// How long a live push waits, once it has written a policy entry, before it +/// writes the blob that names it. Fastly documents no time for a change to +/// reach every location, and the store is consistent key by key, so this is +/// a margin and not a guarantee. +const POLICY_PROPAGATION_MARGIN_SECONDS: u64 = 60; /// The pointer to the chunks of one blob, with `EdgeZero`'s fields in /// `EdgeZero`'s order. @@ -91,28 +104,23 @@ pub(crate) fn own_key(args: &ConfigPushArgs) -> Option<&str> { .filter(|key| *key != DEFAULT_CONFIG_STORE_ID) } -/// Pushes the blob `args` names under `key`, into the local server's store, -/// with each policy file `--permissions` supplies in its shared entry. +/// Pushes the blob `args` names under `key`, into the local server's store +/// with `--local` and into the live store otherwise, with each policy file +/// `--permissions` supplies in its shared entry. /// /// The settings are checked as the plain push checks them, and the policy /// file they name must be among the files supplied. Every key is checked /// before anything is written. Each policy entry is written before the blob, /// and a long value's chunks before the pointer to them. `--dry-run` lists -/// the keys and writes nothing. +/// the keys, writes nothing and asks the `fastly` CLI nothing. /// /// # Errors /// -/// Returns a message when the push is not `--local`, the settings or the -/// policy files are refused, the blob fails validation, a key is refused, or -/// the local manifest cannot be read or written. +/// Returns a message when the settings or the policy files are refused, the +/// blob fails validation, a key is refused, the local manifest cannot be read +/// or written, or the live store cannot be found or written. pub(crate) fn run(args: &PushArgs, key: &str, out: &mut dyn Write) -> Result<(), String> { let push = &args.edgezero; - if !push.local { - return Err(format!( - "`--key {key}` can be pushed only with `--local` on Fastly. Write a live \ - store's entries with `fastly config-store-entry update`." - )); - } let loader = ManifestLoader::from_path(&push.manifest) .map_err(|error| format!("failed to load {}: {error}", push.manifest.display()))?; let manifest = loader.manifest(); @@ -134,22 +142,47 @@ pub(crate) fn run(args: &PushArgs, key: &str, out: &mut dyn Write) -> Result<(), .iter() .map(|file| policy_entries(&file.name, &file.text)) .collect::, _>>()?; - let fastly_toml = local_manifest(&push.manifest, manifest)?; - let stored = local_entries(&fastly_toml)?; - let (kept, missing): (Vec<_>, Vec<_>) = policies - .iter() - .partition(|policy| holds_policy(&stored, &policy.key, &policy.sha256)); - let written: Vec<(String, String)> = missing - .iter() - .flat_map(|policy| policy.entries.iter().cloned()) - .chain(blob) - .collect(); + let (kept, written, destination) = if push.local { + let fastly_toml = local_manifest(&push.manifest, manifest)?; + let stored = local_entries(&fastly_toml)?; + let (kept, missing): (Vec<_>, Vec<_>) = policies + .iter() + .partition(|policy| holds_policy(&StoredEntries(&stored), &policy.key, &policy.sha256)); + let written: Vec<(String, String)> = missing + .iter() + .flat_map(|policy| policy.entries.iter().cloned()) + .chain(blob) + .collect(); + if !push.dry_run { + write_local(&fastly_toml, &written)?; + } + let destination = format!( + "[local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}.contents] in {}", + fastly_toml.display() + ); + (kept, written, destination) + } else { + let store = remote_store_name(&EnvConfig::from_env())?; + let destination = format!("the Fastly config store `{store}`"); + if push.dry_run { + // A dry run asks the `fastly` CLI nothing, so it lists what a push + // to a store holding none of the policy entries would write. + let written: Vec<(String, String)> = policies + .iter() + .flat_map(|policy| policy.entries.iter().cloned()) + .chain(blob) + .collect(); + (Vec::new(), written, destination) + } else { + let runner = FastlyProcess::from_environment()?; + let (kept, written) = + push_remote(&runner, &store, &policies, blob, &std::thread::sleep, out)?; + (kept, written, destination) + } + }; let verb = if push.dry_run { "would write" } else { "wrote" }; - if !push.dry_run { - write_local(&fastly_toml, &written)?; - } let output_failed = |error: std::io::Error| format!("failed to write command output: {error}"); for policy in kept { writeln!( @@ -162,10 +195,8 @@ pub(crate) fn run(args: &PushArgs, key: &str, out: &mut dyn Write) -> Result<(), for (entry_key, value) in &written { writeln!( out, - "{verb} `{entry_key}` ({} bytes) to \ - [local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}.contents] in {}", - value.len(), - fastly_toml.display() + "{verb} `{entry_key}` ({} bytes) to {destination}", + value.len() ) .map_err(output_failed)?; } @@ -248,12 +279,12 @@ fn policy_entries(name: &str, text: &str) -> Result { /// Whether `stored` holds the policy file whose SHA-256 is `sha256` under /// `key`, read back as the runtime reads it, chunks included. An entry that /// cannot be read back, or holds another file, does not. -fn holds_policy(stored: &BTreeMap, key: &str, sha256: &str) -> bool { - let store = StoredEntries(stored); +fn holds_policy(stored: &dyn PlatformConfigStore, key: &str, sha256: &str) -> bool { let store_name = StoreName::from(DEFAULT_CONFIG_STORE_ID); stored - .get(key) - .and_then(|value| resolve_fastly_chunk_pointer(&store, &store_name, value).ok()) + .get(&store_name, key) + .ok() + .and_then(|value| resolve_fastly_chunk_pointer(stored, &store_name, &value).ok()) .and_then(|value| policy_entry_text(&value).ok()) .is_some_and(|text| policy_sha256(&text) == sha256) } @@ -386,6 +417,185 @@ fn sha256_hex(text: &str) -> String { hex } +/// The live store a push without `--local` writes, being the one +/// `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` selects, or the +/// store named by the logical store ID when that is unset, as a deploy links +/// it. +/// +/// # Errors +/// +/// Returns `EdgeZero`'s message when the selector is set but blank or holds +/// a control character. +fn remote_store_name(env: &EnvConfig) -> Result { + env.store_name_checked("config", DEFAULT_CONFIG_STORE_ID) +} + +/// The policies a live store already held, and the entries a live push +/// wrote to it, in order. +type LivePush<'p> = (Vec<&'p PolicyEntries>, Vec<(String, String)>); + +/// Pushes to the live config store named `store`, through the `fastly` CLI, +/// each policy entry the store does not hold yet, then `blob`. +/// +/// `fastly` reads and writes an entry by store id, so the id is found by the +/// store's name first. A policy entry is read back as the runtime reads it, +/// and one that holds the file is kept. Once a policy entry is written, the +/// push says so in `out` and calls `wait` with +/// [`POLICY_PROPAGATION_MARGIN_SECONDS`] before it writes the blob, so a +/// location is unlikely to read the blob before the entry it names. Returns +/// the policies the store held and the entries written, in order. +/// +/// # Errors +/// +/// Returns a message when the stores cannot be listed, no store or more than +/// one carries the name, or a write fails. +fn push_remote<'p>( + runner: &dyn FastlyRunner, + store: &str, + policies: &'p [PolicyEntries], + blob: Vec<(String, String)>, + wait: &dyn Fn(Duration), + out: &mut dyn Write, +) -> Result, String> { + let listing = runner.run( + &fastly_cli::args(&["config-store", "list", "--json", "--non-interactive"]), + None, + )?; + let store_id = store_id_by_name(&listing, store)?; + let live = LiveEntries { + runner, + store_id: &store_id, + }; + let (kept, missing): (Vec<_>, Vec<_>) = policies + .iter() + .partition(|policy| holds_policy(&live, &policy.key, &policy.sha256)); + let mut written: Vec<(String, String)> = missing + .iter() + .flat_map(|policy| policy.entries.iter().cloned()) + .collect(); + write_entries(runner, store, &store_id, &written)?; + if !written.is_empty() { + writeln!( + out, + "waiting {POLICY_PROPAGATION_MARGIN_SECONDS} seconds for the policy entries to \ + reach every location before the blob is written" + ) + .map_err(|error| format!("failed to write command output: {error}"))?; + wait(Duration::from_secs(POLICY_PROPAGATION_MARGIN_SECONDS)); + } + write_entries(runner, store, &store_id, &blob)?; + written.extend(blob); + Ok((kept, written)) +} + +/// Writes `entries` in order to the live config store `store`, whose id is +/// `store_id`, through the `fastly` CLI. +/// +/// Each value reaches `fastly` on its standard input. A failed write stops +/// the push, and its message leaves out what `fastly` printed, because that +/// can quote the value. +fn write_entries( + runner: &dyn FastlyRunner, + store: &str, + store_id: &str, + entries: &[(String, String)], +) -> Result<(), String> { + for (written, (key, value)) in entries.iter().enumerate() { + let arguments = [ + "config-store-entry".to_owned(), + "update".to_owned(), + format!("--store-id={store_id}"), + format!("--key={key}"), + "--stdin".to_owned(), + "--upsert".to_owned(), + "--non-interactive".to_owned(), + ]; + runner.run(&arguments, Some(value)).map_err(|_error| { + format!( + "`fastly config-store-entry update` failed for `{key}` after {written} of {} \ + entries were written to the config store `{store}`. What `fastly` printed is \ + left out, because it can quote the value.", + entries.len() + ) + })?; + } + Ok(()) +} + +/// The entries of a live store, read through the `fastly` CLI, for the chunk +/// reader to put a policy entry back together. +struct LiveEntries<'a> { + runner: &'a dyn FastlyRunner, + store_id: &'a str, +} + +impl PlatformConfigStore for LiveEntries<'_> { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + let arguments = [ + "config-store-entry".to_owned(), + "describe".to_owned(), + format!("--store-id={}", self.store_id), + format!("--key={key}"), + "--json".to_owned(), + "--non-interactive".to_owned(), + ]; + let unreadable = || Report::new(PlatformError::ConfigStore); + let output = self + .runner + .run(&arguments, None) + .map_err(|message| unreadable().attach(message))?; + let entry: serde_json::Value = serde_json::from_str(&output).map_err(|error| { + unreadable().attach(format!( + "`fastly config-store-entry describe` did not answer JSON: {error}" + )) + })?; + entry + .get("item_value") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| unreadable().attach(format!("the entry `{key}` has no value"))) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +/// The id of the one store named `name` in what `fastly config-store list +/// --json` printed, a bare list or an `items` envelope, as `EdgeZero` reads +/// it. +fn store_id_by_name(listing: &str, name: &str) -> Result { + let parsed: serde_json::Value = serde_json::from_str(listing).map_err(|error| { + format!("`fastly config-store list --json` did not answer JSON: {error}") + })?; + let stores = parsed + .as_array() + .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) + .ok_or_else(|| "`fastly config-store list --json` did not answer a list".to_owned())?; + let ids: Vec<&str> = stores + .iter() + .filter(|store| store.get("name").and_then(serde_json::Value::as_str) == Some(name)) + .filter_map(|store| store.get("id").and_then(serde_json::Value::as_str)) + .collect(); + match ids.as_slice() { + [id] => Ok((*id).to_owned()), + [] => Err(format!("no Fastly config store is named `{name}`")), + _ => Err(format!( + "{} Fastly config stores are named `{name}`, so the push cannot tell which to write", + ids.len() + )), + } +} + /// The `fastly.toml` the manifest names for the Fastly adapter, beside the /// manifest. fn local_manifest(manifest_path: &Path, manifest: &Manifest) -> Result { @@ -481,8 +691,241 @@ fn local_contents(document: &mut DocumentMut) -> Option<&mut Table> { #[cfg(test)] mod tests { + use std::sync::Mutex; + use super::*; + /// Plays the `fastly` CLI for one store listing and the entry writes, + /// recording each invocation with what it was handed on its standard + /// input. + struct RecordingFastly { + listing: &'static str, + calls: Mutex, Option)>>, + } + + impl FastlyRunner for RecordingFastly { + fn run(&self, args: &[String], stdin: Option<&str>) -> Result { + self.calls + .lock() + .expect("should lock") + .push((args.to_vec(), stdin.map(str::to_owned))); + match (args[0].as_str(), args[1].as_str()) { + ("config-store", "list") => Ok(self.listing.to_owned()), + ("config-store-entry", "update") => Ok(String::new()), + _ => Err(format!("unexpected fastly {args:?}")), + } + } + } + + #[test] + fn a_remote_push_writes_chunks_then_the_pointer() { + let envelope_json = format!("x{}", "y".repeat(2 * ENTRY_LIMIT)); + let written = entries("a.example", &envelope_json, "data-sha").expect("should split"); + let store = remote_store_name(&EnvConfig::from_vars([( + "EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME", + "publisher_config", + )])) + .expect("should read the store selector"); + let fastly = RecordingFastly { + listing: r#"[{"id":"logical-id","name":"trusted_server_config"},{"id":"publisher-id","name":"publisher_config"}]"#, + calls: Mutex::default(), + }; + + let waited = Mutex::new(Vec::new()); + let mut out = Vec::new(); + + let (kept, pushed) = push_remote( + &fastly, + &store, + &[], + written.clone(), + &|wait| waited.lock().expect("should lock").push(wait), + &mut out, + ) + .expect("should write every entry"); + + assert!(kept.is_empty(), "no policy was supplied"); + assert_eq!(pushed, written, "should write the blob's entries in order"); + assert!( + waited.into_inner().expect("should unlock").is_empty(), + "should not wait when no policy entry is written" + ); + let calls = fastly.calls.into_inner().expect("should unlock"); + assert_eq!(calls[0].0[0], "config-store", "should find the store first"); + assert_eq!(calls[0].0[1], "list", "should find the store by its name"); + assert!(written.len() > 2, "the blob should be written as chunks"); + assert_eq!( + written.last().map(|(key, _)| key.as_str()), + Some("a.example"), + "the pointer should be the last entry" + ); + assert_eq!( + calls.len(), + written.len() + 1, + "should write each entry once" + ); + for ((arguments, stdin), (key, value)) in calls[1..].iter().zip(&written) { + assert_eq!(arguments[0], "config-store-entry", "should write an entry"); + assert_eq!(arguments[1], "update", "should write an entry"); + assert!( + arguments.contains(&"--store-id=publisher-id".to_owned()), + "should write `{key}` to the selected store: {arguments:?}" + ); + assert!( + arguments.contains(&format!("--key={key}")), + "should write `{key}` in its place: {arguments:?}" + ); + assert_eq!( + stdin.as_deref(), + Some(value.as_str()), + "should hand `{key}`'s value on standard input" + ); + } + } + + #[test] + fn a_remote_push_without_a_selector_writes_the_store_named_by_the_logical_id() { + assert_eq!( + remote_store_name(&EnvConfig::default()).expect("should need no selector"), + DEFAULT_CONFIG_STORE_ID + ); + } + + /// Plays one live store for the `fastly` CLI: it lists the store, answers + /// `describe` from the entries it holds, takes every write, and records + /// each call in `events`. + struct LiveStore<'a> { + entries: BTreeMap, + events: &'a Mutex>, + } + + impl FastlyRunner for LiveStore<'_> { + fn run(&self, args: &[String], _stdin: Option<&str>) -> Result { + let key = args + .iter() + .find_map(|arg| arg.strip_prefix("--key=")) + .unwrap_or_default(); + self.events + .lock() + .expect("should lock") + .push(format!("{} {key}", args[1])); + match (args[0].as_str(), args[1].as_str()) { + ("config-store", "list") => { + Ok(r#"[{"id":"store-id","name":"trusted_server_config"}]"#.to_owned()) + } + ("config-store-entry", "describe") => self + .entries + .get(key) + .map(|value| serde_json::json!({ "item_value": value }).to_string()) + .ok_or_else(|| "the Fastly API returned 404".to_owned()), + ("config-store-entry", "update") => Ok(String::new()), + _ => Err(format!("unexpected fastly {args:?}")), + } + } + } + + #[test] + fn a_live_push_writes_a_missing_policy_entry_and_waits_before_the_blob() { + let policy = policy_entries("open", OPEN_POLICY).expect("should build the policy"); + let blob = entries("a.example", "{}", "data-sha").expect("should build the blob"); + let events = Mutex::new(Vec::new()); + let store = LiveStore { + entries: BTreeMap::new(), + events: &events, + }; + let mut out = Vec::new(); + + let (kept, written) = push_remote( + &store, + "trusted_server_config", + std::slice::from_ref(&policy), + blob, + &|wait| { + events + .lock() + .expect("should lock") + .push(format!("wait {}", wait.as_secs())); + }, + &mut out, + ) + .expect("should push"); + + assert!(kept.is_empty(), "the store held no policy entry"); + assert_eq!( + events.into_inner().expect("should unlock"), + vec![ + "list ".to_owned(), + format!("describe {}", policy.key), + format!("update {}", policy.key), + format!("wait {POLICY_PROPAGATION_MARGIN_SECONDS}"), + "update a.example".to_owned(), + ], + "the entry should be written, then the wait, then the blob" + ); + assert_eq!(written.len(), 2, "the entry and the blob"); + assert!( + String::from_utf8_lossy(&out).contains("waiting"), + "the push should say it is waiting" + ); + } + + #[test] + fn a_live_push_keeps_a_policy_entry_the_store_holds_and_does_not_wait() { + let policy = policy_entries("open", OPEN_POLICY).expect("should build the policy"); + let blob = entries("a.example", "{}", "data-sha").expect("should build the blob"); + let events = Mutex::new(Vec::new()); + let store = LiveStore { + entries: policy.entries.iter().cloned().collect(), + events: &events, + }; + let mut out = Vec::new(); + + let (kept, written) = push_remote( + &store, + "trusted_server_config", + std::slice::from_ref(&policy), + blob, + &|wait| { + events + .lock() + .expect("should lock") + .push(format!("wait {}", wait.as_secs())); + }, + &mut out, + ) + .expect("should push"); + + assert_eq!(kept.len(), 1, "the store held the policy entry"); + assert_eq!( + events.into_inner().expect("should unlock"), + vec![ + "list ".to_owned(), + format!("describe {}", policy.key), + "update a.example".to_owned(), + ], + "only the blob should be written, with no wait" + ); + assert_eq!(written.len(), 1, "the blob alone"); + } + + #[test] + fn a_remote_push_refuses_a_store_name_that_is_absent_or_ambiguous() { + let listing = r#"{"items":[{"id":"a","name":"twice"},{"id":"b","name":"twice"},{"id":"c","name":"once"}]}"#; + + assert_eq!( + store_id_by_name(listing, "once").expect("should find one store"), + "c" + ); + assert!( + store_id_by_name(listing, "twice").is_err(), + "should refuse a name two stores carry" + ); + assert!( + store_id_by_name(listing, "none").is_err(), + "should refuse a name no store carries" + ); + } + /// A valid policy whose top node grants everything. const OPEN_POLICY: &str = "groups:\n open:\n default: granted\nrules:\n group: open\n jurisdiction: non-regulated\n"; @@ -511,7 +954,7 @@ mod tests { ); let stored: BTreeMap = policy.entries.iter().cloned().collect(); assert!( - holds_policy(&stored, &policy.key, &policy.sha256), + holds_policy(&StoredEntries(&stored), &policy.key, &policy.sha256), "the entries should read back as the file" ); } @@ -534,7 +977,7 @@ mod tests { (other, "an entry holding another file"), ] { assert!( - !holds_policy(&stored, &open.key, &open.sha256), + !holds_policy(&StoredEntries(&stored), &open.key, &open.sha256), "{case} should be written again" ); } diff --git a/crates/trusted-server-cli/src/commands/keys.rs b/crates/trusted-server-cli/src/commands/keys.rs new file mode 100644 index 0000000000..9c3dc465aa --- /dev/null +++ b/crates/trusted-server-cli/src/commands/keys.rs @@ -0,0 +1,1001 @@ +//! `ts keys`, the request signing key commands. +//! +//! An operator holding a Fastly API token rotates and retires the keys the +//! service signs with. The commands write the stores through the `fastly` +//! CLI, with the token taken from `FASTLY_API_TOKEN` and passed to the +//! `fastly` process in its environment, and a private key reaches the +//! `fastly` process on its standard input, never in its arguments. +//! +//! The rotation library in core takes a read that fails for an entry that +//! does not exist. Every read here is a call to the platform, which can fail +//! for other reasons, so the stores tell the two apart and write nothing once +//! a read has failed. + +use std::io::Write as _; +use std::sync::{Arc, OnceLock}; + +use error_stack::Report; +use trusted_server_core::permissions::PermissionPolicy; +use trusted_server_core::platform::{ + BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, + PlatformConfigStore, PlatformError, PlatformSecretStore, RuntimeServices, StoreId, StoreName, + UnavailableHttpClient, UnavailableKvStore, +}; +use trusted_server_core::request_signing::rotation::KeyRotationManager; +use trusted_server_core::request_signing::{kid_is_creatable, kid_is_well_formed}; + +use crate::fastly_cli::{FastlyProcess, FastlyRunner}; + +/// How the `fastly` CLI begins its report that the API found nothing at the +/// address it was asked for. Versions 15.1.0 and 16.1.0 print it for a missing +/// config store, config store entry, secret store and secret store entry. +const API_NOT_FOUND: &str = "the Fastly API returned 404"; + +/// The entries of the config store that are not keys. A key named as one of +/// them would be written over, or would take the entry with it when deleted. +const BOOKKEEPING_ENTRIES: [&str; 2] = ["current-kid", "active-kids"]; + +/// Request signing key commands. +#[derive(Debug, clap::Subcommand)] +pub(crate) enum KeysCommand { + /// Create a signing key and make it the one the service signs with. + Rotate(RotateArgs), + /// Take a key out of the active set, and delete it with `--delete`. + Deactivate(DeactivateArgs), +} + +/// The two stores request signing keeps its keys in. +#[derive(Debug, clap::Args)] +pub(crate) struct StoreArgs { + /// The Fastly config store the service links as `jwks_store`, holding the + /// public keys, `current-kid` and `active-kids`. + #[arg(long, value_name = "ID")] + config_store_id: String, + /// The Fastly secret store the service links as `signing_keys`, holding + /// the private keys. + #[arg(long, value_name = "ID")] + secret_store_id: String, +} + +/// Arguments for `ts keys rotate`. +#[derive(Debug, clap::Args)] +pub(crate) struct RotateArgs { + #[command(flatten)] + stores: StoreArgs, + /// The new key's id, `ts-` when not given. It starts with a lower + /// case letter, holds only letters, digits, `-`, `_`, `.` and `:`, and is + /// at most 128 characters. + #[arg(long)] + kid: Option, +} + +/// Arguments for `ts keys deactivate`. +#[derive(Debug, clap::Args)] +pub(crate) struct DeactivateArgs { + #[command(flatten)] + stores: StoreArgs, + /// The key to take out of the active set. The current key is refused, so + /// rotate first. + #[arg(long)] + kid: String, + /// Also delete the key from both stores. + #[arg(long)] + delete: bool, +} + +/// The `fastly` CLI as the two stores of one command use it. +struct Platform { + runner: Arc, + /// The first read that failed for a reason other than a missing entry. + failed_read: OnceLock, +} + +impl Platform { + /// Runs `fastly ` on the store `store_id`, with `flags` + /// after the store id. + /// + /// A value is joined to its flag with `=`, so that `fastly` never takes a + /// value beginning with `-` for a flag of its own. + fn run( + &self, + command: &str, + action: &str, + store_id: &str, + flags: &[String], + stdin: Option<&str>, + ) -> Result { + let mut arguments = vec![ + command.to_owned(), + action.to_owned(), + format!("--store-id={store_id}"), + ]; + arguments.extend_from_slice(flags); + arguments.push("--non-interactive".to_owned()); + self.runner.run(&arguments, stdin) + } + + /// Refuses a write once a read has failed. + /// + /// The rotation library would otherwise take the failed read for an empty + /// store, and write a key list that drops the keys already active or + /// retire the key the service signs with. + fn writable(&self, kind: PlatformError) -> Result<(), Report> { + match self.failed_read.get() { + Some(message) => Err(Report::new(kind).attach(format!( + "nothing is written, because the config store could not be read: {message}" + ))), + None => Ok(()), + } + } + + /// Deletes one entry of a store, and takes an entry that is already gone + /// for deleted, so that a delete tried again after a partial failure + /// finishes. + /// + /// The API answers that it found nothing for a missing store as well, so + /// the store is asked for before a missing entry is taken for deleted. + fn delete(&self, store: &str, store_id: &str, entry: String) -> Result<(), String> { + let deleted = self.run( + &format!("{store}-entry"), + "delete", + store_id, + &[entry, "--auto-yes".to_owned()], + None, + ); + match deleted { + Ok(_) => Ok(()), + Err(message) if message.contains(API_NOT_FOUND) => self + .run(store, "describe", store_id, &["--json".to_owned()], None) + .map(drop) + .map_err(|_| message), + Err(message) => Err(message), + } + } +} + +/// The value in what `fastly config-store-entry describe --json` printed. +fn item_value(output: &str, key: &str) -> Result { + let entry: serde_json::Value = serde_json::from_str(output).map_err(|error| { + format!("`fastly config-store-entry describe` did not answer JSON: {error}") + })?; + entry + .get("item_value") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("the entry `{key}` has no value")) +} + +/// The config store, read and written through the `fastly` CLI. +/// +/// Reads name the store the service links, which is not an id the API +/// knows, so every read goes to the store the command was given. +struct FastlyConfigStore { + platform: Arc, + store_id: String, +} + +impl PlatformConfigStore for FastlyConfigStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + self.platform + .run( + "config-store-entry", + "describe", + &self.store_id, + &[format!("--key={key}"), "--json".to_owned()], + None, + ) + .and_then(|output| item_value(&output, key)) + .map_err(|message| { + if !message.contains(API_NOT_FOUND) { + let _ = self.platform.failed_read.set(message.clone()); + } + Report::new(PlatformError::ConfigStore).attach(message) + }) + } + + fn put(&self, store_id: &StoreId, key: &str, value: &str) -> Result<(), Report> { + self.platform.writable(PlatformError::ConfigStore)?; + self.platform + .run( + "config-store-entry", + "update", + store_id.as_ref(), + &[ + format!("--key={key}"), + "--stdin".to_owned(), + "--upsert".to_owned(), + ], + Some(value), + ) + .map(drop) + .map_err(|message| Report::new(PlatformError::ConfigStore).attach(message)) + } + + fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report> { + self.platform.writable(PlatformError::ConfigStore)?; + self.platform + .delete("config-store", store_id.as_ref(), format!("--key={key}")) + .map_err(|message| Report::new(PlatformError::ConfigStore).attach(message)) + } +} + +/// The secret store, written through the `fastly` CLI and never read, because +/// the API does not give a secret back. +struct FastlySecretStore { + platform: Arc, +} + +impl PlatformSecretStore for FastlySecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result, Report> { + Err(Report::new(PlatformError::Unsupported) + .attach("a secret cannot be read back from the platform")) + } + + fn create( + &self, + store_id: &StoreId, + name: &str, + value: &str, + ) -> Result<(), Report> { + self.platform.writable(PlatformError::SecretStore)?; + self.platform + .run( + "secret-store-entry", + "create", + store_id.as_ref(), + &[format!("--name={name}"), "--stdin".to_owned()], + Some(value), + ) + .map(drop) + .map_err(|message| Report::new(PlatformError::SecretStore).attach(message)) + } + + fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report> { + self.platform.writable(PlatformError::SecretStore)?; + self.platform + .delete("secret-store", store_id.as_ref(), format!("--name={name}")) + .map_err(|message| Report::new(PlatformError::SecretStore).attach(message)) + } +} + +/// A backend nothing here asks for. +struct NoBackend; + +impl PlatformBackend for NoBackend { + fn naming_policy(&self) -> BackendNamingPolicy { + BackendNamingPolicy::Axum + } + + fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +/// The services the rotation library writes through, for these stores. +fn services(runner: Arc, stores: &StoreArgs) -> RuntimeServices { + let platform = Arc::new(Platform { + runner, + failed_read: OnceLock::new(), + }); + RuntimeServices::builder() + .config_store(Arc::new(FastlyConfigStore { + platform: Arc::clone(&platform), + store_id: stores.config_store_id.clone(), + })) + .secret_store(Arc::new(FastlySecretStore { platform })) + .kv_store(Arc::new(UnavailableKvStore)) + .backend(Arc::new(NoBackend)) + .http_client(Arc::new(UnavailableHttpClient)) + .geo(Arc::new(DisabledGeo)) + .client_info(ClientInfo::default()) + // The commands decide no request's permissions, and every services + // graph carries a policy, so they carry the one that grants nothing. + .permission_policy(PermissionPolicy::granting_nothing()) + .build() +} + +/// Refuses a key id that names one of the store's bookkeeping entries. +fn refuse_bookkeeping_entry(kid: &str) -> Result<(), String> { + if BOOKKEEPING_ENTRIES.contains(&kid) { + return Err(format!( + "kid `{kid}` names an entry the store keeps its key list in, and cannot name a key" + )); + } + Ok(()) +} + +/// Runs a `ts keys` command with the token from +/// [`crate::fastly_cli::TOKEN_VARIABLE`]. +/// +/// # Errors +/// +/// A message when the token is not set or the command fails. +pub(crate) fn run(command: &KeysCommand) -> Result<(), String> { + let report = run_with(command, Arc::new(FastlyProcess::from_environment()?))?; + writeln!(std::io::stdout().lock(), "{report}") + .map_err(|error| format!("the report could not be written: {error}")) +} + +/// Runs a `ts keys` command through `runner` and returns what it reports, as +/// JSON. +/// +/// # Errors +/// +/// A message when the key id is refused, a store cannot be read or a store +/// write fails. +pub(crate) fn run_with( + command: &KeysCommand, + runner: Arc, +) -> Result { + match command { + KeysCommand::Rotate(rotate) => { + if let Some(kid) = rotate.kid.as_deref() { + if !kid_is_creatable(kid) { + return Err(format!( + "kid `{kid}` must start with a lower case letter, hold only letters, \ + digits, `-`, `_`, `.` and `:`, and be at most 128 characters" + )); + } + refuse_bookkeeping_entry(kid)?; + } + let services = services(runner, &rotate.stores); + let manager = KeyRotationManager::new( + &rotate.stores.config_store_id, + &rotate.stores.secret_store_id, + ); + let result = manager + .rotate_key(&services, rotate.kid.clone()) + .map_err(|report| format!("the key was not rotated: {report:?}"))?; + let jwk = serde_json::to_value(&result.jwk) + .map_err(|error| format!("the new public key could not be written: {error}"))?; + Ok(serde_json::json!({ + "new_kid": result.new_kid, + "previous_kid": result.previous_kid, + "active_kids": result.active_kids, + "jwk": jwk, + }) + .to_string()) + } + KeysCommand::Deactivate(deactivate) => { + if !kid_is_well_formed(&deactivate.kid) { + return Err(format!( + "kid `{}` must hold only letters, digits, `-`, `_`, `.` and `:`, and be 1 \ + to 128 characters", + deactivate.kid + )); + } + refuse_bookkeeping_entry(&deactivate.kid)?; + let services = services(runner, &deactivate.stores); + let manager = KeyRotationManager::new( + &deactivate.stores.config_store_id, + &deactivate.stores.secret_store_id, + ); + if deactivate.delete { + manager.delete_key(&services, &deactivate.kid) + } else { + manager.deactivate_key(&services, &deactivate.kid) + } + .map_err(|report| format!("the key was not deactivated: {report:?}"))?; + let remaining = manager.list_active_keys(&services).map_err(|report| { + format!( + "the key was retired, and the active keys could not then be read: {report:?}" + ) + })?; + Ok(serde_json::json!({ + "deactivated_kid": deactivate.kid, + "deleted": deactivate.delete, + "remaining_active_kids": remaining, + }) + .to_string()) + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + use std::sync::Mutex; + + use super::*; + + const CONFIG_STORE: &str = "config-store-id"; + const SECRET_STORE: &str = "secret-store-id"; + + /// One `fastly` invocation as the fake saw it. + #[derive(Debug, Clone)] + struct Call { + args: Vec, + stdin: Option, + } + + impl Call { + /// Whether the invocation changes a store. + fn writes(&self) -> bool { + matches!(self.args[1].as_str(), "update" | "create" | "delete") + } + } + + /// Plays the `fastly` CLI against two stores held in memory, recording + /// every invocation. It answers a missing store or entry as the real one + /// does, with the API's 404. + #[derive(Debug, Default)] + struct FakeFastly { + config: Mutex>, + secrets: Mutex>, + calls: Mutex>, + /// Config keys whose next read fails as a network fault would. + failing_reads: Mutex>, + } + + impl FakeFastly { + fn with_config(entries: &[(&str, &str)]) -> Self { + let fake = Self::default(); + fake.config.lock().expect("should lock").extend( + entries + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())), + ); + fake + } + + fn config(&self, key: &str) -> Option { + self.config.lock().expect("should lock").get(key).cloned() + } + + fn has_secret(&self, name: &str) -> bool { + self.secrets.lock().expect("should lock").contains_key(name) + } + + fn add_secret(&self, name: &str) { + self.secrets + .lock() + .expect("should lock") + .insert(name.to_owned(), "private".to_owned()); + } + + /// Makes the next read of `key` fail for a reason other than the + /// entry being absent. + fn fail_next_read_of(&self, key: &str) { + self.failing_reads + .lock() + .expect("should lock") + .insert(key.to_owned()); + } + + fn calls(&self) -> Vec { + self.calls.lock().expect("should lock").clone() + } + + fn writes(&self) -> Vec { + self.calls().into_iter().filter(Call::writes).collect() + } + } + + /// The value joined to `name` with `=`, which is the only form the + /// command may pass a value in. + fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter() + .find_map(|arg| arg.strip_prefix(name)?.strip_prefix('=')) + } + + /// What `fastly` 15.1.0 and 16.1.0 print on standard error when the API + /// finds no config store or entry, and no secret store or secret. + const RECORDED_CONFIG_404: &str = "ERROR: the Fastly API returned 404 Not Found: Record \ + not found.\n\nIf you believe this error is the result \ + of a bug, please file an issue: \ + https://github.com/fastly/cli/issues/new?labels=bug&\ + template=bug_report.md"; + const RECORDED_SECRET_404: &str = "ERROR: the Fastly API returned 404 Not Found: Not \ + Found (not found).\n\nIf you believe this error is the \ + result of a bug, please file an issue: \ + https://github.com/fastly/cli/issues/new?labels=bug&\ + template=bug_report.md"; + + /// The failure the runner reports for a recorded 404, `what` saying + /// which store was asked. + fn not_found(what: &str) -> String { + let recorded = if what.contains("secret") { + RECORDED_SECRET_404 + } else { + RECORDED_CONFIG_404 + }; + format!("`fastly` failed: {recorded}") + } + + impl FastlyRunner for FakeFastly { + fn run(&self, args: &[String], stdin: Option<&str>) -> Result { + self.calls.lock().expect("should lock").push(Call { + args: args.to_vec(), + stdin: stdin.map(str::to_owned), + }); + let command = (args[0].as_str(), args[1].as_str()); + let store = flag(args, "--store-id").unwrap_or_default(); + match command { + ("config-store-entry" | "config-store", _) if store != CONFIG_STORE => { + Err(not_found("no such config store")) + } + ("secret-store-entry" | "secret-store", _) if store != SECRET_STORE => { + Err(not_found("no such secret store")) + } + ("config-store" | "secret-store", "describe") => Ok("{}".to_owned()), + ("config-store-entry", "describe") => { + let key = flag(args, "--key").unwrap_or_default(); + if self.failing_reads.lock().expect("should lock").remove(key) { + return Err("`fastly` failed: ERROR: network is unreachable".to_owned()); + } + self.config(key) + .map(|value| serde_json::json!({ "item_value": value }).to_string()) + .ok_or_else(|| not_found("no such entry")) + } + ("config-store-entry", "update") => { + let key = flag(args, "--key").unwrap_or_default(); + self.config + .lock() + .expect("should lock") + .insert(key.to_owned(), stdin.unwrap_or_default().to_owned()); + Ok(String::new()) + } + ("config-store-entry", "delete") => { + let key = flag(args, "--key").unwrap_or_default(); + self.config + .lock() + .expect("should lock") + .remove(key) + .map(|_| String::new()) + .ok_or_else(|| not_found("no such entry")) + } + ("secret-store-entry", "create") => { + // The API refuses to create a secret under a name the + // store holds, and replaces one only when asked to with + // `--recreate` or `--recreate-allow`. + let name = flag(args, "--name").unwrap_or_default(); + let mut secrets = self.secrets.lock().expect("should lock"); + let replace = args + .iter() + .any(|arg| arg == "--recreate" || arg == "--recreate-allow"); + if secrets.contains_key(name) && !replace { + return Err(format!("`fastly` failed: ERROR: secret `{name}` exists")); + } + secrets.insert(name.to_owned(), stdin.unwrap_or_default().to_owned()); + Ok(String::new()) + } + ("secret-store-entry", "delete") => { + let name = flag(args, "--name").unwrap_or_default(); + self.secrets + .lock() + .expect("should lock") + .remove(name) + .map(|_| String::new()) + .ok_or_else(|| not_found("no such secret")) + } + _ => Err(format!("unexpected fastly {args:?}")), + } + } + } + + fn stores() -> StoreArgs { + StoreArgs { + config_store_id: CONFIG_STORE.to_owned(), + secret_store_id: SECRET_STORE.to_owned(), + } + } + + fn rotate(fake: &Arc, kid: Option<&str>) -> Result { + let command = KeysCommand::Rotate(RotateArgs { + stores: stores(), + kid: kid.map(str::to_owned), + }); + run_with(&command, Arc::clone(fake) as Arc) + .map(|report| serde_json::from_str(&report).expect("should report JSON")) + } + + fn deactivate_in( + fake: &Arc, + stores: StoreArgs, + kid: &str, + delete: bool, + ) -> Result { + let command = KeysCommand::Deactivate(DeactivateArgs { + stores, + kid: kid.to_owned(), + delete, + }); + run_with(&command, Arc::clone(fake) as Arc) + .map(|report| serde_json::from_str(&report).expect("should report JSON")) + } + + fn deactivate( + fake: &Arc, + kid: &str, + delete: bool, + ) -> Result { + deactivate_in(fake, stores(), kid, delete) + } + + /// Two active keys, `ts-new` the current one, with `ts-old` held in both + /// stores. + fn two_keys() -> Arc { + let fake = Arc::new(FakeFastly::with_config(&[ + ("current-kid", "ts-new"), + ("active-kids", "ts-old,ts-new"), + ("ts-old", "{}"), + ])); + fake.add_secret("ts-old"); + fake + } + + #[test] + fn rotate_stores_a_new_key_and_makes_it_current() { + let fake = Arc::new(FakeFastly::with_config(&[ + ("current-kid", "ts-old"), + ("active-kids", "ts-old"), + ("ts-old", "{}"), + ])); + + let report = rotate(&fake, Some("ts-new")).expect("should rotate"); + + assert_eq!(report["new_kid"], "ts-new"); + assert_eq!(report["previous_kid"], "ts-old"); + assert_eq!(fake.config("current-kid").as_deref(), Some("ts-new")); + assert_eq!(fake.config("active-kids").as_deref(), Some("ts-old,ts-new")); + let jwk: serde_json::Value = + serde_json::from_str(&fake.config("ts-new").expect("should store the public key")) + .expect("should store the public key as JSON"); + assert_eq!(jwk["kid"], "ts-new"); + assert_eq!(report["jwk"], jwk, "should report the stored public key"); + assert!( + fake.secrets + .lock() + .expect("should lock") + .get("ts-new") + .is_some_and(|secret| !secret.is_empty()), + "should store the private key in the secret store" + ); + } + + #[test] + fn the_first_key_of_an_empty_store_has_no_previous_key() { + let fake = Arc::new(FakeFastly::default()); + + let report = rotate(&fake, Some("ts-new")).expect("should rotate"); + + assert_eq!(report["previous_kid"], serde_json::Value::Null); + assert_eq!(fake.config("current-kid").as_deref(), Some("ts-new")); + assert_eq!(fake.config("active-kids").as_deref(), Some("ts-new")); + } + + #[test] + fn the_private_key_reaches_fastly_only_on_its_standard_input() { + let fake = Arc::new(FakeFastly::default()); + + rotate(&fake, Some("ts-new")).expect("should rotate"); + + let secret = fake + .secrets + .lock() + .expect("should lock") + .get("ts-new") + .cloned() + .expect("should store the private key"); + for call in fake.calls() { + assert!( + call.args.iter().all(|arg| !arg.contains(&secret)), + "a private key in the arguments is visible to every process: {:?}", + call.args + ); + assert!( + call.args.iter().any(|arg| arg == "--non-interactive"), + "fastly must never wait on a prompt: {:?}", + call.args + ); + } + let create = fake + .calls() + .into_iter() + .find(|call| call.args[..2] == ["secret-store-entry", "create"]) + .expect("should create the secret"); + assert_eq!(create.stdin.as_deref(), Some(secret.as_str())); + } + + #[test] + fn rotate_names_a_key_by_date_when_given_no_kid() { + let fake = Arc::new(FakeFastly::default()); + + let report = rotate(&fake, None).expect("should rotate"); + + let kid = report["new_kid"].as_str().expect("should name the key"); + assert!(kid.starts_with("ts-"), "{kid}"); + assert!(kid_is_creatable(kid), "{kid}"); + } + + #[test] + fn rotate_refuses_a_kid_no_platform_can_store_before_writing_anything() { + let too_long = "a".repeat(129); + for kid in ["Kid", "_kid", "-kid", "kid with space", "", &too_long] { + let fake = Arc::new(FakeFastly::default()); + + let error = rotate(&fake, Some(kid)).expect_err("should refuse the kid"); + + assert!( + error.contains("must start with a lower case letter"), + "{error}" + ); + assert!(fake.calls().is_empty(), "nothing should be asked of fastly"); + } + } + + #[test] + fn rotate_refuses_a_kid_that_is_already_active() { + let fake = Arc::new(FakeFastly::with_config(&[ + ("current-kid", "ts-old"), + ("active-kids", "ts-old"), + ])); + + let error = rotate(&fake, Some("ts-old")).expect_err("should refuse a used kid"); + + assert!(error.contains("already exists"), "{error}"); + assert_eq!(fake.config("current-kid").as_deref(), Some("ts-old")); + } + + /// A secret stored under the new key's id with no public key beside it, + /// as a half finished rotation can leave one, is never replaced. The + /// command asks only to create, the create is refused, and nothing is + /// written to the config store. + #[test] + fn rotate_never_replaces_a_secret_stored_under_the_new_kid() { + let fake = two_keys(); + fake.add_secret("ts-orphan"); + + rotate(&fake, Some("ts-orphan")).expect_err("should refuse to replace the secret"); + + assert_eq!( + fake.secrets + .lock() + .expect("should lock") + .get("ts-orphan") + .map(String::as_str), + Some("private"), + "the stored secret must be left as it was" + ); + let writes = fake.writes(); + assert!( + writes + .iter() + .all(|call| call.args[0] == "secret-store-entry"), + "nothing may be written to the config store: {writes:?}" + ); + assert!( + writes + .iter() + .flat_map(|call| &call.args) + .all(|arg| arg != "--recreate" && arg != "--recreate-allow"), + "the command must never ask fastly to replace a secret: {writes:?}" + ); + assert_eq!(fake.config("current-kid").as_deref(), Some("ts-new")); + } + + /// The wording the stores read a missing entry from is what the real CLI + /// prints, for the config store and for the secret store. + #[test] + fn the_recorded_404_of_the_real_cli_reads_as_not_found() { + for recorded in [RECORDED_CONFIG_404, RECORDED_SECRET_404] { + assert!(recorded.contains(API_NOT_FOUND), "{recorded}"); + } + } + + /// The value is read from the fields `fastly config-store-entry describe + /// --json` prints in 15.1.0 and 16.1.0, here with made up values. + #[test] + fn the_value_is_read_from_the_fields_the_real_cli_prints() { + let answer = r#"{"created_at":"2026-01-01T00:00:00Z","deleted_at":null,"item_key":"current-kid","item_value":"ts-new","store_id":"examplestoreid","updated_at":"2026-01-01T00:00:00Z"}"#; + + assert_eq!(item_value(answer, "current-kid"), Ok("ts-new".to_owned())); + } + + #[test] + fn a_key_is_never_named_as_one_of_the_bookkeeping_entries() { + // On an empty store nothing else would refuse these. The public key + // would be stored as `current-kid` and then written over by the + // pointer of the same name. + for kid in BOOKKEEPING_ENTRIES { + let fake = Arc::new(FakeFastly::default()); + + let error = rotate(&fake, Some(kid)).expect_err("should refuse the kid"); + + assert!(error.contains("cannot name a key"), "{error}"); + assert!(fake.calls().is_empty(), "nothing should be asked of fastly"); + } + } + + #[test] + fn a_bookkeeping_entry_is_never_retired_as_a_key() { + // Deleting either would take the service's key list with it. + for kid in BOOKKEEPING_ENTRIES { + let fake = two_keys(); + + let error = deactivate(&fake, kid, true).expect_err("should refuse the kid"); + + assert!(error.contains("cannot name a key"), "{error}"); + assert!(fake.calls().is_empty(), "nothing should be asked of fastly"); + assert!(fake.config(kid).is_some(), "should keep `{kid}`"); + } + } + + #[test] + fn deactivate_refuses_a_kid_that_is_not_a_key_id_before_asking_fastly_anything() { + let too_long = "a".repeat(129); + for kid in ["", "ts old", "ts-old,ts-new", "ts/old", &too_long] { + let fake = two_keys(); + + let error = deactivate(&fake, kid, true).expect_err("should refuse the kid"); + + assert!(error.contains("must hold only"), "{error}"); + assert!(fake.calls().is_empty(), "nothing should be asked of fastly"); + } + } + + #[test] + fn a_key_made_under_an_earlier_rule_can_still_be_retired() { + // Neither id could be given to a new key. Both begin with a + // character `fastly` would read as a flag if it stood alone. + for kid in ["-legacy", "9legacy"] { + let fake = Arc::new(FakeFastly::with_config(&[ + ("current-kid", "ts-new"), + ("active-kids", &format!("{kid},ts-new")), + (kid, "{}"), + ])); + fake.add_secret(kid); + + deactivate(&fake, kid, true).expect("should retire the key"); + + assert_eq!(fake.config("active-kids").as_deref(), Some("ts-new")); + assert!(fake.config(kid).is_none(), "should delete the public key"); + assert!(!fake.has_secret(kid), "should delete the private key"); + } + } + + #[test] + fn every_value_is_joined_to_its_flag() { + let fake = Arc::new(FakeFastly::with_config(&[ + ("current-kid", "ts-new"), + ("active-kids", "-legacy,ts-new"), + ("-legacy", "{}"), + ])); + fake.add_secret("-legacy"); + + rotate(&fake, Some("ts-newer")).expect("should rotate"); + deactivate(&fake, "-legacy", true).expect("should retire the key"); + + for call in fake.calls() { + for arg in &call.args[2..] { + assert!( + arg.starts_with("--"), + "a value standing alone can be read as a flag: {:?}", + call.args + ); + } + } + } + + #[test] + fn deactivate_takes_a_key_out_of_the_active_set_and_keeps_its_material() { + let fake = two_keys(); + + let report = deactivate(&fake, "ts-old", false).expect("should deactivate"); + + assert_eq!( + report["remaining_active_kids"], + serde_json::json!(["ts-new"]) + ); + assert_eq!(fake.config("active-kids").as_deref(), Some("ts-new")); + assert!( + fake.config("ts-old").is_some(), + "should keep the public key" + ); + assert!(fake.has_secret("ts-old"), "should keep the private key"); + } + + #[test] + fn deactivate_with_delete_removes_the_key_from_both_stores() { + let fake = two_keys(); + + deactivate(&fake, "ts-old", true).expect("should delete"); + + assert!( + fake.config("ts-old").is_none(), + "should delete the public key" + ); + assert!(!fake.has_secret("ts-old"), "should delete the private key"); + } + + #[test] + fn deactivate_refuses_the_current_key() { + let fake = two_keys(); + + let error = deactivate(&fake, "ts-new", false).expect_err("should refuse the current key"); + + assert!(error.contains("current signing key"), "{error}"); + assert_eq!(fake.config("active-kids").as_deref(), Some("ts-old,ts-new")); + } + + #[test] + fn a_delete_tried_again_finishes_when_part_of_the_key_is_already_gone() { + // The first attempt deleted the private key and failed before the + // public one. + let fake = Arc::new(FakeFastly::with_config(&[ + ("current-kid", "ts-new"), + ("active-kids", "ts-new"), + ("ts-old", "{}"), + ])); + + deactivate(&fake, "ts-old", true).expect("should finish the delete"); + + assert!( + fake.config("ts-old").is_none(), + "should delete the public key" + ); + + deactivate(&fake, "ts-old", true).expect("should have nothing left to delete"); + } + + #[test] + fn a_missing_store_is_not_taken_for_a_deleted_key() { + let fake = two_keys(); + let wrong_secret_store = StoreArgs { + config_store_id: CONFIG_STORE.to_owned(), + secret_store_id: "another-store-id".to_owned(), + }; + + let error = deactivate_in(&fake, wrong_secret_store, "ts-old", true) + .expect_err("should report the store that was not found"); + + assert!(error.contains("404"), "{error}"); + assert!(fake.has_secret("ts-old"), "should keep the private key"); + assert!( + fake.config("ts-old").is_some(), + "should keep the public key while the private key is still stored" + ); + } + + #[test] + fn rotate_writes_nothing_when_the_active_keys_cannot_be_read() { + // Read as an empty list, the rotation would write a list holding the + // new key alone and drop `ts-old` while its signatures are in flight. + let fake = Arc::new(FakeFastly::with_config(&[ + ("current-kid", "ts-old"), + ("active-kids", "ts-old"), + ("ts-old", "{}"), + ])); + fake.fail_next_read_of("active-kids"); + + let error = rotate(&fake, Some("ts-new")).expect_err("should not rotate"); + + assert!(error.contains("nothing is written"), "{error}"); + assert!(error.contains("network is unreachable"), "{error}"); + assert!(fake.writes().is_empty(), "{:?}", fake.writes()); + assert_eq!(fake.config("active-kids").as_deref(), Some("ts-old")); + } + + #[test] + fn the_current_key_is_not_retired_when_the_current_key_cannot_be_read() { + // Read as no current key, the check that refuses the current key + // would pass, and the key the service signs with would leave the + // active set. + let fake = two_keys(); + fake.fail_next_read_of("current-kid"); + + let error = deactivate(&fake, "ts-new", false).expect_err("should not retire the key"); + + assert!(error.contains("nothing is written"), "{error}"); + assert!(fake.writes().is_empty(), "{:?}", fake.writes()); + assert_eq!(fake.config("active-kids").as_deref(), Some("ts-old,ts-new")); + } +} diff --git a/crates/trusted-server-cli/src/commands/mod.rs b/crates/trusted-server-cli/src/commands/mod.rs index 0f4354f007..2cdc395b0a 100644 --- a/crates/trusted-server-cli/src/commands/mod.rs +++ b/crates/trusted-server-cli/src/commands/mod.rs @@ -1,11 +1,11 @@ pub(crate) mod audit; +pub(crate) mod cache; pub(crate) mod config; +pub(crate) mod keys; pub(crate) mod pbs; // `dev` is `pub` so the macOS-gated `tests/proxy_e2e.rs` suite can reach -// `commands::dev::proxy`, `origin` is `pub` so `tests/origin_probe.rs` can drive the -// shareability probe against a local fixture, and `cache` is `pub` so -// `tests/cache_purge.rs` can drive a purge against one; the other command modules are +// `commands::dev::proxy`, and `origin` is `pub` so `tests/origin_probe.rs` can drive the +// shareability probe against a local fixture; the other command modules are // crate-internal. -pub mod cache; pub mod dev; pub mod origin; diff --git a/crates/trusted-server-cli/src/commands/origin/mod.rs b/crates/trusted-server-cli/src/commands/origin/mod.rs index c385881c26..c0e9f021ea 100644 --- a/crates/trusted-server-cli/src/commands/origin/mod.rs +++ b/crates/trusted-server-cli/src/commands/origin/mod.rs @@ -89,7 +89,7 @@ pub fn run(command: OriginCommand, out: &mut impl std::io::Write) -> CliResult<( /// value: `name=value; name=value`. /// /// Read from the environment and never accepted as a flag, for the same reason as the -/// admin password in `ts cache purge`: an argument is visible to every other process on +/// Fastly token in `ts cache purge`: an argument is visible to every other process on /// the host through `ps`, and lands in shell history. The report asks operators to probe /// with a genuine session's cookies, so these values are credentials. pub const PROBE_COOKIES_ENVIRONMENT_VARIABLE: &str = "TRUSTED_SERVER_PROBE_COOKIES"; diff --git a/crates/trusted-server-cli/src/fastly_cli.rs b/crates/trusted-server-cli/src/fastly_cli.rs new file mode 100644 index 0000000000..bd40b7a5ec --- /dev/null +++ b/crates/trusted-server-cli/src/fastly_cli.rs @@ -0,0 +1,146 @@ +//! Running the `fastly` CLI for the commands that administer a deployed +//! service. +//! +//! Those commands go through the hosting platform's own tool, with the +//! operator's own token. The token is read from `FASTLY_API_TOKEN` and reaches +//! the `fastly` process in its environment, never in its arguments, which +//! every other process on the machine can read. + +use std::io::Write as _; +use std::process::{Command, Stdio}; + +/// The environment variable the Fastly API token is read from. +pub(crate) const TOKEN_VARIABLE: &str = "FASTLY_API_TOKEN"; + +/// Runs one invocation of the `fastly` CLI. +pub(crate) trait FastlyRunner: Send + Sync { + /// Runs `fastly` with `args`, writing `stdin` to it when given, and + /// returns its standard output. + /// + /// # Errors + /// + /// A message when the process cannot be run or exits unsuccessfully. + fn run(&self, args: &[String], stdin: Option<&str>) -> Result; +} + +/// Runs the `fastly` binary on the `PATH`, with the token in its environment. +pub(crate) struct FastlyProcess { + token: String, +} + +impl FastlyProcess { + /// Takes the token from [`TOKEN_VARIABLE`]. + /// + /// # Errors + /// + /// A message naming the variable when it is unset or blank. + pub(crate) fn from_environment() -> Result { + Self::with_token(std::env::var(TOKEN_VARIABLE).ok()) + } + + fn with_token(token: Option) -> Result { + token + .filter(|token| !token.trim().is_empty()) + .map(|token| Self { token }) + .ok_or_else(|| format!("set {TOKEN_VARIABLE} to a Fastly API token")) + } +} + +impl FastlyRunner for FastlyProcess { + fn run(&self, args: &[String], stdin: Option<&str>) -> Result { + let mut child = Command::new("fastly") + .args(args) + .env(TOKEN_VARIABLE, &self.token) + .stdin(if stdin.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("could not run `fastly`: {error}"))?; + let written = match stdin { + Some(input) => match child.stdin.take() { + Some(mut pipe) => pipe + .write_all(input.as_bytes()) + .map_err(|error| error.to_string()), + None => Err("its standard input was not open".to_owned()), + }, + None => Ok(()), + }; + let output = child + .wait_with_output() + .map_err(|error| format!("`fastly` did not finish: {error}"))?; + // What `fastly` said comes first. A process that stopped before it + // read its input also fails the write, and its own message says why. + if !output.status.success() { + return Err(format!( + "`fastly {}` failed: {}", + command_words(args), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + written.map_err(|error| format!("could not write to `fastly`: {error}"))?; + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } +} + +/// The words of an invocation that name its command, which are those before +/// its first flag. +fn command_words(args: &[String]) -> String { + args.iter() + .map(String::as_str) + .take_while(|arg| !arg.starts_with('-')) + .collect::>() + .join(" ") +} + +/// The arguments of one `fastly` invocation, owned. +pub(crate) fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_or_blank_token_is_refused_by_name() { + for token in [None, Some(String::new()), Some(" ".to_owned())] { + let Err(error) = FastlyProcess::with_token(token.clone()) else { + panic!("should refuse the token {token:?}"); + }; + assert!( + error.contains(TOKEN_VARIABLE), + "should name the variable to set: {error}" + ); + } + } + + #[test] + fn a_token_is_kept_for_the_process_environment() { + let process = + FastlyProcess::with_token(Some("example-token".to_owned())).expect("should accept"); + + assert_eq!(process.token, "example-token"); + } + + #[test] + fn a_command_is_named_by_the_words_before_its_first_flag() { + assert_eq!( + command_words(&args(&[ + "secret-store-entry", + "create", + "--store-id=example", + "--name=ts-key", + "--stdin", + ])), + "secret-store-entry create" + ); + assert_eq!( + command_words(&args(&["service", "purge", "--key=ts-template"])), + "service purge" + ); + } +} diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index 24e001d853..4eb3d09058 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -5,6 +5,8 @@ mod app_config; #[cfg(not(target_arch = "wasm32"))] mod error; #[cfg(not(target_arch = "wasm32"))] +mod fastly_cli; +#[cfg(not(target_arch = "wasm32"))] mod prebid_bundle; #[cfg(not(target_arch = "wasm32"))] mod run; diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 2a55ca7e15..c16fc6ed23 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -41,6 +41,9 @@ enum Command { Deploy(DeployArgs), /// Probe a deployed version until it reports healthy. Healthcheck(HealthcheckArgs), + /// Rotate and retire request signing keys, with a Fastly API token. + #[command(subcommand)] + Keys(crate::commands::keys::KeysCommand), /// Trusted Server Prebid commands. Prebid(PrebidArgs), /// Provision platform resources through a target adapter. @@ -70,7 +73,7 @@ enum ConfigCommand { Gc(ConfigGcArgs), /// Push `trusted-server.toml`, with the permission policy file it names, /// as a blob envelope through `EdgeZero`, or under a Fastly `--key` of - /// its own with `--local`. + /// its own. Push(policy::PushArgs), /// Validate `edgezero.toml`, the typed Trusted Server config and, with /// `--permissions`, the permission policy file it names. @@ -161,6 +164,9 @@ fn dispatch(args: Args) -> Result { Command::Healthcheck(args) => { edgezero_cli::run_healthcheck(&args).map(|()| RunOutcome::Success) } + Command::Keys(command) => { + crate::commands::keys::run(&command).map(|()| RunOutcome::Success) + } Command::Prebid(prebid) => match prebid.command { PrebidCommand::Client(args) => { let mut generator = NpmPrebidBundleGenerator; @@ -182,7 +188,7 @@ fn dispatch(args: Args) -> Result { Command::Cache(command) => { let stdout = std::io::stdout(); let mut out = stdout.lock(); - crate::commands::cache::run(command, &mut out).map(|()| RunOutcome::Success) + crate::commands::cache::run(&command, &mut out).map(|()| RunOutcome::Success) } Command::Origin(command) => { let stdout = std::io::stdout(); @@ -204,6 +210,63 @@ mod tests { Args::try_parse_from(args).expect("should parse args") } + #[test] + fn parses_cache_purge_with_its_service_and_one_scope() { + let args = parse(&[ + "ts", + "cache", + "purge", + "--service-id", + "ExampleServiceId0123456", + "--page", + "https://example.com/article", + ]); + let Command::Cache(crate::commands::cache::CacheCommand::Purge(purge)) = args.command + else { + panic!("expected cache purge command"); + }; + assert_eq!(purge.service_id, "ExampleServiceId0123456"); + assert_eq!(purge.page.as_deref(), Some("https://example.com/article")); + assert!(!purge.all, "should not also purge everything"); + + Args::try_parse_from([ + "ts", + "cache", + "purge", + "--service-id", + "ExampleServiceId0123456", + "--all", + "--page", + "https://example.com/article", + ]) + .expect_err("should refuse both scopes at once"); + } + + #[test] + fn parses_keys_rotate_with_its_stores_and_kid() { + let args = parse(&[ + "ts", + "keys", + "rotate", + "--config-store-id", + "config-123", + "--secret-store-id", + "secret-456", + "--kid", + "ts-next", + ]); + let Command::Keys(crate::commands::keys::KeysCommand::Rotate(rotate)) = args.command else { + panic!("expected keys rotate command"); + }; + let rendered = format!("{rotate:?}"); + assert!( + rendered.contains("config-123") + && rendered.contains("secret-456") + && rendered.contains("ts-next"), + "should carry both stores and the key id, got {rendered}" + ); + } + #[test] fn top_level_version_flag_is_available() { let err = Args::try_parse_from(["ts", "--version"]) diff --git a/crates/trusted-server-cli/src/url_guard.rs b/crates/trusted-server-cli/src/url_guard.rs index 240f10d543..2fc961ff31 100644 --- a/crates/trusted-server-cli/src/url_guard.rs +++ b/crates/trusted-server-cli/src/url_guard.rs @@ -1,8 +1,8 @@ -//! Transport checks shared by the commands that send an operator's credentials to a URL. +//! Transport checks for any command that sends an operator's credentials to a URL. //! //! One implementation rather than one per command: a second copy is a second place for the //! loopback exemption to drift, and every command that gets this wrong sends a session -//! cookie or an admin password over the wire in the clear. +//! cookie over the wire in the clear. use crate::error::{CliResult, cli_error}; diff --git a/crates/trusted-server-cli/tests/cache_purge.rs b/crates/trusted-server-cli/tests/cache_purge.rs deleted file mode 100644 index 9db1938ef3..0000000000 --- a/crates/trusted-server-cli/tests/cache_purge.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! Tests for `ts cache purge` against a local fixture standing in for a deployed service. -//! -//! Run with `./scripts/test-cli.sh`. - -mod support_origin; - -use support_origin::{FixtureResponse, FixtureServer}; -use trusted_server_cli::commands::cache::{CacheCommand, PurgeArgs, run}; - -/// Serializes the environment across this binary's tests. -/// -/// The CLI reads the password from the environment on purpose, so exercising that path -/// means writing to it — and the test harness runs these in parallel threads of one -/// process, where one test clearing the variable races another that just set it. -static ENVIRONMENT: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -#[test] -fn plaintext_remote_services_are_rejected_before_sending_credentials() { - for service in [ - "http://edge.example.com", - "http://localhost.example.com", - "ftp://127.0.0.1", - ] { - let mut out = Vec::new(); - let error = with_password(Some("example-password"), || { - run( - CacheCommand::Purge(PurgeArgs { - service: service.to_owned(), - all: true, - page: None, - username: "admin".to_owned(), - }), - &mut out, - ) - }) - .expect_err("should refuse insecure remote transport"); - assert!( - error.to_string().contains("HTTPS"), - "should explain the transport requirement: {error}" - ); - assert!(out.is_empty(), "should not report a successful purge"); - } -} - -/// Set the admin password for one call and clear it afterwards. -fn with_password(password: Option<&str>, body: impl FnOnce() -> T) -> T { - // A panicking test poisons the lock; the data is `()`, so recovering it loses nothing - // and keeps one failure from cascading into every other test in the file. - let _guard = ENVIRONMENT - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - - // SAFETY: the guard above makes this the only thread touching the environment. - unsafe { - match password { - Some(value) => std::env::set_var("TRUSTED_SERVER_ADMIN_PASSWORD", value), - None => std::env::remove_var("TRUSTED_SERVER_ADMIN_PASSWORD"), - } - } - let result = body(); - // SAFETY: as above; still holding the guard. - unsafe { - std::env::remove_var("TRUSTED_SERVER_ADMIN_PASSWORD"); - } - result -} - -fn args(server: &FixtureServer, all: bool, page: Option<&str>) -> PurgeArgs { - PurgeArgs { - service: server.url(""), - all, - page: page.map(str::to_owned), - username: "admin".to_owned(), - } -} - -#[test] -fn a_successful_purge_prints_the_services_answer_and_exits_zero() { - let server = FixtureServer::start(|_request| { - FixtureResponse::html(r#"{"purged":true,"scope":"all"}"#) - .with_header("content-type", "application/json") - }); - - let mut out = Vec::new(); - let outcome = with_password(Some("admin-pass"), || { - run(CacheCommand::Purge(args(&server, true, None)), &mut out) - }); - - assert!(outcome.is_ok(), "a 200 from the service must exit zero"); - let rendered = String::from_utf8(out).expect("output should be UTF-8"); - assert!( - rendered.contains(r#""purged":true"#), - "the service's own answer is what an operator needs, got: {rendered}" - ); -} - -#[test] -fn the_request_carries_credentials_json_and_the_expected_body() { - // Pins the wire format against the endpoint's guards: it requires POST, rejects any - // Content-Type but application/json, and authenticates on ^/_ts/admin. - let server = FixtureServer::start(|request| { - FixtureResponse::html(format!( - r#"{{"purged":true,"scope":"all","method":"{}","auth":{},"type":"{}","path":"{}"}}"#, - request.method, - request.header("authorization").is_some(), - request.header("content-type").unwrap_or("none"), - request.path - )) - }); - - let mut out = Vec::new(); - with_password(Some("admin-pass"), || { - run(CacheCommand::Purge(args(&server, true, None)), &mut out).expect("should succeed") - }); - - let rendered = String::from_utf8(out).expect("output should be UTF-8"); - assert!(rendered.contains(r#""method":"POST""#), "got: {rendered}"); - assert!(rendered.contains(r#""auth":true"#), "got: {rendered}"); - assert!( - rendered.contains(r#""type":"application/json""#), - "got: {rendered}" - ); - assert!( - rendered.contains(r#""path":"/_ts/admin/cache/purge""#), - "got: {rendered}" - ); -} - -#[test] -fn a_missing_password_fails_before_the_service_is_touched() { - let server = FixtureServer::start(|_request| FixtureResponse::html("{}")); - - let mut out = Vec::new(); - let outcome = with_password(None, || { - run(CacheCommand::Purge(args(&server, true, None)), &mut out) - }); - - assert!(outcome.is_err()); - assert_eq!( - server.request_count(), - 0, - "a purge must not be attempted without a credential" - ); -} - -#[test] -fn a_rejected_purge_exits_non_zero_and_explains_the_status() { - for (status, expected_hint) in [ - (401u16, "admin username"), - (404, "predate the purge endpoint"), - (501, "cannot purge"), - ] { - let server = - FixtureServer::start(move |_request| FixtureResponse::html("{}").with_status(status)); - - let mut out = Vec::new(); - let outcome = with_password(Some("admin-pass"), || { - run(CacheCommand::Purge(args(&server, true, None)), &mut out) - }); - - let error = outcome.expect_err("a refused purge must not exit zero"); - let message = error.to_string(); - assert!( - message.contains(expected_hint), - "a {status} must say what to do next, got: {message}" - ); - } -} - -#[test] -fn a_page_purge_sends_the_url_the_operator_typed() { - let server = FixtureServer::start(|request| { - let body: serde_json::Value = - serde_json::from_slice(&request.body).expect("should parse purge body"); - assert_eq!( - body, - serde_json::json!({"scope": "url", "url": "https://example.com/article"}), - "should send the requested reader URL" - ); - FixtureResponse::html( - serde_json::json!({"purged": true, "scope": "url", "surrogate_key": "example-key"}) - .to_string(), - ) - }); - - let mut out = Vec::new(); - with_password(Some("admin-pass"), || { - run( - CacheCommand::Purge(args(&server, false, Some("https://example.com/article"))), - &mut out, - ) - .expect("should succeed") - }); - - assert_eq!(server.request_count(), 1); -} - -#[test] -fn redirects_cannot_turn_a_login_page_into_a_successful_purge() { - let server = FixtureServer::start(|request| { - if request.path == "/_ts/admin/cache/purge" { - FixtureResponse::html("") - .with_status(302) - .with_header("location", "/login") - } else { - FixtureResponse::html("login") - } - }); - let mut out = Vec::new(); - - let outcome = with_password(Some("admin-pass"), || { - run(CacheCommand::Purge(args(&server, true, None)), &mut out) - }); - - assert!(outcome.is_err(), "should refuse a redirected purge"); - assert!(out.is_empty(), "should print no success response"); - assert_eq!(server.request_count(), 1, "should not follow the redirect"); -} - -#[test] -fn success_requires_a_valid_acknowledgment_for_the_requested_scope() { - for body in [ - "login".to_owned(), - serde_json::json!({"purged": false, "scope": "all"}).to_string(), - serde_json::json!({"purged": true, "scope": "url"}).to_string(), - serde_json::json!({"scope": "all"}).to_string(), - ] { - let server = FixtureServer::start(move |_request| FixtureResponse::html(body.clone())); - let mut out = Vec::new(); - - let outcome = with_password(Some("admin-pass"), || { - run(CacheCommand::Purge(args(&server, true, None)), &mut out) - }); - - assert!( - outcome.is_err(), - "should refuse a missing or mismatched purge acknowledgment" - ); - assert!(out.is_empty(), "should print no success response"); - } -} - -#[test] -fn a_url_purge_requires_a_surrogate_key_in_its_acknowledgment() { - for key in [ - serde_json::Value::Null, - serde_json::json!(""), - serde_json::json!(" "), - ] { - let server = FixtureServer::start(move |_request| { - FixtureResponse::html( - serde_json::json!({ - "purged": true, "scope": "url", "surrogate_key": key - }) - .to_string(), - ) - }); - let mut out = Vec::new(); - - let outcome = with_password(Some("admin-pass"), || { - run( - CacheCommand::Purge(args(&server, false, Some("https://example.com/article"))), - &mut out, - ) - }); - - assert!( - outcome.is_err(), - "should require the service to name the purged URL key" - ); - assert!( - out.is_empty(), - "should not print an incomplete acknowledgment as success" - ); - } -} diff --git a/crates/trusted-server-cli/tests/config_push_by_key.rs b/crates/trusted-server-cli/tests/config_push_by_key.rs index dc39a2f2db..d25ee2d5a8 100644 --- a/crates/trusted-server-cli/tests/config_push_by_key.rs +++ b/crates/trusted-server-cli/tests/config_push_by_key.rs @@ -542,21 +542,29 @@ fn a_keyed_push_refuses_settings_that_name_no_policy_and_writes_nothing() { } #[test] -fn a_remote_push_of_a_host_key_is_refused_without_local() { +fn a_remote_dry_run_names_the_live_store_and_writes_nothing() { let project = project(); let before = fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"); - let output = push(&project, &["--key", HOST]); + // The runner's environment is cleared, so no `fastly` CLI can be found + // and a dry run that asked it anything would fail. + let output = push( + &project, + &["--key", HOST, "--dry-run", "--permissions", POLICY_FILE], + ); - assert!(!output.status.success(), "the push should be refused"); + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); assert!( - String::from_utf8_lossy(&output.stderr).contains("only with `--local`"), - "should say the key can be pushed only with --local: {}", - String::from_utf8_lossy(&output.stderr) + stdout.contains(&format!("would write `{HOST}` (")) + && stdout.contains(&format!( + "the Fastly config store `{DEFAULT_CONFIG_STORE_ID}`" + )), + "should list the key for the store the selector names: {stdout}" ); assert_eq!( fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"), before, - "a refused push should write nothing" + "a push without --local should not write the local store" ); } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 412ba7a342..35fb9bd7ce 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -2659,11 +2659,7 @@ mod tests { .collect::>(); let mut config = plan_config(tables); config.timeout_ms = 777; - config.request_signing = signing.then(|| crate::settings::RequestSigning { - enabled: true, - config_store_id: "fictional-config-store".to_string(), - secret_store_id: "fictional-secret-store".to_string(), - }); + config.request_signing = signing.then(|| crate::settings::RequestSigning::new(true)); config } diff --git a/crates/trusted-server-core/src/auction/plan.rs b/crates/trusted-server-core/src/auction/plan.rs index ab9437177e..49fff990f0 100644 --- a/crates/trusted-server-core/src/auction/plan.rs +++ b/crates/trusted-server-core/src/auction/plan.rs @@ -335,7 +335,10 @@ impl AuctionPlan { .adserver .validate("ad-server") .map_err(configuration_error)?; - let signing_enabled = compile_signing_enabled(config.request_signing.as_ref())?; + let signing_enabled = config + .request_signing + .as_ref() + .is_some_and(|signing| signing.enabled); let mut providers = Vec::new(); let mut provider_indices = BTreeMap::new(); @@ -716,23 +719,6 @@ fn check_endpoint( Ok(endpoint) } -fn compile_signing_enabled( - request_signing: Option<&RequestSigning>, -) -> Result> { - let Some(request_signing) = request_signing else { - return Ok(false); - }; - if request_signing.enabled - && (request_signing.config_store_id.trim().is_empty() - || request_signing.secret_store_id.trim().is_empty()) - { - return Err(configuration_error( - "enabled request_signing requires nonblank config_store_id and secret_store_id", - )); - } - Ok(request_signing.enabled) -} - fn compile_notifications( provider_id: &ProviderId, config: NotificationConfig, @@ -1290,41 +1276,12 @@ mod tests { ); } - #[test] - fn enabled_signing_requires_nonblank_existing_global_store_ids() { - let mut raw = one_source(); - raw.request_signing = Some(RequestSigning { - enabled: true, - config_store_id: " ".to_string(), - secret_store_id: "example-secret-store".to_string(), - }); - assert!( - AuctionPlan::compile(raw).is_err(), - "should reject enabled signing without a config store ID" - ); - - let mut raw = one_source(); - raw.request_signing = Some(RequestSigning { - enabled: true, - config_store_id: "example-config-store".to_string(), - secret_store_id: "\t".to_string(), - }); - assert!( - AuctionPlan::compile(raw).is_err(), - "should reject enabled signing without a secret store ID" - ); - } - #[test] fn routing_signing_and_the_selected_ad_server_are_preserved_in_the_plan() { let mut plain = table("auction.plain-fixture"); plain.insert(ROUTING_KEY.to_string(), json!("all_eligible")); let mut raw = config(vec![("one", plain)]); - raw.request_signing = Some(RequestSigning { - enabled: true, - config_store_id: "example-config-store".to_string(), - secret_store_id: "example-secret-store".to_string(), - }); + raw.request_signing = Some(RequestSigning::new(true)); raw.adserver = ProviderChoice::new( Some("fixture".to_string()), BTreeMap::from([( diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs deleted file mode 100644 index 9bbd40b69a..0000000000 --- a/crates/trusted-server-core/src/auth.rs +++ /dev/null @@ -1,607 +0,0 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use edgezero_core::body::Body as EdgeBody; -use error_stack::Report; -use http::header; -use http::{Request, Response, StatusCode}; -use sha2::{Digest as _, Sha256}; -use subtle::ConstantTimeEq as _; - -use crate::error::TrustedServerError; -use crate::settings::Settings; - -const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#; - -/// Marks the single `Authorization` value Trusted Server validated. -/// -/// The shared template cache may exempt this value from its normal authorization -/// bypass. [`enforce_basic_auth`] clears any existing marker before checking and -/// inserts a digest-bound marker only after successful authentication. -#[derive(Debug, Clone)] -pub(crate) struct EdgeTerminatedAuthorization([u8; 32]); - -impl EdgeTerminatedAuthorization { - fn digest(value: &[u8]) -> [u8; 32] { - Sha256::digest(value).into() - } - - pub(crate) fn matches(&self, headers: &http::HeaderMap) -> bool { - let mut values = headers.get_all(header::AUTHORIZATION).iter(); - let Some(value) = values.next() else { - return false; - }; - values.next().is_none() && self.0 == Self::digest(value.as_bytes()) - } - - /// Builds the marker without performing a credential check. - /// - /// Test-only. Production code obtains this marker exclusively by passing - /// [`enforce_basic_auth`], which is what makes it meaningful. - #[cfg(test)] - #[must_use] - pub(crate) fn for_test(value: &str) -> Self { - Self(Self::digest(value.as_bytes())) - } -} - -/// Enforces HTTP Basic authentication for configured handler paths. -/// -/// Returns `Ok(None)` when the request does not target a protected handler or -/// when the supplied credentials are valid. Returns `Ok(Some(Response))` with -/// the auth challenge when credentials are missing or invalid. -/// -/// Admin endpoints are protected by requiring a handler during settings -/// finalization; see [`Settings::from_toml`]. Credential checks use constant-time -/// comparison for both username and password, and evaluate both regardless of -/// individual match results to avoid timing oracles. Runtime requests within -/// the reserved admin namespace fail closed if no handler matches, providing -/// defense in depth for malformed and parameterized paths. -/// -/// # Request mutation -/// -/// Takes `req` mutably because it owns [`EdgeTerminatedAuthorization`]. Any -/// inherited marker is cleared on entry, and a fresh one is inserted only on the -/// success path, so the marker present after this call always describes this -/// call's own decision. Nothing else about the request is touched — in -/// particular the `Authorization` header is left in place and still reaches the -/// publisher origin. -/// -/// That last point is a stated assumption: a credential this edge terminates is -/// treated as reader-neutral, which holds unless the origin *also* authenticates -/// on the same header. An origin that does so declares `Vary: Authorization`, -/// which the template-cache store refuses as an uncovered `Vary` name. An origin -/// that varies on `Authorization` without declaring it would defeat any HTTP -/// cache, and is out of scope here. -/// -/// # Errors -/// -/// Returns an error when handler configuration is invalid, such as an -/// un-compilable path regex. -pub fn enforce_basic_auth( - settings: &Settings, - req: &mut Request, -) -> Result>, Report> { - // Cleared before any early return so no inherited marker can survive a call - // that did not itself validate a credential. Without this, a request marked - // upstream and then routed to an unprotected path would keep an assertion - // nothing checked. - req.extensions_mut().remove::(); - - let path = req.uri().path(); - let Some(handler) = settings.handler_for_path(path)? else { - if Settings::is_admin_path(path) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!("Admin path `{path}` has no configured handler"), - })); - } - return Ok(None); - }; - - let Some((username, password, authorization_digest)) = extract_credentials(req) else { - return Ok(Some(unauthorized_response())); - }; - - // Hash before comparing to normalise lengths — `ct_eq` on raw byte slices - // short-circuits when lengths differ, which would leak credential length. - // SHA-256 produces fixed-size digests so the comparison is truly constant-time. - // - // Note: constant-time guarantees are best-effort on WASM targets because the - // runtime optimiser/JIT may re-introduce variable-time paths. This is an - // inherent limitation of all constant-time code in managed runtimes. - let username_match = Sha256::digest(handler.username.expose().as_bytes()) - .ct_eq(&Sha256::digest(username.as_bytes())); - let password_match = Sha256::digest(handler.password.expose().as_bytes()) - .ct_eq(&Sha256::digest(password.as_bytes())); - - if bool::from(username_match & password_match) { - // Record that TS itself consumed this credential, so the shared template - // cache can distinguish it from a credential meant for the origin. - req.extensions_mut() - .insert(EdgeTerminatedAuthorization(authorization_digest)); - Ok(None) - } else { - log::warn!("Basic auth failed for path: {}", req.uri().path()); - Ok(Some(unauthorized_response())) - } -} - -/// Username from a basic-auth request, for audit logging. -/// -/// Returns the username alone. The password is a shared static secret and must never reach -/// a log line. -/// -/// This parses a header; it verifies nothing. Call it only on a request -/// [`enforce_basic_auth`] has already accepted, where the username identifies which -/// operator credential was used. -#[must_use] -pub fn authenticated_username(req: &Request) -> Option { - extract_credentials(req).map(|(username, _password, _digest)| username) -} - -fn extract_credentials(req: &Request) -> Option<(String, String, [u8; 32])> { - let mut header_values = req.headers().get_all(header::AUTHORIZATION).iter(); - let header_value = header_values.next()?; - if header_values.next().is_some() { - return None; - } - let authorization_digest = EdgeTerminatedAuthorization::digest(header_value.as_bytes()); - let header_value = header_value.to_str().ok()?; - - let mut parts = header_value.splitn(2, ' '); - let scheme = parts.next()?.trim(); - if !scheme.eq_ignore_ascii_case("basic") { - return None; - } - - let token = parts.next()?.trim(); - if token.is_empty() { - return None; - } - - let decoded = STANDARD.decode(token).ok()?; - let credentials = String::from_utf8(decoded).ok()?; - - let mut credentials_parts = credentials.splitn(2, ':'); - let username = credentials_parts.next()?.to_owned(); - let password = credentials_parts.next()?.to_owned(); - - Some((username, password, authorization_digest)) -} - -fn unauthorized_response() -> Response { - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header(header::WWW_AUTHENTICATE, BASIC_AUTH_REALM) - .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") - .body(EdgeBody::from(b"Unauthorized".as_ref())) - .expect("should build unauthorized response") -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::engine::general_purpose::STANDARD; - use http::{HeaderValue, Method, header}; - - use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; - - fn build_request(method: Method, uri: &str) -> Request { - Request::builder() - .method(method) - .uri(uri) - .body(EdgeBody::empty()) - .expect("should build request") - } - - fn set_authorization(req: &mut Request, value: &str) { - req.headers_mut().insert( - header::AUTHORIZATION, - HeaderValue::from_str(value).expect("should build authorization header"), - ); - } - - #[test] - fn encoded_admin_separator_path_is_auth_gated() { - // `^/_ts/admin` matches the raw path, so a percent-encoded separator - // still consumes admin credentials. The publisher-fallback boundary - // reserves the same paths so those credentials are never forwarded - // upstream (see `ec::admin::deny_admin_diagnostic_fallback`). - let settings = create_test_settings(); - - for path in ["/_ts/admin%2Fec", "/_ts/admin%2fec"] { - let mut req = build_request(Method::GET, &format!("https://example.com{path}")); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .unwrap_or_else(|| panic!("should challenge {path}")); - - assert_eq!( - response.status(), - StatusCode::UNAUTHORIZED, - "should require credentials for {path}" - ); - } - } - - #[test] - fn valid_credentials_mark_the_request_as_edge_terminated() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/secure"); - let encoded = STANDARD.encode("user:pass"); - set_authorization(&mut req, &format!("Basic {encoded}")); - - assert!( - enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .is_none(), - "valid credentials should be admitted" - ); - let marker = req - .extensions() - .get::() - .expect("should mark a credential this edge consumed"); - assert!( - marker.matches(req.headers()), - "the marker should match the unchanged validated authorization" - ); - - req.headers_mut().insert( - header::AUTHORIZATION, - HeaderValue::from_static("Bearer publisher-origin-credential"), - ); - let marker = req - .extensions() - .get::() - .expect("should retain the marker after an unrelated mutation"); - assert!( - !marker.matches(req.headers()), - "the marker must not match a replacement authorization value" - ); - } - - #[test] - fn repeated_authorization_values_are_rejected_without_a_marker() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/secure"); - let encoded = STANDARD.encode("user:pass"); - set_authorization(&mut req, &format!("Basic {encoded}")); - req.headers_mut().append( - header::AUTHORIZATION, - HeaderValue::from_static("Bearer publisher-origin-credential"), - ); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .expect("should challenge an ambiguous credential"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - req.extensions() - .get::() - .is_none(), - "a repeated authorization field must remain pass-through rather than being marked safe" - ); - } - - #[test] - fn an_inherited_marker_is_cleared_on_an_unprotected_path() { - // The marker asserts "this edge already checked a credential". A request - // routed to a path no handler protects was never checked here, so a marker - // it arrived with must not survive to grant shared-template eligibility. - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/open"); - set_authorization(&mut req, "Basic dXNlcjpwYXNz"); - req.extensions_mut() - .insert(EdgeTerminatedAuthorization::for_test("Basic dXNlcjpwYXNz")); - - assert!( - enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .is_none(), - "an unprotected path should not challenge" - ); - assert!( - req.extensions() - .get::() - .is_none(), - "a marker no credential check produced must not survive this call" - ); - } - - #[test] - fn an_inherited_marker_is_cleared_when_credentials_are_rejected() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/secure"); - let encoded = STANDARD.encode("user:wrong-pass"); - set_authorization(&mut req, &format!("Basic {encoded}")); - req.extensions_mut() - .insert(EdgeTerminatedAuthorization::for_test( - "Basic dXNlcjp3cm9uZy1wYXNz", - )); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .expect("should challenge"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - req.extensions() - .get::() - .is_none(), - "a failed check must strip an inherited marker rather than honour it" - ); - } - - #[test] - fn a_non_protected_path_leaves_authorization_unmarked() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/open"); - set_authorization(&mut req, "Basic dXNlcjpwYXNz"); - - assert!( - enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .is_none(), - "an unprotected path should not challenge" - ); - assert!( - req.extensions() - .get::() - .is_none(), - "a credential no handler consumed is pass-through and must stay disqualifying" - ); - } - - #[test] - fn rejected_credentials_leave_the_request_unmarked() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/secure"); - let encoded = STANDARD.encode("user:wrong-pass"); - set_authorization(&mut req, &format!("Basic {encoded}")); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .expect("should challenge"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - req.extensions() - .get::() - .is_none(), - "a failed credential must never be marked as terminated" - ); - } - - #[test] - fn no_challenge_for_non_protected_path() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/open"); - - assert!( - enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .is_none() - ); - } - - #[test] - fn challenge_when_missing_credentials() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/secure"); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .expect("should challenge"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - let realm = response - .headers() - .get(header::WWW_AUTHENTICATE) - .expect("should have WWW-Authenticate header"); - assert_eq!(realm, BASIC_AUTH_REALM); - } - - #[test] - fn allow_when_credentials_match() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/secure/data"); - let token = STANDARD.encode("user:pass"); - set_authorization(&mut req, &format!("Basic {token}")); - - assert!( - enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .is_none() - ); - } - - #[test] - fn challenge_when_both_credentials_wrong() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/secure/data"); - let token = STANDARD.encode("wrong:wrong"); - set_authorization(&mut req, &format!("Basic {token}")); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .expect("should challenge"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn challenge_when_username_wrong_password_correct() { - // Validates that both fields are always evaluated — no short-circuit username oracle. - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/secure/data"); - let token = STANDARD.encode("wrong-user:pass"); - set_authorization(&mut req, &format!("Basic {token}")); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .expect("should challenge"); - assert_eq!( - response.status(), - StatusCode::UNAUTHORIZED, - "should reject wrong username even with correct password" - ); - } - - #[test] - fn challenge_when_username_correct_password_wrong() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/secure/data"); - let token = STANDARD.encode("user:wrong-pass"); - set_authorization(&mut req, &format!("Basic {token}")); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .expect("should challenge"); - assert_eq!( - response.status(), - StatusCode::UNAUTHORIZED, - "should reject correct username with wrong password" - ); - } - - #[test] - fn challenge_when_scheme_is_not_basic() { - let settings = create_test_settings(); - let mut req = build_request(Method::GET, "https://example.com/secure"); - set_authorization(&mut req, "Bearer token"); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .expect("should challenge"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn returns_error_for_invalid_handler_regex_without_panicking() { - let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "(""#); - let err = Settings::from_toml(&config).expect_err("should reject invalid handler regex"); - assert!( - err.to_string() - .contains("Handler path regex `(` failed to compile"), - "should describe the invalid handler regex" - ); - } - - #[test] - fn allow_admin_path_with_valid_credentials() { - let settings = create_test_settings(); - let mut req = build_request(Method::POST, "https://example.com/_ts/admin/keys/rotate"); - let token = STANDARD.encode("admin:admin-pass"); - set_authorization(&mut req, &format!("Basic {token}")); - - assert!( - enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .is_none(), - "should allow admin path with correct credentials" - ); - } - - #[test] - fn challenge_admin_path_with_wrong_credentials() { - let settings = create_test_settings(); - let mut req = build_request(Method::POST, "https://example.com/_ts/admin/keys/rotate"); - let token = STANDARD.encode("admin:wrong"); - set_authorization(&mut req, &format!("Basic {token}")); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .expect("should challenge admin path with wrong credentials"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - /// A handler regex is matched against the whole request path, so a broad - /// pattern such as `^/_ts` covers browser-facing endpoints in that - /// namespace — `/_ts/page-bids` and `/_ts/api/v1/identify` — not just the - /// admin routes it was probably meant for. Anonymous browser fetches never - /// carry Basic credentials, so those endpoints answer `401` for every - /// visitor. - /// - /// This pins the behaviour rather than exempting the paths: which paths a - /// handler covers is the operator's decision, and silently carving holes in - /// it would be worse than a documented constraint. Operators must scope - /// handler patterns to the paths they mean (`^/_ts/admin`) — see the - /// configuration guide. The tsjs client's `/__ts/page-bids` fallback keeps - /// affected deployments serving SPA ads until they do, but it disappears - /// with the alias in IABTechLab/trusted-server#970. - #[test] - fn broad_handler_regex_also_covers_browser_facing_endpoints() { - let config = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "^/_ts""#); - let settings = Settings::from_toml(&config).expect("should parse broad handler regex"); - - for path in [ - "https://example.com/_ts/page-bids?path=/article", - "https://example.com/_ts/api/v1/identify", - ] { - let mut req = build_request(Method::GET, path); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .unwrap_or_else(|| panic!("should challenge {path} under a `^/_ts` handler")); - assert_eq!( - response.status(), - StatusCode::UNAUTHORIZED, - "a `^/_ts` handler should challenge {path}" - ); - } - } - - #[test] - fn challenge_admin_path_with_missing_credentials() { - let settings = create_test_settings(); - let mut req = build_request(Method::POST, "https://example.com/_ts/admin/keys/rotate"); - - let response = enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .expect("should challenge admin path with missing credentials"); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn concrete_admin_path_without_matching_handler_fails_closed() { - let config = crate_test_settings_str().replace( - r#"path = "^/_ts/admin" - username = "admin" - password = "admin-pass""#, - r#"path = "^/_ts/admin/(keys/rotate|keys/deactivate|ec|eids)$" - username = "admin" - password = "strong-test-password" - - [[handlers]] - path = "^/_ts/admin/ec/[{]id[}]$" - username = "admin" - password = "strong-test-password""#, - ); - let settings: Settings = - toml::from_str(&config).expect("should deserialize settings without finalization"); - let ec_id = format!("{}.abc123", "a".repeat(64)); - let mut req = build_request( - Method::GET, - &format!("https://example.com/_ts/admin/ec/{ec_id}"), - ); - - let error = enforce_basic_auth(&settings, &mut req) - .expect_err("should fail closed without a matching admin handler"); - assert!( - error.to_string().contains("no configured handler"), - "should describe the missing admin handler" - ); - } - - #[test] - fn similar_non_admin_prefix_without_handler_remains_public() { - let config = crate_test_settings_str().replace( - r#"path = "^/_ts/admin""#, - r#"path = "^/_ts/admin/keys/rotate$""#, - ); - let settings: Settings = - toml::from_str(&config).expect("should deserialize settings without finalization"); - let mut req = build_request(Method::GET, "https://example.com/_ts/administrator"); - - assert!( - enforce_basic_auth(&settings, &mut req) - .expect("should evaluate auth") - .is_none(), - "should not classify a similar prefix as the admin namespace" - ); - } -} diff --git a/crates/trusted-server-core/src/cache_purge.rs b/crates/trusted-server-core/src/cache_purge.rs deleted file mode 100644 index f77f43e4dd..0000000000 --- a/crates/trusted-server-core/src/cache_purge.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! The operator-facing purge endpoint for templates and tagged origin responses. -//! -//! `POST /_ts/admin/cache/purge` with `{"scope":"all"}` or -//! `{"scope":"url","url":"https://example.com/page"}`. -//! -//! # Why every method is registered, not just `POST` -//! -//! A named route only claims the methods it lists. A method it does not claim falls through -//! to the publisher, and `enforce_basic_auth` leaves the `Authorization` header in place, so -//! a `GET` to this path would authenticate and then ship the shared admin credential to the -//! publisher origin. The route therefore claims every method and this handler answers the -//! non-`POST` ones with 405 itself. -//! -//! # Why `Content-Type` is enforced exactly -//! -//! Browsers attach basic-auth credentials automatically. A cross-origin form POST with -//! `enctype="text/plain"` is not preflighted, so requiring `POST` alone does not stop CSRF; -//! requiring a `Content-Type` that a form cannot produce does. - -use edgezero_core::body::Body as EdgeBody; -use error_stack::Report; -use http::{Method, Request, Response, StatusCode, header}; -use serde::{Deserialize, Serialize}; - -use crate::error::TrustedServerError; -use crate::http_util::enforce_max_body_size; -use crate::platform::{RuntimeServices, reader_url_surrogate_key}; - -/// Purge bodies name a scope and, for a URL purge, one URL. Nothing here is unbounded. -const PURGE_MAX_BODY_BYTES: usize = 4096; - -/// The request body, before its scope is validated. -/// -/// Deserialized as a flat struct rather than an internally-tagged enum on purpose. -/// `deny_unknown_fields` does not reach the unit variant of such an enum, so -/// `{"scope":"all","url":"…"}` would parse as a full flush — an operator who typed the -/// wrong scope while intending to purge one page would empty the whole cache and be told -/// it succeeded. Validating the pair by hand makes that combination an error. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PurgeBody { - scope: String, - url: Option, -} - -/// What an operator asked to purge. -#[derive(Debug, PartialEq, Eq)] -enum PurgeRequest { - /// Every template and tagged origin response this service has cached. - All, - /// One reader-facing URL, as a reader would type it; canonicalized before hashing. - Url(String), -} - -impl PurgeRequest { - /// Parse and validate a purge body. - /// - /// # Errors - /// - /// Returns a message naming the problem when the JSON is malformed, the scope is not - /// one of the two documented values, or the scope and `url` field disagree. - fn parse(bytes: &[u8]) -> Result { - let body: PurgeBody = - serde_json::from_slice(bytes).map_err(|error| format!("invalid JSON: {error}"))?; - - match (body.scope.as_str(), body.url) { - ("all", None) => Ok(Self::All), - ("all", Some(_)) => Err( - "scope \"all\" takes no url; did you mean {\"scope\":\"url\",\"url\":…}?" - .to_owned(), - ), - // Parsed, not merely non-empty. `reader_url_surrogate_key` hashes whatever it - // is given, so a path, a scheme-less host, or an `ftp://` URL would be - // acknowledged as purged under a key nothing was ever stored with — the exact - // silent no-op that key's own documentation calls the failure that matters. - // A CMS webhook sending paths would purge nothing and never find out. - ("url", Some(url)) - if url::Url::parse(&url).is_ok_and(|parsed| { - matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some() - }) => - { - Ok(Self::Url(url)) - } - ("url", _) => Err( - "scope \"url\" requires an absolute http(s) url, as a reader addresses the page" - .to_owned(), - ), - (other, _) => Err(format!( - "unknown scope {other:?}; expected \"all\" or \"url\"" - )), - } - } -} - -/// What the purge did, in terms an operator mid-incident can act on. -#[derive(Debug, Serialize)] -struct PurgeResponse { - purged: bool, - scope: &'static str, - /// Present on a URL purge, so an operator can confirm which key was hit. - #[serde(skip_serializing_if = "Option::is_none")] - surrogate_key: Option, - message: &'static str, -} - -/// Handle a purge request. -/// -/// # Errors -/// -/// Returns an error when the body cannot be read, exceeds [`PURGE_MAX_BODY_BYTES`], is not -/// valid JSON for a known scope, or when the platform's purge fails. -pub async fn handle_cache_purge( - services: &RuntimeServices, - req: Request, - authenticated_principal: Option<&str>, -) -> Result, Report> { - if req.method() != Method::POST { - return Ok(method_not_allowed()); - } - - // Checked before the body is read: a request that cannot be a legitimate API call - // should not have its payload parsed at all. - let content_type = req - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default(); - if !is_exactly_json(content_type) { - return Ok(json_response( - StatusCode::UNSUPPORTED_MEDIA_TYPE, - r#"{"purged":false,"error":"Content-Type must be application/json"}"#, - )); - } - - let body = req.into_body(); - if body.is_stream() { - return Err(Report::new(TrustedServerError::BadRequest { - message: "cache-purge request body must be buffered, not streamed".into(), - })); - } - let bytes = body.into_bytes().unwrap_or_default(); - enforce_max_body_size(&bytes, PURGE_MAX_BODY_BYTES, "cache-purge")?; - - let request = - PurgeRequest::parse(&bytes).map_err(|message| TrustedServerError::BadRequest { - message: format!("invalid cache-purge request: {message}"), - })?; - - match request { - PurgeRequest::All => { - // An unbounded flush behind one shared static credential is also an - // origin-stampede lever, and there is no rate-limit primitive on this path, so - // who used it is recorded every time. - log::warn!( - "Cache purge: ALL templates purged by {}", - authenticated_principal.unwrap_or("") - ); - services - .template_cache() - .purge_all() - .await - .map_err(|error| TrustedServerError::Configuration { - message: format!("cache purge failed: {error}"), - })?; - Ok(purge_ok(&PurgeResponse { - purged: true, - scope: "all", - surrogate_key: None, - // One surrogate-key call, so there is no partial state to reason about: - // an error means nothing was purged and the call is safe to retry. - message: "All templates purged. Purge is idempotent and safe to repeat.", - })) - } - PurgeRequest::Url(url) => { - // The reader-facing key, so callers pass the URL a reader would see and never - // have to replay this service's origin rewriting. - let surrogate_key = reader_url_surrogate_key(&url); - log::info!( - "Cache purge: url {url} (key {surrogate_key}) by {}", - authenticated_principal.unwrap_or("") - ); - services - .template_cache() - .purge_url_surrogate_key(&surrogate_key) - .await - .map_err(|error| TrustedServerError::Configuration { - message: format!("cache purge failed: {error}"), - })?; - Ok(purge_ok(&PurgeResponse { - purged: true, - scope: "url", - surrogate_key: Some(surrogate_key), - message: "URL purged. Purge is idempotent and safe to repeat.", - })) - } - } -} - -/// Exactly `application/json`, with optional parameters and whitespace. -/// -/// Deliberately strict: accepting `application/json`-ish media types would readmit the -/// form-post CSRF shape this check exists to close. -fn is_exactly_json(content_type: &str) -> bool { - content_type - .split(';') - .next() - .unwrap_or_default() - .trim() - .eq_ignore_ascii_case("application/json") -} - -fn method_not_allowed() -> Response { - let mut response = json_response( - StatusCode::METHOD_NOT_ALLOWED, - r#"{"purged":false,"error":"cache purge requires POST"}"#, - ); - response - .headers_mut() - .insert(header::ALLOW, header::HeaderValue::from_static("POST")); - response -} - -fn purge_ok(payload: &PurgeResponse) -> Response { - let body = serde_json::to_string(payload) - .unwrap_or_else(|_| r#"{"purged":true,"scope":"unknown"}"#.to_owned()); - json_response(StatusCode::OK, &body) -} - -/// Purge answers are per-operator and per-moment; nothing may store one. -fn json_response(status: StatusCode, body: &str) -> Response { - Response::builder() - .status(status) - .header(header::CONTENT_TYPE, "application/json") - .header(header::CACHE_CONTROL, "private, no-store") - .body(EdgeBody::from(body.to_owned())) - .expect("should build a cache-purge response") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn content_type_must_be_exactly_json() { - assert!(is_exactly_json("application/json")); - assert!(is_exactly_json("application/json; charset=utf-8")); - assert!(is_exactly_json(" APPLICATION/JSON ")); - } - - #[test] - fn form_postable_content_types_are_refused() { - // These three are the only types a cross-origin form can send, and a form POST - // carries basic-auth credentials without a preflight. - for content_type in [ - "text/plain", - "application/x-www-form-urlencoded", - "multipart/form-data", - "", - ] { - assert!( - !is_exactly_json(content_type), - "{content_type} must not be accepted" - ); - } - } - - #[test] - fn json_lookalike_types_are_refused() { - for content_type in [ - "application/jsonrequest", - "text/json", - "application/ld+json", - ] { - assert!( - !is_exactly_json(content_type), - "{content_type} must not be accepted" - ); - } - } - - #[test] - fn a_non_post_answer_names_the_method_it_accepts() { - let response = method_not_allowed(); - assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); - assert_eq!( - response - .headers() - .get(header::ALLOW) - .and_then(|value| value.to_str().ok()), - Some("POST") - ); - } - - #[test] - fn every_answer_forbids_storage() { - for response in [method_not_allowed(), json_response(StatusCode::OK, "{}")] { - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("private, no-store"), - "a purge answer must never be stored" - ); - } - } - - #[test] - fn scope_parsing_accepts_both_documented_shapes() { - assert_eq!( - PurgeRequest::parse(br#"{"scope":"all"}"#).expect("should parse"), - PurgeRequest::All - ); - assert_eq!( - PurgeRequest::parse(br#"{"scope":"url","url":"https://example.com/a"}"#) - .expect("should parse"), - PurgeRequest::Url("https://example.com/a".to_owned()) - ); - } - - #[test] - fn a_url_alongside_scope_all_is_refused_rather_than_flushing_everything() { - // The dangerous typo: an operator means to purge one page, mistypes the scope, and - // would otherwise be told a full flush succeeded. - let error = PurgeRequest::parse(br#"{"scope":"all","url":"https://example.com/a"}"#) - .expect_err("should refuse"); - assert!( - error.contains("takes no url"), - "the error must point at the confusion, got: {error}" - ); - } - - #[test] - fn an_unknown_or_malformed_scope_is_refused() { - // A typo must not silently widen into a full flush. - for body in [ - &br#"{"scope":"everything"}"#[..], - br#"{"scope":"url"}"#, - br#"{"scope":"url","url":" "}"#, - // Hashed as given, these would each be acknowledged as purged under a key - // that can never match a stored object. - br#"{"scope":"url","url":"/article"}"#, - br#"{"scope":"url","url":"example.com/article"}"#, - br#"{"scope":"url","url":"ftp://example.com/article"}"#, - br#"{"scope":"all","url":"https://example.com/a"}"#, - br#"{}"#, - br#"{"scope":"ALL"}"#, - br#"{"scope":"all","extra":1}"#, - br#"not json"#, - ] { - assert!( - PurgeRequest::parse(body).is_err(), - "{} must not parse", - String::from_utf8_lossy(body) - ); - } - } - - #[test] - fn a_reader_facing_url_still_parses() { - for body in [ - &br#"{"scope":"url","url":"https://ts.example.com/article"}"#[..], - br#"{"scope":"url","url":"http://ts.example.com/article?a=1"}"#, - br#"{"scope":"url","url":"https://ts.example.com"}"#, - ] { - assert!( - PurgeRequest::parse(body).is_ok(), - "{} is a URL a reader can address and must still purge", - String::from_utf8_lossy(body) - ); - } - } -} diff --git a/crates/trusted-server-core/src/closed_paths.rs b/crates/trusted-server-core/src/closed_paths.rs new file mode 100644 index 0000000000..bac9b398c2 --- /dev/null +++ b/crates/trusted-server-core/src/closed_paths.rs @@ -0,0 +1,256 @@ +//! Paths this server answers itself and never forwards to the publisher's origin. +//! +//! A request to `/_ts/admin`, or to the `/admin/keys` addresses beside it, is +//! an administration request. It can carry an operator's `Authorization` +//! header and a payload meant for this server alone. No route serves either +//! prefix, and forwarding such a request would hand both to the origin, so +//! every adapter asks [`closed_path_response`] before its publisher fallback +//! and the request is answered `404` here. + +use std::borrow::Cow; + +use edgezero_core::body::Body as EdgeBody; +use http::{Request, Response, StatusCode, header}; + +/// The server's administration prefix. +/// +/// It is matched as a bare prefix, with no `/` after it, so that a +/// percent-encoded separator such as `/_ts/admin%2Fec` is closed with the +/// paths it decodes to. A check for a literal slash would miss that spelling. +const ADMIN_NAMESPACE_PREFIX: &str = "/_ts/admin"; + +/// The key administration alias outside `/_ts`. +/// +/// Only the alias itself and its separator descendants are closed. A bare +/// prefix here would also close unrelated publisher paths such as +/// `/admin/keystore`. +const ADMIN_KEYS_PREFIX: &str = "/admin/keys"; + +/// Maximum percent-decoding rounds applied when testing a path. +/// +/// Bounds the work a `%25`-chained path can force while still reaching the +/// fixed point of any separator encoding a proxy chain would plausibly decode. +const MAX_PERCENT_DECODE_ROUNDS: usize = 4; + +/// Returns the local `404 Not Found` for a request to a closed path, and +/// `None` for any other request, which the publisher fallback then answers. +/// +/// The path is tested as sent and after each of a bounded number of +/// percent-decoding rounds, so `/_ts/admin%2Fec` and `/admin%252Fkeys/rotate` +/// are closed with the paths they decode to. +#[must_use] +pub fn closed_path_response(req: &Request) -> Option> { + is_closed(req.uri().path()).then(not_found) +} + +/// Returns whether `path` is closed either as sent or after any bounded number +/// of percent-decoding rounds. +/// +/// A separator encoded twice, as in `/admin%252Fkeys/rotate`, survives one +/// decode as `/admin%2Fkeys/rotate`, so the check is repeated to a fixed point +/// rather than applied once. +fn is_closed(path: &str) -> bool { + if is_closed_as_written(path) { + return true; + } + + let mut current = path.to_owned(); + for _ in 0..MAX_PERCENT_DECODE_ROUNDS { + let Some(decoded) = percent_decoded_path(¤t) else { + return false; + }; + + // A path whose remaining `%` sequences are not decodable escapes is a + // fixed point, so further rounds would repeat the same comparison. + if decoded == current { + return false; + } + + if is_closed_as_written(&decoded) { + return true; + } + + current = decoded; + } + + false +} + +fn is_closed_as_written(path: &str) -> bool { + path.starts_with(ADMIN_NAMESPACE_PREFIX) + || path == ADMIN_KEYS_PREFIX + || path + .strip_prefix(ADMIN_KEYS_PREFIX) + .is_some_and(|remainder| remainder.starts_with('/')) +} + +/// Percent-decodes `path` once, returning `None` when the path contains no +/// escape sequence or decodes to invalid UTF-8. +fn percent_decoded_path(path: &str) -> Option { + if !path.contains('%') { + return None; + } + + urlencoding::decode(path).ok().map(Cow::into_owned) +} + +fn not_found() -> Response { + Response::builder() + .status(StatusCode::NOT_FOUND) + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .header(header::CACHE_CONTROL, "no-store") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .body(EdgeBody::from(r#"{"error":"not found"}"#)) + .expect("should build the response for a closed path") +} + +#[cfg(test)] +mod tests { + use http::Method; + + use super::*; + + fn request(method: Method, path: &str) -> Request { + Request::builder() + .method(method) + .uri(format!("https://edge.example.com{path}")) + .body(EdgeBody::empty()) + .expect("should build test request") + } + + fn assert_closed(path: &str) { + for method in [ + Method::GET, + Method::POST, + Method::HEAD, + Method::OPTIONS, + Method::PUT, + Method::PATCH, + Method::DELETE, + ] { + let response = closed_path_response(&request(method.clone(), path)) + .unwrap_or_else(|| panic!("should answer {method} {path} here")); + + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "should answer {method} {path} with 404" + ); + assert_eq!( + response.headers().get(header::CACHE_CONTROL), + Some(&http::HeaderValue::from_static("no-store")), + "should prevent caching for {path}" + ); + } + } + + #[test] + fn every_path_beneath_the_administration_prefix_is_closed() { + let ec_id = format!("{}.abc123", "a".repeat(64)); + for path in [ + "/_ts/admin".to_owned(), + "/_ts/admin/".to_owned(), + "/_ts/admin/ec".to_owned(), + "/_ts/admin/ec/".to_owned(), + format!("/_ts/admin/ec/{ec_id}"), + format!("/_ts/admin/ec/{ec_id}/extra"), + "/_ts/admin/eids".to_owned(), + "/_ts/admin/eids.json".to_owned(), + "/_ts/admin/ec;foo".to_owned(), + "/_ts/admin/keys/rotate".to_owned(), + "/_ts/admin/cache/purge".to_owned(), + "/_ts/admin/unknown".to_owned(), + ] { + assert_closed(&path); + } + } + + #[test] + fn an_encoded_separator_does_not_open_a_closed_path() { + // A check for a literal slash would miss `/_ts/admin%2Fec`, and + // reaching the publisher fallback would forward the caller's + // `Authorization` header and body to the origin. + for path in [ + "/_ts/admin%2Fec", + "/_ts/admin%2fec", + "/_ts/admin%2Fkeys/rotate", + "/_ts/admin%252Fec", + "/_ts/admin%5Cec", + "/_ts/adminec", + "/%5Fts/admin/ec", + ] { + assert_closed(path); + } + } + + #[test] + fn the_key_alias_outside_ts_is_closed_with_its_descendants() { + for path in [ + "/admin/keys", + "/admin/keys/", + "/admin/keys/rotate", + "/admin/keys/deactivate", + "/admin/keys/rotate/", + "/admin/keys/rotate/extra", + "/admin/keys%2Frotate", + "/admin/keys%2frotate", + "/admin%2Fkeys/rotate", + "/admin%2fkeys%2Frotate", + ] { + assert_closed(path); + } + } + + #[test] + fn a_separator_encoded_more_than_once_is_still_closed() { + // One decode leaves `/admin%252Fkeys/rotate` as `/admin%2Fkeys/rotate`, + // which no literal check matches. Decoding to a fixed point keeps the + // path closed against a proxy or origin that decodes it more than once. + for path in [ + "/admin%252Fkeys/rotate", + "/admin/keys%252Frotate", + "/admin%25252Fkeys/rotate", + "/_ts%252Fadmin/ec", + ] { + assert_closed(path); + } + } + + #[test] + fn other_publisher_paths_are_left_to_the_fallback() { + for path in [ + "/", + "/articles/example", + "/admin", + "/admin/login", + "/admin/keyboards", + "/admin/keystore", + "/admin/keys%25store", + "/articles/100%25-organic", + "/_ts/api/v1/batch-sync", + "/_ts/data", + ] { + assert!( + closed_path_response(&request(Method::POST, path)).is_none(), + "should leave {path} to the publisher fallback" + ); + } + } + + #[test] + fn the_answer_says_nothing_but_not_found() { + let response = closed_path_response(&request(Method::GET, "/_ts/admin/ec")) + .expect("should answer a closed path"); + + assert_eq!( + response.headers().get(header::CONTENT_TYPE), + Some(&http::HeaderValue::from_static("application/json")) + ); + assert_eq!( + response.headers().get(header::X_CONTENT_TYPE_OPTIONS), + Some(&http::HeaderValue::from_static("nosniff")) + ); + let body = response.into_body().into_bytes().unwrap_or_default(); + assert_eq!(&body[..], br#"{"error":"not found"}"#); + } +} diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index ca34ca3568..32a2df53a7 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -264,14 +264,6 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { ], true, ), - field( - vec![ - object("handlers"), - SecretPathSegment::ArrayEach, - object("password"), - ], - false, - ), field( vec![ optional_object("trusted_client_ip"), @@ -362,7 +354,6 @@ pub fn validate_settings_for_deploy_with( let mut structural_settings = settings.clone(); structural_settings.prepare_runtime()?; - structural_settings.validate_admin_coverage()?; // The plan is compiled with the builders the blocks are validated // against, so a `[demand]` or `[ad-server]` name one of them supplies @@ -410,7 +401,6 @@ pub fn validate_settings_for_runtime_with( extra_integrations: &[IntegrationBuilder], ) -> Result<(), Report> { settings.reject_placeholder_secrets()?; - settings.validate_admin_handler_passwords()?; let plan = crate::auction::compile_auction_plan_with(settings, extra_integrations)?; validate_integration_blocks(settings, &plan, extra_integrations)?; PartnerRegistry::from_config(&settings.ec.partners).map(|_| ())?; @@ -455,14 +445,6 @@ fn validate_non_secret_deploy_placeholders( if crate::settings::Publisher::is_placeholder_origin_url(&settings.publisher.origin_url) { insecure_fields.push("publisher.origin_url"); } - if let Some(request_signing) = &settings.request_signing { - if crate::settings::RequestSigning::is_unusable_store_id(&request_signing.config_store_id) { - insecure_fields.push("request_signing.config_store_id"); - } - if crate::settings::RequestSigning::is_unusable_store_id(&request_signing.secret_store_id) { - insecure_fields.push("request_signing.secret_store_id"); - } - } if insecure_fields.is_empty() { return Ok(()); @@ -509,13 +491,6 @@ fn validate_secret_key_references( } } - for (index, handler) in settings.handlers.iter().enumerate() { - validate_secret_key_reference( - &format!("handlers[{index}].password"), - handler.password.expose(), - )?; - } - if let Some(trusted_client_ip) = &settings.trusted_client_ip { validate_secret_key_reference( "trusted_client_ip.shared_secret", @@ -872,8 +847,6 @@ formats = [{ width = 300, height = 250 }] let mut settings = valid_settings(); settings.publisher.proxy_secret = Redacted::new("publisher_proxy".to_owned()); select_hmac_module(&mut settings.ec, HMAC_MODULE_KEY, "ec_key"); - settings.handlers[0].password = Redacted::new("handler_password".to_owned()); - settings.handlers[1].password = Redacted::new("admin_password".to_owned()); let app_config = TrustedServerAppConfig::new(settings) .expect("should validate key names without values"); @@ -1036,7 +1009,6 @@ formats = [{ width = 300, height = 250 }] ("ec.host_signals.passphrase".to_owned(), true), ("ec.partners[*].api_token".to_owned(), true), ("ec.partners[*].ts_pull_token".to_owned(), true), - ("handlers[*].password".to_owned(), false), ("trusted_client_ip.shared_secret".to_owned(), false), ("tinybird.auction_token_secret".to_owned(), true), ("proxy.asset_routes[*].auth.access_key_id".to_owned(), true), @@ -1310,10 +1282,6 @@ module = "hmac" [ec.hmac] passphrase = "production-secret-key-32-bytes-min" -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "production-admin-password-32-bytes" "#, ) .expect("should parse placeholder settings before runtime validation"); @@ -1346,90 +1314,6 @@ password = "production-admin-password-32-bytes" ); } - #[test] - fn deploy_validation_rejects_placeholder_request_signing_store_ids() { - let mut settings = valid_settings(); - settings.request_signing = Some(crate::settings::RequestSigning { - enabled: true, - config_store_id: "".to_string(), - secret_store_id: "".to_string(), - }); - - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject placeholder request-signing store ids when enabled"); - let text = format!("{err:?}"); - - assert!( - text.contains("request_signing.config_store_id") - && text.contains("request_signing.secret_store_id"), - "should flag both request-signing store ids: {err:?}" - ); - } - - /// The rotate/deactivate admin routes are registered unconditionally and - /// read the store IDs without consulting `enabled`, so a disabled block with - /// placeholder IDs would still reach key management at runtime. - #[test] - fn deploy_validation_rejects_placeholder_store_ids_while_request_signing_is_disabled() { - let mut settings = valid_settings(); - settings.request_signing = Some(crate::settings::RequestSigning { - enabled: false, - config_store_id: "".to_string(), - secret_store_id: "".to_string(), - }); - - let err = validate_settings_for_deploy(&settings).expect_err( - "should reject placeholder store ids even while request signing is disabled", - ); - let text = format!("{err:?}"); - - assert!( - text.contains("request_signing.config_store_id") - && text.contains("request_signing.secret_store_id"), - "should flag both request-signing store ids: {err:?}" - ); - } - - #[test] - fn deploy_validation_rejects_empty_request_signing_store_ids() { - let mut settings = valid_settings(); - settings.request_signing = Some(crate::settings::RequestSigning { - enabled: true, - config_store_id: String::new(), - secret_store_id: " ".to_string(), - }); - - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject empty and whitespace-only store ids"); - let text = format!("{err:?}"); - - assert!( - text.contains("request_signing.config_store_id") - && text.contains("request_signing.secret_store_id"), - "should flag both request-signing store ids: {err:?}" - ); - } - - #[test] - fn deploy_validation_rejects_padded_request_signing_store_ids() { - let mut settings = valid_settings(); - settings.request_signing = Some(crate::settings::RequestSigning { - enabled: false, - config_store_id: " management-config-store ".to_string(), - secret_store_id: "management-secret-store ".to_string(), - }); - - let err = validate_settings_for_deploy(&settings) - .expect_err("should reject store ids with surrounding whitespace"); - let text = format!("{err:?}"); - - assert!( - text.contains("request_signing.config_store_id") - && text.contains("request_signing.secret_store_id"), - "should flag both padded store ids: {err:?}" - ); - } - /// A table written for a module its section does not select is refused /// when the settings load, so `ts config validate` reports it before the /// configuration reaches a deployment. diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 13b9654335..f43c3de7a3 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -563,8 +563,7 @@ mod tests { "should preserve numeric fields" ); assert_eq!( - reconstructed.handlers.len(), - original.handlers.len(), + reconstructed.proxy.allowed_domains, original.proxy.allowed_domains, "should preserve arrays" ); } @@ -1095,7 +1094,9 @@ mod tests { HMAC_MODULE_KEY, "12345678901234567890123456789012", ); - original.handlers[0].password = Redacted::new("true".to_string()); + original + .response_headers + .insert("x-example".to_string(), "true".to_string()); let reconstructed = load_settings(&envelope_json(&original)).expect("should reconstruct settings"); @@ -1111,9 +1112,9 @@ mod tests { "numeric-looking passphrase should remain a string" ); assert_eq!( - reconstructed.handlers[0].password.expose(), - original.handlers[0].password.expose(), - "boolean-looking handler password should remain a string" + reconstructed.response_headers.get("x-example"), + Some(&"true".to_string()), + "boolean-looking header value should remain a string" ); } diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs deleted file mode 100644 index ea6729c713..0000000000 --- a/crates/trusted-server-core/src/ec/admin.rs +++ /dev/null @@ -1,1700 +0,0 @@ -//! Admin endpoints for inspecting EC identity state. -//! -//! Serves `GET /_ts/admin/ec` (EC ID taken from the request's `ts-ec` -//! cookie) and `GET /_ts/admin/ec/{id}` (explicit EC ID). Returns the raw -//! stored [`KvEntry`] plus a derived view of the EIDs the auction would -//! attach, so operators can debug KV-to-auction propagation without KV -//! console access. -//! -//! Also serves `GET /_ts/admin/eids`, which echoes the request's `ts-eids` -//! and `sharedId` cookies with an ingestion preview — the client-side half -//! of EID propagation that is never stored server-side. -//! -//! Authentication is enforced by the `^/_ts/admin` basic-auth handler -//! configuration; startup validation rejects configs that leave these paths -//! uncovered (see `Settings::ADMIN_ENDPOINTS`). Because the endpoints are -//! auth-gated and operator-facing, responses intentionally include full -//! internal detail (raw consent strings, partner UIDs, parse errors). - -use std::borrow::Cow; -use std::collections::BTreeMap; - -use http::{HeaderValue, Method, Request, Response, StatusCode, header}; -use serde::Serialize; -use serde_json::Value as JsonValue; - -use edgezero_core::body::Body as EdgeBody; -use error_stack::{Report, ResultExt as _}; - -use crate::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; -use crate::cookies::extract_cookie_value; -use crate::error::TrustedServerError; -use crate::openrtb::Eid; - -use super::eids::{resolve_partner_ids, to_eids}; -use super::kv::KvIdentityGraph; -use super::kv_backend::EcKvLookup; -use super::kv_types::{KvEntry, KvMetadata}; -use super::log_id; -use super::module::{AcceptedModules, EdgeCookieModule}; -use super::prebid_eids::{ - analyze_prebid_eids_cookie, collect_sharedid_update, dedupe_partner_updates, is_valid_eid_uid, -}; -use super::registry::PartnerRegistry; - -/// Route prefix shared by the cookie-based and explicit-ID lookup routes. -const ADMIN_EC_PATH: &str = "/_ts/admin/ec"; - -/// Route used by the request-only EID cookie diagnostic. -const ADMIN_EIDS_PATH: &str = "/_ts/admin/eids"; - -/// Reserved Trusted Server admin prefix. -/// -/// Mirrors the documented `^/_ts/admin` basic-auth handler regex, so every -/// path that handler authenticates is also reserved at the fallback boundary. -/// Matching on the bare prefix — rather than on `/_ts/admin` plus a literal -/// `/` — also covers percent-encoded separators such as `/_ts/admin%2Fec`, -/// which the auth handler matches but a literal-slash check does not. -const ADMIN_NAMESPACE_PREFIX: &str = "/_ts/admin"; - -/// Retired non-`/_ts` admin key alias prefix. -/// -/// The exact `/admin/keys/rotate` and `/admin/keys/deactivate` aliases are -/// routed to a local deny by each adapter; the rest of the retired namespace -/// (trailing, descendant, and encoded-separator forms) is reserved here. -const RETIRED_ADMIN_KEYS_PREFIX: &str = "/admin/keys"; - -/// Maximum percent-decoding rounds applied when testing reserved namespaces. -/// -/// Bounds the work a `%25`-chained path can force while still reaching the -/// fixed point of any separator encoding a proxy chain would plausibly decode. -const MAX_PERCENT_DECODE_ROUNDS: usize = 4; - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum AdminDiagnosticShape { - ValidResource, - Malformed, -} - -fn admin_diagnostic_shape(path: &str) -> Option { - if path == ADMIN_EC_PATH || path == ADMIN_EIDS_PATH { - return Some(AdminDiagnosticShape::ValidResource); - } - - if let Some(remainder) = path.strip_prefix("/_ts/admin/ec/") { - return Some(if !remainder.is_empty() && !remainder.contains('/') { - AdminDiagnosticShape::ValidResource - } else { - AdminDiagnosticShape::Malformed - }); - } - - // Reserve the complete admin namespace at the publisher-fallback boundary. - // A successfully authenticated malformed or future admin path must never - // forward its Authorization header or body to the publisher origin. - reserves_admin_namespace(path).then_some(AdminDiagnosticShape::Malformed) -} - -/// Returns whether `path` reaches a reserved admin namespace either as sent or -/// after any bounded number of percent-decoding rounds. -/// -/// Multi-encoded separators such as `/admin%252Fkeys/rotate` survive a single -/// decode as `/admin%2Fkeys/rotate`, so the check is repeated to a fixed point -/// rather than applied once. -fn reserves_admin_namespace(path: &str) -> bool { - if is_reserved_admin_path(path) { - return true; - } - - // Normal publisher paths carry no escape sequence, so the decode loop — - // and its allocation — is skipped entirely for them. - if !path.contains('%') { - return false; - } - - let mut current = path.to_owned(); - for _ in 0..MAX_PERCENT_DECODE_ROUNDS { - let Some(decoded) = percent_decoded_path(¤t) else { - return false; - }; - - // A path whose remaining `%` sequences are not decodable escapes is a - // fixed point; further rounds would repeat the same comparison. - if decoded == current { - return false; - } - - if is_reserved_admin_path(&decoded) { - return true; - } - - current = decoded; - } - - false -} - -/// Returns whether `path` sits in a namespace that must never reach publisher -/// fallback, because doing so would forward Trusted Server admin credentials -/// and request bodies to the publisher origin. -fn is_reserved_admin_path(path: &str) -> bool { - // `/_ts/admin` is matched on the bare prefix because the unanchored - // `^/_ts/admin` auth regex authenticates those paths too. No auth handler - // matches the retired `/admin/keys` alias, so only the alias itself and its - // separator descendants are reserved — a bare prefix there would also deny - // unrelated publisher paths such as `/admin/keystore`. - path.starts_with(ADMIN_NAMESPACE_PREFIX) - || path == RETIRED_ADMIN_KEYS_PREFIX - || path - .strip_prefix(RETIRED_ADMIN_KEYS_PREFIX) - .is_some_and(|remainder| remainder.starts_with('/')) -} - -/// Percent-decodes `path` once, returning `None` when the path contains no -/// escape sequence or decodes to invalid UTF-8. -/// -/// Routers and the basic-auth matcher both operate on the raw path, so an -/// encoded separator can shift a request out of the literal admin namespace -/// while still matching the admin auth handler. See [`reserves_admin_namespace`] -/// for how the decoded forms are checked. -fn percent_decoded_path(path: &str) -> Option { - if !path.contains('%') { - return None; - } - - urlencoding::decode(path).ok().map(Cow::into_owned) -} - -/// Returns a local denial response when an admin diagnostic request reaches -/// an adapter's publisher fallback. -/// -/// Valid diagnostic resources reject non-GET methods with `405 Method Not -/// Allowed`. Malformed, trailing, unknown, and any valid GET admin route that -/// unexpectedly reaches fallback return `404 Not Found`. The reservation -/// spans the whole `/_ts/admin` prefix — including percent-encoded separators -/// such as `/_ts/admin%2Fec` — plus the retired `/admin/keys` alias namespace, -/// evaluated on the raw path and on each of its bounded percent-decodings. -/// Paths outside those namespaces return `None`, preserving normal fallback. -#[must_use] -pub fn deny_admin_diagnostic_fallback(req: &Request) -> Option> { - let shape = admin_diagnostic_shape(req.uri().path())?; - let mut response = - if shape == AdminDiagnosticShape::ValidResource && req.method() != Method::GET { - json_error(StatusCode::METHOD_NOT_ALLOWED, "method not allowed") - } else { - json_error(StatusCode::NOT_FOUND, "admin diagnostic route not found") - }; - - if response.status() == StatusCode::METHOD_NOT_ALLOWED { - response - .headers_mut() - .insert(header::ALLOW, HeaderValue::from_static("GET")); - } - - Some(response) -} - -/// Successful admin EC lookup payload. -#[derive(Debug, Serialize)] -struct AdminEcLookupResponse { - /// The EC ID as requested, from the path or the `ts-ec` cookie. - ec_id: String, - /// The identity-graph key the entry was read from, being the canonical form - /// of `ec_id` that [`AcceptedModules::canonical_kv_key`] returns. - kv_key: String, - /// Platform KV store name the entry was read from. - store: String, - /// Store generation marker for the entry. - generation: u64, - /// `true` when the entry is a consent-withdrawal tombstone - /// (`consent.ok = false`). Absent when the body failed to parse as JSON or - /// deserialize as a [`KvEntry`]. - #[serde(skip_serializing_if = "Option::is_none")] - tombstone: Option, - /// The stored entry, preserved as raw JSON except for derived - /// `created_iso` / `updated_iso` companions added next to the stored - /// unix-seconds timestamps for readability. Absent when the body - /// was not valid JSON (see `entry_error` / `raw_body`). - #[serde(skip_serializing_if = "Option::is_none")] - entry: Option, - /// JSON parsing, schema deserialization, or validation failure detail. - #[serde(skip_serializing_if = "Option::is_none")] - entry_error: Option, - /// Raw entry body (lossy UTF-8) when it was not valid JSON. - #[serde(skip_serializing_if = "Option::is_none")] - raw_body: Option, - /// The stored KV metadata JSON, when present and parseable. - #[serde(skip_serializing_if = "Option::is_none")] - metadata: Option, - /// JSON parsing or schema deserialization failure detail for metadata. - #[serde(skip_serializing_if = "Option::is_none")] - metadata_error: Option, - /// Derived auction view. Present only when the entry deserializes and - /// validates — the same precondition the auction read path applies, so - /// its absence means the auction would attach no KV-derived EIDs. - /// Live requests additionally gate on per-request consent, which is not - /// reproducible here. - #[serde(skip_serializing_if = "Option::is_none")] - auction: Option, -} - -/// What the auction EID decoration would produce for this entry. -#[derive(Debug, Serialize)] -struct AuctionEidsView { - /// EIDs the auction would attach to `user.eids`, exactly as produced by - /// the auction resolution path. - eids: Vec, - /// Stored partner IDs that the auction resolution filters out, with the - /// reason each was skipped. - skipped: Vec, -} - -/// A stored partner ID excluded from auction EIDs. -#[derive(Debug, Serialize)] -struct SkippedPartnerId { - /// Partner namespace key in the entry's `ids` map. - source_domain: String, - /// Why the auction resolution skips it: `empty_uid`, `not_in_registry`, - /// or `bidstream_disabled`. - reason: &'static str, -} - -/// Handles `GET /_ts/admin/ec` and `GET /_ts/admin/ec/{id}`. -/// -/// Resolves the EC ID from the path when present, falling back to the -/// request's `ts-ec` cookie for the bare route. Responds: -/// -/// - `200 OK` with an [`AdminEcLookupResponse`] JSON body when the key -/// exists (including corrupt entries, which are reported with -/// `entry_error` and `raw_body` instead of failing closed); -/// - `400 Bad Request` when the resolved ID is not a valid EC ID; -/// - `404 Not Found` when the key does not exist, or the bare route was -/// called without a `ts-ec` cookie; -/// - `501 Not Implemented` when no EC identity graph is configured. -/// -/// # Errors -/// -/// Returns [`TrustedServerError::KvStore`] when the store open or read -/// fails. -pub fn handle_admin_ec_lookup( - kv: Option<&KvIdentityGraph>, - registry: &PartnerRegistry, - module: Option<&dyn EdgeCookieModule>, - req: &Request, -) -> Result, Report> { - let Some(kv) = kv else { - return Ok(admin_ec_lookup_not_supported()); - }; - - let requested = match requested_ec_id(req, &AcceptedModules::active(module)) { - Ok(requested) => requested, - Err(response) => return Ok(*response), - }; - - // Read the row under the owning module's canonical form of the - // identifier, the key the row is stored under, rather than under the - // identifier as requested. The two differ whenever the canonical form is - // not the requested string itself, for example for a built-in HMAC - // identifier requested with its hash in uppercase. - let Some(lookup) = kv.lookup_raw(&requested.kv_key)? else { - log::info!( - "Admin EC lookup: no entry for '{}'", - log_id(&requested.ec_id) - ); - return Ok(json_error( - StatusCode::NOT_FOUND, - "EC entry not found (KV reads are eventually consistent; a very \ - recent entry may not be visible yet)", - )); - }; - - log::info!( - "Admin EC lookup: returning entry for '{}'", - log_id(&requested.ec_id) - ); - let payload = build_lookup_response(registry, kv.store_name(), requested, &lookup); - let body = - serde_json::to_string(&payload).change_context(TrustedServerError::Configuration { - message: "failed to serialize admin EC lookup response".to_owned(), - })?; - Ok(json_response(StatusCode::OK, body)) -} - -/// Returns the portable response used when an adapter has no EC KV backend. -#[must_use] -pub fn admin_ec_lookup_not_supported() -> Response { - json_error( - StatusCode::NOT_IMPLEMENTED, - "EC identity graph is not configured on this deployment", - ) -} - -/// Reads the request's `ts-ec` cookie through the same [`cookie::CookieJar`] -/// path the live EC lifecycle uses. -/// -/// The jar keeps the last of any duplicate `ts-ec` pairs; a first-match header -/// scan would let this diagnostic report a different EC record than the request -/// lifecycle — and the auction — actually read. -/// -/// Returns the (boxed) error response to send directly when no usable cookie -/// value is available. -fn cookie_ec_id(req: &Request) -> Result>> { - let parsed = super::parse_ec_from_request(req).map_err(|_| { - Box::new(json_error( - StatusCode::BAD_REQUEST, - "request Cookie header is not valid UTF-8", - )) - })?; - - parsed.cookie_ec.ok_or_else(|| { - Box::new(json_error( - StatusCode::NOT_FOUND, - "no EC ID in path and no ts-ec cookie on the request — pass \ - an explicit id: /_ts/admin/ec/{id}", - )) - }) -} - -/// An EC ID resolved for lookup, with the identity-graph key its row is stored -/// under. -#[derive(Debug)] -struct RequestedEcId { - /// The EC ID as requested, from the path or the `ts-ec` cookie. - ec_id: String, - /// The canonical form of `ec_id` that - /// [`AcceptedModules::canonical_kv_key`] returns, which the row is stored - /// under. - kv_key: String, -} - -/// Resolves the EC ID to look up from the path or the `ts-ec` cookie, with the -/// identity-graph key its row is stored under. -/// -/// The identifier is validated in two parts: the global cookie bounds, then -/// the module that owns its `{code}~` prefix, so an operator can look up an -/// identifier created by whichever module this deployment reads rather than -/// only a built-in HMAC one. The same check supplies the key, the canonical -/// form of the identifier that [`AcceptedModules::canonical_kv_key`] -/// returns. -/// -/// Returns the (boxed) error response to send directly when no valid ID is -/// available. -fn requested_ec_id( - req: &Request, - accepted_modules: &AcceptedModules<'_>, -) -> Result>> { - let remainder = req - .uri() - .path() - .strip_prefix(ADMIN_EC_PATH) - .unwrap_or("") - .trim_matches('/'); - - let ec_id = if remainder.is_empty() { - cookie_ec_id(req)? - } else { - remainder.to_owned() - }; - - let Some(kv_key) = accepted_modules.canonical_kv_key(&ec_id) else { - return Err(Box::new(json_error( - StatusCode::BAD_REQUEST, - "invalid EC ID: not an identifier any module this deployment reads \ - issued (the built-in HMAC module issues hmac~{64hex}.{6alnum} and \ - still reads the bare legacy form)", - ))); - }; - - Ok(RequestedEcId { ec_id, kv_key }) -} - -/// Builds the success payload from a raw KV lookup. -/// -/// Parse failures are reported in the payload rather than propagated, so -/// corrupt entries remain inspectable. -fn build_lookup_response( - registry: &PartnerRegistry, - store_name: &str, - requested: RequestedEcId, - lookup: &EcKvLookup, -) -> AdminEcLookupResponse { - let mut payload = AdminEcLookupResponse { - ec_id: requested.ec_id, - kv_key: requested.kv_key, - store: store_name.to_owned(), - generation: lookup.generation, - tombstone: None, - entry: None, - entry_error: None, - raw_body: None, - metadata: None, - metadata_error: None, - auction: None, - }; - - match serde_json::from_slice::(&lookup.body) { - Ok(mut entry_json) => { - add_iso_timestamp_companions(&mut entry_json); - payload.entry = Some(entry_json); - - match serde_json::from_slice::(&lookup.body) { - Ok(entry) => { - payload.tombstone = Some(!entry.consent.ok); - match entry.validate() { - Ok(()) => payload.auction = Some(build_auction_view(registry, &entry)), - Err(message) => { - payload.entry_error = Some(format!( - "entry failed validation (auction reads fail closed \ - and attach no EIDs): {message}" - )); - } - } - } - Err(error) => { - payload.entry_error = - Some(format!("failed to deserialize entry schema: {error}")); - } - } - } - Err(error) => { - payload.entry_error = Some(format!("failed to parse entry JSON: {error}")); - payload.raw_body = Some(String::from_utf8_lossy(&lookup.body).into_owned()); - } - } - - match &lookup.metadata { - None => {} - Some(bytes) => match serde_json::from_slice::(bytes) { - Ok(metadata_json) => { - payload.metadata = Some(metadata_json); - if let Err(error) = serde_json::from_slice::(bytes) { - payload.metadata_error = - Some(format!("failed to deserialize metadata schema: {error}")); - } - } - Err(error) => { - payload.metadata_error = Some(format!( - "failed to parse metadata JSON: {error} (raw: {})", - String::from_utf8_lossy(bytes) - )); - } - }, - } - - payload -} - -/// Adds derived ISO 8601 companions next to the -/// stored unix-seconds timestamps (`created_iso`, `consent.updated_iso`). -/// -/// Every stored value, including pre-existing ISO companions, stays untouched. -/// The derived fields exist purely for operator readability when absent. -fn add_iso_timestamp_companions(entry_json: &mut JsonValue) { - let created = entry_json.get("created").and_then(JsonValue::as_u64); - let updated = entry_json - .get("consent") - .and_then(|consent| consent.get("updated")) - .and_then(JsonValue::as_u64); - if let Some(object) = entry_json.as_object_mut() { - if let Some(iso) = created.and_then(iso_timestamp) { - object - .entry("created_iso".to_owned()) - .or_insert(JsonValue::String(iso)); - } - if let Some(consent) = object.get_mut("consent").and_then(JsonValue::as_object_mut) - && let Some(iso) = updated.and_then(iso_timestamp) - { - consent - .entry("updated_iso".to_owned()) - .or_insert(JsonValue::String(iso)); - } - } -} - -/// Formats a unix-seconds timestamp as ISO 8601 (`yyyy-MM-ddTHH:mm:ss.SSSZ`). -/// -/// Returns `None` for values outside the representable date range. -fn iso_timestamp(unix_seconds: u64) -> Option { - let unix_seconds = i64::try_from(unix_seconds).ok()?; - chrono::DateTime::from_timestamp(unix_seconds, 0) - .map(|datetime| datetime.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()) -} - -/// Derives the auction EID view for a valid entry, mirroring the filters in -/// [`resolve_partner_ids`] and reporting why each stored ID was skipped. -fn build_auction_view(registry: &PartnerRegistry, entry: &KvEntry) -> AuctionEidsView { - let resolved = resolve_partner_ids(registry, entry); - let eids = to_eids(&resolved); - - let mut skipped = Vec::new(); - for (source_domain, partner_uid) in &entry.ids { - let reason = if partner_uid.uid.is_empty() { - "empty_uid" - } else { - match registry.get(source_domain) { - None => "not_in_registry", - Some(partner) if !partner.bidstream_enabled => "bidstream_disabled", - Some(_) => continue, - } - }; - skipped.push(SkippedPartnerId { - source_domain: source_domain.clone(), - reason, - }); - } - - AuctionEidsView { eids, skipped } -} - -/// Admin EIDs echo payload. -#[derive(Debug, Serialize)] -struct AdminEidsResponse { - /// Whether a `ts-eids` cookie was present on the request. - cookie_present: bool, - /// EIDs parsed from the `ts-eids` cookie. Absent when the cookie is - /// missing or failed to parse. - #[serde(skip_serializing_if = "Option::is_none")] - eids: Option>, - /// Parse failure detail when the `ts-eids` cookie could not be decoded. - #[serde(skip_serializing_if = "Option::is_none")] - parse_error: Option, - /// Whether a `sharedId` cookie was present on the request. - sharedid_present: bool, - /// Number of partners configured in the registry. - partners_configured: usize, - /// Preview of what cookie ingestion would write into the EC entry's - /// `ids` map on a navigation carrying these cookies. - ingest: IngestPreview, -} - -/// What cookie ingestion would store, and what it would drop. -#[derive(Debug, Serialize)] -struct IngestPreview { - /// Cookie sources matched to a configured partner, with the UID that - /// would be stored (deduplicated exactly like the ingestion path). - matched: Vec, - /// `ts-eids` sources dropped on ingestion, with the reason. - unmatched: Vec, -} - -/// A cookie-derived partner UID that ingestion would store. -#[derive(Debug, Serialize)] -struct MatchedPartnerId { - /// Partner namespace key in the EC entry's `ids` map. - source_domain: String, - /// The UID that would be stored. - uid: String, -} - -#[derive(Debug, Serialize)] -struct DroppedEidSource { - /// EID source from the cookie. - source: String, - /// Why ingestion would drop the source. - reason: DroppedEidReason, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -enum DroppedEidReason { - NoPartner, - NoValidUid, -} - -/// Handles `GET /_ts/admin/eids`. -/// -/// Echoes the request's `ts-eids` and `sharedId` cookies: the parsed EID -/// list plus a preview of what cookie ingestion would write into the EC -/// entry's `ids` map given the configured partner registry. Pure request -/// inspection — no KV access — so it works on every adapter. -/// -/// Always responds `200 OK`; missing or malformed cookies are reported in -/// the payload instead of as errors. -/// -/// # Errors -/// -/// Returns [`TrustedServerError::Configuration`] only when the response -/// payload fails JSON serialization. -pub fn handle_admin_eids_lookup( - registry: &PartnerRegistry, - req: &Request, -) -> Result, Report> { - let eids_cookie = extract_cookie_value(req, COOKIE_TS_EIDS); - let sharedid_cookie = extract_cookie_value(req, COOKIE_SHAREDID); - - let (eids, parse_error, diagnostic_sources, mut updates) = match &eids_cookie { - None => (None, None, Vec::new(), Vec::new()), - Some(value) => match analyze_prebid_eids_cookie(value, registry) { - Ok(analysis) => ( - Some(analysis.eids), - None, - analysis.diagnostic_sources, - analysis.updates, - ), - Err(error) => ( - None, - Some(format!("failed to parse ts-eids cookie: {error}")), - Vec::new(), - Vec::new(), - ), - }, - }; - - // Collect matches from both cookies, then dedupe the same way as response - // finalization. The preview is cookie-only and ungated: it ignores the - // `/auction` request-body EIDs, the consent gate on identity-graph writes, - // and UIDs already stored in KV, so it lists candidate matches rather than - // exactly what a request would store. - if let Some(value) = &sharedid_cookie - && let Some(update) = collect_sharedid_update(value, registry) - { - updates.push(update); - } - let matched = dedupe_partner_updates(updates) - .into_iter() - .map(|update| MatchedPartnerId { - source_domain: update.partner_id, - uid: update.uid, - }) - .collect(); - - let mut source_has_valid_uid = BTreeMap::new(); - for diagnostic_source in diagnostic_sources { - let has_valid_uid = diagnostic_source - .uids - .iter() - .any(|uid| is_valid_eid_uid(uid)); - source_has_valid_uid - .entry(diagnostic_source.source) - .and_modify(|source_has_valid_uid| *source_has_valid_uid |= has_valid_uid) - .or_insert(has_valid_uid); - } - let unmatched = source_has_valid_uid - .into_iter() - .filter_map(|(source, has_valid_uid)| { - let reason = if registry.find_by_source_domain(&source).is_none() { - DroppedEidReason::NoPartner - } else if !has_valid_uid { - DroppedEidReason::NoValidUid - } else { - return None; - }; - Some(DroppedEidSource { source, reason }) - }) - .collect(); - - let payload = AdminEidsResponse { - cookie_present: eids_cookie.is_some(), - eids, - parse_error, - sharedid_present: sharedid_cookie.is_some(), - partners_configured: registry.len(), - ingest: IngestPreview { matched, unmatched }, - }; - - let body = - serde_json::to_string(&payload).change_context(TrustedServerError::Configuration { - message: "failed to serialize admin EIDs response".to_owned(), - })?; - Ok(json_response(StatusCode::OK, body)) -} - -fn json_error(status: StatusCode, message: &str) -> Response { - let body = serde_json::json!({ "error": message }); - json_response(status, body.to_string()) -} - -fn json_response(status: StatusCode, body: String) -> Response { - Response::builder() - .status(status) - .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) - .header(header::CACHE_CONTROL, "no-store") - .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") - .body(EdgeBody::from(body.into_bytes())) - .expect("should build admin EC lookup response") -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; - - use crate::ec::kv::TombstoneOutcome; - - use super::*; - use crate::ec::kv_backend::test_support::InMemoryEcKv; - use crate::ec::kv_backend::{EcKvStore as _, EcKvWrite, EcKvWriteMode}; - use crate::ec::kv_types::KvPartnerId; - use crate::ec::tests::{ - CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingModule, OpaqueModule, - }; - use crate::redacted::Redacted; - use crate::settings::EcPartner; - - fn test_ec_id() -> String { - format!("{}.abc123", "a".repeat(64)) - } - - fn make_test_partner(source_domain: &str, bidstream_enabled: bool) -> EcPartner { - EcPartner { - name: format!("Partner {source_domain}"), - source_domain: source_domain.to_owned(), - openrtb_atype: EcPartner::default_openrtb_atype(), - bidstream_enabled, - api_token: Some(Redacted::new(format!("test-token-{source_domain:-<32}"))), - batch_rate_limit: EcPartner::default_batch_rate_limit(), - pull_sync_enabled: false, - pull_sync_url: None, - pull_sync_allowed_domains: vec![], - pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), - pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), - ts_pull_token: None, - } - } - - fn test_registry() -> PartnerRegistry { - PartnerRegistry::from_config(&[ - make_test_partner("bidstream.example", true), - make_test_partner("disabled.example", false), - ]) - .expect("should build test partner registry") - } - - fn get_request(path: &str) -> Request { - Request::builder() - .method("GET") - .uri(format!("https://edge.example.com{path}")) - .body(EdgeBody::empty()) - .expect("should build test request") - } - - fn get_request_with_cookie(path: &str, cookie: &str) -> Request { - Request::builder() - .method("GET") - .uri(format!("https://edge.example.com{path}")) - .header(header::COOKIE, cookie) - .body(EdgeBody::empty()) - .expect("should build test request") - } - - fn request_with_method(method: http::Method, path: &str) -> Request { - Request::builder() - .method(method) - .uri(format!("https://edge.example.com{path}")) - .body(EdgeBody::empty()) - .expect("should build test request") - } - - fn kv_with_entry(ec_id: &str, entry: &KvEntry) -> KvIdentityGraph { - let kv = KvIdentityGraph::in_memory("test-store"); - kv.create(ec_id, entry).expect("should seed KV entry"); - kv - } - - fn kv_with_raw_body(ec_id: &str, body: &str) -> KvIdentityGraph { - let metadata = serde_json::json!({ "ok": true, "country": "US", "v": 1 }).to_string(); - kv_with_raw_body_and_metadata(ec_id, body, &metadata) - } - - fn kv_with_raw_body_and_metadata(ec_id: &str, body: &str, metadata: &str) -> KvIdentityGraph { - let store = InMemoryEcKv::new("test-store"); - store - .insert( - ec_id, - EcKvWrite { - body, - metadata, - ttl: Duration::from_secs(60), - mode: EcKvWriteMode::Add, - }, - ) - .expect("should seed raw KV body"); - KvIdentityGraph::new(store) - } - - fn response_json(response: Response) -> JsonValue { - serde_json::from_slice(&response.into_body().into_bytes().unwrap_or_default()) - .expect("should parse response body as JSON") - } - - fn sample_entry() -> KvEntry { - let mut entry = KvEntry::minimal("bidstream.example", "uid-live", 1_741_824_000); - entry.ids.insert( - "disabled.example".to_owned(), - KvPartnerId { - uid: "uid-disabled".to_owned(), - }, - ); - entry.ids.insert( - "unknown.example".to_owned(), - KvPartnerId { - uid: "uid-unknown".to_owned(), - }, - ); - entry - } - - #[test] - fn admin_diagnostic_fallback_rejects_wrong_methods_locally() { - let ec_id = test_ec_id(); - let paths = [ - "/_ts/admin/ec".to_owned(), - format!("/_ts/admin/ec/{ec_id}"), - "/_ts/admin/eids".to_owned(), - ]; - let methods = [ - http::Method::POST, - http::Method::HEAD, - http::Method::OPTIONS, - http::Method::PUT, - http::Method::PATCH, - http::Method::DELETE, - ]; - - for path in paths { - for method in &methods { - let request = request_with_method(method.clone(), &path); - let response = deny_admin_diagnostic_fallback(&request) - .unwrap_or_else(|| panic!("should deny {method} {path} locally")); - - assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); - assert_eq!( - response.headers().get(header::ALLOW), - Some(&http::HeaderValue::from_static("GET")), - "should advertise GET for {path}" - ); - assert_eq!( - response.headers().get(header::CACHE_CONTROL), - Some(&http::HeaderValue::from_static("no-store")), - "should prevent caching for {path}" - ); - } - } - } - - #[test] - fn admin_diagnostic_fallback_rejects_malformed_paths_locally() { - let ec_id = test_ec_id(); - let paths = [ - "/_ts/admin/ec/".to_owned(), - format!("/_ts/admin/ec/{ec_id}/extra"), - "/_ts/admin/eids/".to_owned(), - "/_ts/admin/eids/extra".to_owned(), - "/_ts/admin/eids.json".to_owned(), - "/_ts/admin/ec;foo".to_owned(), - format!("/_ts/admin/ec%2F{ec_id}"), - "/_ts/admin/unknown".to_owned(), - ]; - - for path in paths { - for method in [http::Method::GET, http::Method::POST] { - let request = request_with_method(method.clone(), &path); - let response = deny_admin_diagnostic_fallback(&request) - .unwrap_or_else(|| panic!("should deny {method} {path} locally")); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!( - response.headers().get(header::CACHE_CONTROL), - Some(&http::HeaderValue::from_static("no-store")), - "should prevent caching for {path}" - ); - } - } - } - - #[test] - fn admin_diagnostic_fallback_reserves_encoded_admin_separators() { - // `/_ts/admin%2Fec` matches the documented `^/_ts/admin` basic-auth - // handler, so it is authenticated, but a literal-slash namespace check - // misses it. Reaching publisher fallback would forward the caller's - // `Authorization` header and body to the origin. - let paths = [ - "/_ts/admin%2Fec", - "/_ts/admin%2fec", - "/_ts/admin%2Fkeys/rotate", - "/_ts/admin%252Fec", - "/_ts/admin%5Cec", - "/_ts/adminec", - "/%5Fts/admin/ec", - ]; - - for path in paths { - for method in [http::Method::GET, http::Method::POST] { - let request = request_with_method(method.clone(), path); - let response = deny_admin_diagnostic_fallback(&request) - .unwrap_or_else(|| panic!("should deny {method} {path} locally")); - - assert_eq!( - response.status(), - StatusCode::NOT_FOUND, - "should deny {path} before publisher fallback" - ); - } - } - } - - #[test] - fn admin_diagnostic_fallback_reserves_retired_admin_keys_namespace() { - // The retired non-`/_ts` aliases are not covered by the `^/_ts/admin` - // basic-auth handler. Only the two exact paths are routed to a local - // deny, so trailing, descendant, and encoded-separator forms must be - // denied at the shared fallback boundary instead. - let paths = [ - "/admin/keys", - "/admin/keys/", - "/admin/keys/rotate/", - "/admin/keys/rotate/extra", - "/admin/keys%2Frotate", - "/admin/keys%2frotate", - "/admin%2Fkeys/rotate", - "/admin%2fkeys%2Frotate", - ]; - - for path in paths { - for method in [http::Method::GET, http::Method::POST] { - let request = request_with_method(method.clone(), path); - let response = deny_admin_diagnostic_fallback(&request) - .unwrap_or_else(|| panic!("should deny {method} {path} locally")); - - assert_eq!( - response.status(), - StatusCode::NOT_FOUND, - "should deny {path} before publisher fallback" - ); - } - } - } - - #[test] - fn admin_diagnostic_fallback_reserves_multi_encoded_separators() { - // A single decode leaves `/admin%252Fkeys/rotate` as - // `/admin%2Fkeys/rotate`, which no literal check matches. Decoding to a - // fixed point keeps the reservation closed against a proxy or origin - // that decodes the path more than once. - let paths = [ - "/admin%252Fkeys/rotate", - "/admin/keys%252Frotate", - "/admin%25252Fkeys/rotate", - "/_ts%252Fadmin/ec", - ]; - - for path in paths { - for method in [http::Method::GET, http::Method::POST] { - let request = request_with_method(method.clone(), path); - let response = deny_admin_diagnostic_fallback(&request) - .unwrap_or_else(|| panic!("should deny {method} {path} locally")); - - assert_eq!( - response.status(), - StatusCode::NOT_FOUND, - "should deny {path} before publisher fallback" - ); - } - } - } - - #[test] - fn admin_diagnostic_fallback_ignores_unrelated_publisher_paths() { - for path in [ - "/articles/example", - "/admin", - "/admin/login", - "/admin/keyboards", - "/admin/keystore", - "/admin/keys%25store", - "/articles/100%25-organic", - "/_ts/api/v1/batch-sync", - ] { - let request = request_with_method(http::Method::POST, path); - - assert!( - deny_admin_diagnostic_fallback(&request).is_none(), - "should leave unrelated publisher fallback unchanged for {path}" - ); - } - } - - #[test] - fn returns_entry_with_auction_view() { - let ec_id = test_ec_id(); - let kv = kv_with_entry(&ec_id, &sample_entry()); - let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()), - Some("no-store"), - "should send no-store on admin responses" - ); - - let json = response_json(response); - assert_eq!(json["ec_id"], ec_id.as_str()); - assert_eq!(json["store"], "test-store"); - assert_eq!(json["tombstone"], false); - assert_eq!( - json["entry"]["ids"]["bidstream.example"]["uid"], "uid-live", - "should echo the stored entry verbatim" - ); - assert_eq!( - json["entry"]["created"], 1_741_824_000_u64, - "should keep the stored unix-seconds timestamp" - ); - assert_eq!( - json["entry"]["created_iso"], "2025-03-13T00:00:00.000Z", - "should add an ISO 8601 companion for created" - ); - assert_eq!( - json["entry"]["consent"]["updated_iso"], "2025-03-13T00:00:00.000Z", - "should add an ISO 8601 companion for consent.updated" - ); - - let eids = json["auction"]["eids"] - .as_array() - .expect("should have auction eids"); - assert_eq!(eids.len(), 1, "should resolve only the bidstream partner"); - assert_eq!(eids[0]["source"], "bidstream.example"); - assert_eq!(eids[0]["uids"][0]["id"], "uid-live"); - - let skipped = json["auction"]["skipped"] - .as_array() - .expect("should have skipped list"); - assert_eq!(skipped.len(), 2, "should report both filtered partners"); - assert!( - skipped - .iter() - .any(|s| s["source_domain"] == "disabled.example" - && s["reason"] == "bidstream_disabled"), - "should report the bidstream-disabled partner" - ); - assert!( - skipped.iter().any( - |s| s["source_domain"] == "unknown.example" && s["reason"] == "not_in_registry" - ), - "should report the unregistered partner" - ); - } - - #[test] - fn preserves_raw_entry_and_metadata_shapes() { - let ec_id = test_ec_id(); - let body = serde_json::json!({ - "v": 1, - "created": 1_741_824_000_u64, - "created_iso": "stored-created-iso", - "future_top_level": { "enabled": true }, - "consent": { - "ok": true, - "updated": 1_741_824_000_u64, - "updated_iso": "stored-updated-iso", - "future_consent": "preserve-me" - }, - "geo": { "country": "US" }, - "pub_properties": { - "origin_domain": "example.com", - "seen_domains": { - "example.com": { "first": 1000, "last": 1200, "visits": 3 } - } - }, - "ids": { - "bidstream.example": { "uid": "uid-live", "synced": 1100 } - } - }) - .to_string(); - let metadata = serde_json::json!({ - "ok": true, - "country": "US", - "v": 1, - "future_metadata": { "source": "edge" } - }) - .to_string(); - let kv = kv_with_raw_body_and_metadata(&ec_id, &body, &metadata); - let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - let json = response_json(response); - - assert_eq!(json["entry"]["future_top_level"]["enabled"], true); - assert_eq!(json["entry"]["consent"]["future_consent"], "preserve-me"); - assert_eq!(json["entry"]["ids"]["bidstream.example"]["synced"], 1100); - assert!( - json["entry"]["pub_properties"]["seen_domains"].is_object(), - "legacy map-shaped seen_domains should remain unchanged" - ); - assert_eq!(json["entry"]["created_iso"], "stored-created-iso"); - assert_eq!( - json["entry"]["consent"]["updated_iso"], - "stored-updated-iso" - ); - assert_eq!(json["metadata"]["future_metadata"]["source"], "edge"); - assert_eq!(json["auction"]["eids"][0]["source"], "bidstream.example"); - } - - #[test] - fn valid_json_with_invalid_entry_schema_remains_visible() { - let ec_id = test_ec_id(); - let body = serde_json::json!({ "future": "value" }).to_string(); - let kv = kv_with_raw_body(&ec_id, &body); - let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - let json = response_json(response); - - assert_eq!(json["entry"]["future"], "value"); - assert!( - json["entry_error"] - .as_str() - .expect("should have entry_error") - .contains("failed to deserialize entry schema") - ); - assert!(json.get("raw_body").is_none()); - assert!(json.get("auction").is_none()); - } - - #[test] - fn reports_tombstone_entries() { - let ec_id = test_ec_id(); - // Only an identity the store already holds can be tombstoned, so seed - // the live entry the withdrawal replaces. - let kv = kv_with_entry( - &ec_id, - &KvEntry::minimal("bidstream.example", "uid-live", 1_741_824_000), - ); - assert_eq!( - kv.write_withdrawal_tombstone(&ec_id, drop) - .expect("should write tombstone"), - TombstoneOutcome::Written, - "should tombstone the seeded identity" - ); - let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - - assert_eq!(response.status(), StatusCode::OK); - let json = response_json(response); - assert_eq!(json["tombstone"], true, "should flag tombstone entries"); - assert!( - json["auction"]["eids"] - .as_array() - .expect("should have auction eids") - .is_empty(), - "tombstone should resolve no EIDs" - ); - } - - #[test] - fn missing_entry_returns_404() { - let kv = KvIdentityGraph::in_memory("test-store"); - let req = get_request(&format!("/_ts/admin/ec/{}", test_ec_id())); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[test] - fn invalid_id_returns_400() { - let kv = KvIdentityGraph::in_memory("test-store"); - let req = get_request("/_ts/admin/ec/not-a-valid-id"); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - #[test] - fn corrupt_entry_returns_parse_error_and_raw_body() { - let ec_id = test_ec_id(); - let kv = kv_with_raw_body(&ec_id, "not json at all"); - let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - - assert_eq!( - response.status(), - StatusCode::OK, - "corrupt entries should be inspectable, not opaque errors" - ); - let json = response_json(response); - assert!( - json["entry_error"] - .as_str() - .expect("should have entry_error") - .contains("failed to parse entry JSON"), - "should describe the parse failure" - ); - assert_eq!(json["raw_body"], "not json at all"); - assert!(json.get("entry").is_none(), "should omit unparsed entry"); - assert!( - json.get("auction").is_none(), - "should omit auction view for unparseable entries" - ); - assert_eq!( - json["metadata"]["country"], "US", - "should still parse the stored metadata" - ); - } - - #[test] - fn invalid_schema_version_reports_validation_error() { - let ec_id = test_ec_id(); - let body = serde_json::json!({ - "v": 99, - "created": 1000, - "consent": { "ok": true, "updated": 1000 }, - "geo": { "country": "US" } - }) - .to_string(); - let kv = kv_with_raw_body(&ec_id, &body); - let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - - assert_eq!(response.status(), StatusCode::OK); - let json = response_json(response); - assert!( - json["entry_error"] - .as_str() - .expect("should have entry_error") - .contains("failed validation"), - "should describe the validation failure" - ); - assert_eq!(json["entry"]["v"], 99, "should still show the parsed entry"); - assert!( - json.get("auction").is_none(), - "should omit auction view when the auction read would fail closed" - ); - } - - #[test] - fn bare_route_uses_ts_ec_cookie() { - let ec_id = test_ec_id(); - let kv = kv_with_entry(&ec_id, &sample_entry()); - let req = get_request_with_cookie("/_ts/admin/ec", &format!("other=1; ts-ec={ec_id}; x=2")); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - - assert_eq!(response.status(), StatusCode::OK); - let json = response_json(response); - assert_eq!( - json["ec_id"], - ec_id.as_str(), - "should resolve the EC ID from the ts-ec cookie" - ); - } - - #[test] - fn bare_route_uses_last_duplicate_ts_ec_cookie() { - // `CookieJar` — the parser the live EC lifecycle uses — keeps the last - // duplicate pair. The diagnostic must inspect that same record, not the - // first one a header scan would find. - let stale_ec_id = format!("{}.stale1", "b".repeat(64)); - let live_ec_id = test_ec_id(); - let kv = kv_with_entry(&live_ec_id, &sample_entry()); - let req = get_request_with_cookie( - "/_ts/admin/ec", - &format!("ts-ec={stale_ec_id}; ts-ec={live_ec_id}"), - ); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - - assert_eq!(response.status(), StatusCode::OK); - let json = response_json(response); - assert_eq!( - json["ec_id"], - live_ec_id.as_str(), - "should resolve the same duplicate ts-ec cookie the EC lifecycle reads" - ); - } - - #[test] - fn bare_route_without_cookie_returns_404() { - let kv = KvIdentityGraph::in_memory("test-store"); - let req = get_request("/_ts/admin/ec"); - - let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req) - .expect("should handle lookup"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - let json = response_json(response); - assert!( - json["error"] - .as_str() - .expect("should have error message") - .contains("ts-ec cookie"), - "should explain the missing cookie" - ); - } - - #[test] - fn missing_identity_graph_returns_501() { - let req = get_request(&format!("/_ts/admin/ec/{}", test_ec_id())); - - let response = handle_admin_ec_lookup(None, &test_registry(), None, &req) - .expect("should handle lookup"); - - assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - } - - #[test] - fn unsupported_ec_lookup_response_is_json_and_no_store() { - let response = admin_ec_lookup_not_supported(); - - assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&HeaderValue::from_static("application/json")) - ); - assert_eq!( - response.headers().get(header::CACHE_CONTROL), - Some(&HeaderValue::from_static("no-store")) - ); - assert_eq!( - response.headers().get(header::X_CONTENT_TYPE_OPTIONS), - Some(&HeaderValue::from_static("nosniff")) - ); - assert!( - response_json(response)["error"] - .as_str() - .is_some_and(|message| message.contains("not configured")) - ); - } - - #[test] - fn kv_read_failure_propagates() { - let kv = KvIdentityGraph::failing("broken-store"); - let req = get_request(&format!("/_ts/admin/ec/{}", test_ec_id())); - - let result = handle_admin_ec_lookup(Some(&kv), &test_registry(), None, &req); - - assert!(result.is_err(), "should propagate KV read failures"); - } - - fn eids_cookie_for(entries: &serde_json::Value) -> String { - BASE64.encode(entries.to_string()) - } - - #[test] - fn eids_lookup_without_cookies_returns_empty_payload() { - let req = get_request("/_ts/admin/eids"); - - let response = - handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::X_CONTENT_TYPE_OPTIONS), - Some(&HeaderValue::from_static("nosniff")) - ); - let json = response_json(response); - assert_eq!(json["cookie_present"], false); - assert_eq!(json["sharedid_present"], false); - assert_eq!(json["partners_configured"], 2); - assert!( - json["ingest"]["matched"] - .as_array() - .expect("should have matched list") - .is_empty(), - "should preview no matches without cookies" - ); - } - - #[test] - fn eids_lookup_parses_cookie_and_previews_ingestion() { - let cookie = eids_cookie_for(&serde_json::json!([ - { - "source": "bidstream.example", - "uids": [{ "id": "uid-configured", "atype": 1 }] - }, - { - "source": "unknown.example", - "uids": [{ "id": "uid-unknown", "atype": 1 }] - } - ])); - let req = get_request_with_cookie("/_ts/admin/eids", &format!("ts-eids={cookie}")); - - let response = - handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); - - assert_eq!(response.status(), StatusCode::OK); - let json = response_json(response); - assert_eq!(json["cookie_present"], true); - assert_eq!( - json["eids"] - .as_array() - .expect("should have parsed eids") - .len(), - 2, - "should echo both parsed EID sources" - ); - - let matched = json["ingest"]["matched"] - .as_array() - .expect("should have matched list"); - assert_eq!(matched.len(), 1, "should match only the configured partner"); - assert_eq!(matched[0]["source_domain"], "bidstream.example"); - assert_eq!(matched[0]["uid"], "uid-configured"); - - let unmatched = json["ingest"]["unmatched"] - .as_array() - .expect("should have unmatched list"); - assert_eq!(unmatched.len(), 1, "should report the unregistered source"); - assert_eq!(unmatched[0]["source"], "unknown.example"); - assert_eq!(unmatched[0]["reason"], "no_partner"); - } - - #[test] - fn eids_lookup_reports_configured_source_without_valid_uid() { - let oversized_uid = "x".repeat(513); - let cookie = eids_cookie_for(&serde_json::json!([{ - "source": "bidstream.example", - "uids": [ - { "id": "", "atype": 1 }, - { "id": " ", "atype": 1 }, - { "id": oversized_uid, "atype": 1 } - ] - }])); - let req = get_request_with_cookie("/_ts/admin/eids", &format!("ts-eids={cookie}")); - - let response = - handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); - let json = response_json(response); - - assert!( - json["ingest"]["matched"] - .as_array() - .expect("should have matched list") - .is_empty(), - "invalid UIDs should not be matched" - ); - let unmatched = json["ingest"]["unmatched"] - .as_array() - .expect("should have unmatched list"); - assert_eq!(unmatched.len(), 1, "should report one dropped source"); - assert_eq!(unmatched[0]["source"], "bidstream.example"); - assert_eq!(unmatched[0]["reason"], "no_valid_uid"); - } - - #[test] - fn eids_lookup_does_not_drop_duplicate_source_with_valid_uid() { - let cookie = eids_cookie_for(&serde_json::json!([ - { - "source": "bidstream.example", - "uids": [{ "id": " ", "atype": 1 }] - }, - { - "source": "bidstream.example", - "uids": [{ "id": "uid-valid", "atype": 1 }] - } - ])); - let req = get_request_with_cookie("/_ts/admin/eids", &format!("ts-eids={cookie}")); - - let response = - handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); - let json = response_json(response); - - let matched = json["ingest"]["matched"] - .as_array() - .expect("should have matched list"); - assert_eq!(matched.len(), 1, "should match the valid duplicate source"); - assert_eq!(matched[0]["uid"], "uid-valid"); - assert!( - json["ingest"]["unmatched"] - .as_array() - .expect("should have unmatched list") - .is_empty(), - "a valid duplicate should suppress no_valid_uid" - ); - } - - #[test] - fn eids_lookup_reports_parse_error() { - let req = get_request_with_cookie("/_ts/admin/eids", "ts-eids=!!!not-base64!!!"); - - let response = - handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); - - assert_eq!( - response.status(), - StatusCode::OK, - "malformed cookies should be reported, not errored" - ); - let json = response_json(response); - assert_eq!(json["cookie_present"], true); - assert!( - json["parse_error"] - .as_str() - .expect("should have parse_error") - .contains("ts-eids"), - "should describe the parse failure" - ); - assert!(json.get("eids").is_none(), "should omit unparsed eids"); - assert!( - json["ingest"]["matched"] - .as_array() - .expect("should have matched list") - .is_empty(), - "unparseable cookie should preview no matches" - ); - } - - #[test] - fn eids_lookup_includes_sharedid_match() { - let registry = PartnerRegistry::from_config(&[ - make_test_partner("bidstream.example", true), - make_test_partner("sharedid.org", true), - ]) - .expect("should build sharedid test registry"); - let req = get_request_with_cookie("/_ts/admin/eids", "sharedId=shared-uid-123"); - - let response = - handle_admin_eids_lookup(®istry, &req).expect("should handle eids lookup"); - - assert_eq!(response.status(), StatusCode::OK); - let json = response_json(response); - assert_eq!(json["cookie_present"], false); - assert_eq!(json["sharedid_present"], true); - - let matched = json["ingest"]["matched"] - .as_array() - .expect("should have matched list"); - assert_eq!(matched.len(), 1, "should match the sharedid partner"); - assert_eq!(matched[0]["source_domain"], "sharedid.org"); - assert_eq!(matched[0]["uid"], "shared-uid-123"); - } - - #[test] - fn requested_ec_id_accepts_the_hmac_envelope() { - let coded = format!("hmac~{}", test_ec_id()); - let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{coded}")); - - let requested = requested_ec_id(&request, &AcceptedModules::active(None)) - .unwrap_or_else(|_| panic!("should accept a coded HMAC identifier in the path")); - - assert_eq!( - requested.ec_id, coded, - "should report the identifier as given" - ); - assert_eq!( - requested.kv_key, coded, - "a lowercase HMAC identifier should be its own identity-graph key" - ); - } - - #[test] - fn requested_ec_id_accepts_the_active_non_hmac_module_and_rejects_others() { - // The diagnostic must be usable on a deployment whose module is not - // the built-in HMAC one. Before the dispatch every non-`hmac` code was - // a 400, so an operator could not look up the identifier in the very - // cookie the browser was carrying. - let accepted = AcceptedModules::active(Some(&OpaqueModule)); - - let opaque = "t0op~Opaque_Value_MixedCase"; - let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{opaque}")); - let requested = requested_ec_id(&request, &accepted) - .unwrap_or_else(|_| panic!("should accept the active module's identifier")); - assert_eq!( - requested.ec_id, opaque, - "should report the identifier as given" - ); - - // A code no configured module reads stays a 400, even in the built-in - // HMAC shape, so one deployment cannot inspect another's identifiers. - let foreign = format!("t0zz~{}", test_ec_id()); - let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{foreign}")); - let response = requested_ec_id(&request, &accepted) - .expect_err("an unread module code should be rejected"); - assert_eq!( - response.status(), - StatusCode::BAD_REQUEST, - "an unread module code should be a 400" - ); - } - - #[test] - fn ec_lookup_reads_the_row_under_the_canonical_key() { - // The identity graph stores a row under the owning module's - // canonical form of the identifier. Read under the identifier as - // requested, the lookup answered 404 for a row that exists whenever a - // module's canonical form differs from the cookie value. - let kv = kv_with_entry(CANONICAL_KV_KEY, &sample_entry()); - let req = - get_request_with_cookie("/_ts/admin/ec", &format!("ts-ec={CANONICAL_COOKIE_VALUE}")); - - let response = handle_admin_ec_lookup( - Some(&kv), - &test_registry(), - Some(&CanonicalizingModule), - &req, - ) - .expect("should handle lookup"); - - assert_eq!( - response.status(), - StatusCode::OK, - "the cookie value should find the row stored under the canonical key" - ); - let json = response_json(response); - assert_eq!( - json["ec_id"], CANONICAL_COOKIE_VALUE, - "should report the identifier as requested" - ); - assert_eq!( - json["kv_key"], CANONICAL_KV_KEY, - "should report the key the entry was read from" - ); - assert_eq!( - json["entry"]["ids"]["bidstream.example"]["uid"], "uid-live", - "should return the entry stored under the canonical key" - ); - } - - #[test] - fn ec_lookup_given_an_uppercase_hmac_hash_reads_the_lowercase_row() { - // The built-in HMAC module issues lowercase hex, and its canonical - // form lowercases the hash, so its row key is the identifier it issued. - // An operator who pastes that identifier with the hash in uppercase is - // still asking for the same row. - let ec_id = format!("hmac~{}", test_ec_id()); - let kv = kv_with_entry(&ec_id, &sample_entry()); - let uppercase = format!("hmac~{}.abc123", "A".repeat(64)); - let module = crate::ec::tests::hmac_module(); - let req = get_request(&format!("/_ts/admin/ec/{uppercase}")); - - let response = - handle_admin_ec_lookup(Some(&kv), &test_registry(), Some(module.as_ref()), &req) - .expect("should handle lookup"); - - assert_eq!( - response.status(), - StatusCode::OK, - "an uppercase hash should find the row stored under the lowercase key" - ); - let json = response_json(response); - assert_eq!( - json["ec_id"], - uppercase.as_str(), - "should report the identifier as requested" - ); - assert_eq!( - json["kv_key"], - ec_id.as_str(), - "should report the lowercase key the entry was read from" - ); - } -} diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 7fb583b305..41b5db0c57 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -198,7 +198,7 @@ pub fn is_valid_ec_hash(value: &str) -> bool { /// not an HMAC identifier and is rejected. /// /// This is the built-in module's grammar, not the deployment's. The -/// partner-facing paths (pull sync, batch sync, the admin lookup) dispatch by +/// partner-facing paths (pull sync and batch sync) dispatch by /// module code through /// [`AcceptedModules`](super::module::AcceptedModules), which reaches /// this only for an identifier the built-in module owns, or as the fallback diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index 071ea29d6a..84011c6c4f 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -20,7 +20,7 @@ use error_stack::{Report, ResultExt}; use crate::error::TrustedServerError; use super::generation::ec_hash; -use super::kv_backend::{EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome}; +use super::kv_backend::{EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome}; use super::kv_types::{KvEntry, KvMetadata, KvNetwork}; use super::{EcKvSnapshot, checked_current_timestamp, current_timestamp, log_id}; @@ -293,25 +293,6 @@ impl KvIdentityGraph { Ok((body, meta_str)) } - /// Reads the raw stored body, metadata, and generation for an EC ID key. - /// - /// Unlike [`Self::get`], the entry body is returned without - /// deserialization or validation, so corrupt or legacy-schema records can - /// still be inspected instead of failing closed. Used by the admin EC - /// lookup endpoint. - /// - /// Returns `Ok(None)` when the key does not exist. - /// - /// # Errors - /// - /// Returns [`TrustedServerError::KvStore`] on store open or read failure. - pub fn lookup_raw( - &self, - ec_id: &str, - ) -> Result, Report> { - self.store.lookup(ec_id) - } - /// Reads the full entry and its generation marker for CAS writes. /// /// Returns `Ok(None)` when the key does not exist. @@ -1625,6 +1606,7 @@ impl KvIdentityGraph { #[cfg(test)] mod tests { use super::*; + use crate::ec::kv_backend::EcKvLookup; use crate::ec::kv_backend::test_support::InMemoryEcKv; /// [`EcKvStore`] wrapper whose first CAS write both fails the precondition @@ -4649,7 +4631,8 @@ mod tests { kv.create(&ec_id, &live_entry()) .expect("should issue an identity"); assert!( - kv.lookup_raw(&ec_id) + kv.store + .lookup(&ec_id) .expect("should read lagging replica") .is_none(), "should model the issuance replication gap" diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 25b223ea41..34be0def15 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -14,8 +14,6 @@ //! # Module structure //! //! - auth (private) — shared Bearer-token authentication helpers -//! - [`admin`]: operator endpoints that show the stored identity state -//! (`GET /_ts/admin/ec`) //! - [`generation`] — HMAC-based ID generation, IP normalization, format helpers //! - [`consent`]: EC-specific permission gating, with consent as one input //! - [`cookies`] — `Set-Cookie` header creation and expiration helpers @@ -36,7 +34,6 @@ mod auth; -pub mod admin; pub mod batch_sync; pub mod consent; pub mod cookies; @@ -856,7 +853,7 @@ impl EcContext { /// and EID resolution looks the entry up in that snapshot under this key. /// Each of these reaches the key through this function or through /// [`ec_kv_key`](Self::ec_kv_key), which wraps this function. Batch sync - /// and the admin lookup have no EC context, so they call + /// has no EC context, so it calls /// [`AcceptedModules::canonical_kv_key`](module::AcceptedModules::canonical_kv_key), /// the function this one wraps, directly. /// @@ -1537,7 +1534,7 @@ pub(crate) mod tests { /// as a signed envelope. It accepts any of its own non-empty identifiers /// and keys the identity graph by the value unchanged. /// - /// Shared with the admin lookup, batch sync and pull sync tests. + /// Shared with the batch sync and pull sync tests. #[derive(Debug)] pub(crate) struct OpaqueModule; @@ -2541,9 +2538,9 @@ pub(crate) mod tests { /// A module whose identifier normalizes to a distinct canonical form, to /// prove the identity graph is keyed by the canonical form. /// - /// Shared with the identify, finalization, pull sync, admin lookup, auction - /// and publisher tests, which need a module whose canonical key is not the - /// value the browser carries. + /// Shared with the identify, finalization, pull sync, auction and publisher + /// tests, which need a module whose canonical key is not the value the + /// browser carries. #[derive(Debug)] pub(crate) struct CanonicalizingModule; diff --git a/crates/trusted-server-core/src/ec/module.rs b/crates/trusted-server-core/src/ec/module.rs index d5acf6e8fd..526f302e54 100644 --- a/crates/trusted-server-core/src/ec/module.rs +++ b/crates/trusted-server-core/src/ec/module.rs @@ -462,9 +462,9 @@ pub fn module_kv_key(module: &dyn EdgeCookieModule, full: &str) -> String { } } -/// The modules whose identifiers a partner or diagnostic path accepts. +/// The modules whose identifiers a partner path accepts. /// -/// Pull sync, batch sync, and the admin lookup each take an identifier from +/// Pull sync and batch sync each take an identifier from /// outside the organic request path and have to decide whether Trusted Server /// issued it. The answer is in two parts. The **global cookie bounds** (the /// length cap and the cookie-safe alphabet, see `ec_id_has_only_allowed_chars`) @@ -475,11 +475,11 @@ pub fn module_kv_key(module: &dyn EdgeCookieModule, full: &str) -> String { /// second module's identifiers can never be adopted or written under this /// deployment's keys. /// -/// All three look rows up under the key +/// Both look rows up under the key /// [`canonical_kv_key`](Self::canonical_kv_key) returns rather than under the -/// identifier as given, and pull sync and batch sync also write under that +/// identifier as given, and write under that /// key, so a module whose canonical form differs from the cookie value still -/// reaches the row it created. Batch sync and the admin lookup call +/// reaches the row it created. Batch sync calls /// `canonical_kv_key` directly. Pull sync calls `canonical_kv_key` through /// `EcContext::kv_key_for` and still sends partners the identifier as issued. /// diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index d823cb32fb..3f72fb620e 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -51,17 +51,6 @@ enum DecodedCookieEids { Structured(Vec), } -pub(crate) struct DiagnosticEidSource { - pub(crate) source: String, - pub(crate) uids: Vec, -} - -pub(crate) struct PrebidEidAnalysis { - pub(crate) eids: Vec, - pub(crate) diagnostic_sources: Vec, - pub(crate) updates: Vec, -} - /// Parses a `ts-eids` cookie value into OpenRTB-style `Eid` entries. /// /// Accepts both the current structured cookie format and the earlier legacy @@ -97,27 +86,6 @@ fn decode_prebid_eids_cookie(cookie_value: &str) -> Result Vec { - match self { - Self::Legacy(entries) => entries - .iter() - .filter(|entry| !entry.source.is_empty()) - .map(|entry| DiagnosticEidSource { - source: entry.source.clone(), - uids: vec![entry.id.clone()], - }) - .collect(), - Self::Structured(entries) => entries - .iter() - .filter(|entry| !entry.source.is_empty()) - .map(|entry| DiagnosticEidSource { - source: entry.source.clone(), - uids: entry.uids.iter().map(|uid| uid.id.clone()).collect(), - }) - .collect(), - } - } - fn into_openrtb(self) -> Vec { match self { Self::Legacy(entries) => legacy_cookie_eids_to_openrtb(entries), @@ -164,28 +132,12 @@ pub(crate) fn collect_prebid_eid_updates( cookie_value: &str, registry: &PartnerRegistry, ) -> Vec { - let Ok(analysis) = analyze_prebid_eids_cookie(cookie_value, registry) else { + let Ok(eids) = parse_prebid_eids_cookie(cookie_value) else { log::trace!("Prebid EIDs: failed to decode ts-eids cookie; dropping"); return Vec::new(); }; - analysis.updates -} - -pub(crate) fn analyze_prebid_eids_cookie( - cookie_value: &str, - registry: &PartnerRegistry, -) -> Result { - let decoded = decode_prebid_eids_cookie(cookie_value)?; - let diagnostic_sources = decoded.diagnostic_sources(); - let eids = decoded.into_openrtb(); - let updates = collect_prebid_eid_updates_from_eids(&eids, registry); - - Ok(PrebidEidAnalysis { - eids, - diagnostic_sources, - updates, - }) + collect_prebid_eid_updates_from_eids(&eids, registry) } fn collect_prebid_eid_updates_from_eids( @@ -217,7 +169,7 @@ fn collect_prebid_eid_updates_from_eids( updates } -pub(crate) fn dedupe_partner_updates(updates: Vec) -> Vec { +fn dedupe_partner_updates(updates: Vec) -> Vec { let mut latest = std::collections::BTreeMap::new(); for update in updates { latest.insert(update.partner_id, update.uid); @@ -233,14 +185,14 @@ fn first_valid_uid(uids: &[Uid]) -> Option<&Uid> { uids.iter().find(|uid| is_valid_eid_uid(&uid.id)) } -pub(crate) fn is_valid_eid_uid(uid: &str) -> bool { +fn is_valid_eid_uid(uid: &str) -> bool { !uid.trim().is_empty() && !eid_id_exceeds_size_limit(uid) } /// `SharedID` EID source domain used for partner registry lookup. const SHAREDID_SOURCE_DOMAIN: &str = "sharedid.org"; -pub(crate) fn collect_sharedid_update( +fn collect_sharedid_update( cookie_value: &str, registry: &PartnerRegistry, ) -> Option { diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 7fcc4c2e1b..19a815dc15 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -995,11 +995,6 @@ pub mod test_support { /// The least a deployment's settings must say, which a module's own /// settings are written after. const BASE_SETTINGS: &str = r#" -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "page-recording-password" - [publisher] domain = "publisher.example.com" cookie_domain = ".publisher.example.com" diff --git a/crates/trusted-server-core/src/inspect/config.rs b/crates/trusted-server-core/src/inspect/config.rs index d7ce367734..25e9f1d3de 100644 --- a/crates/trusted-server-core/src/inspect/config.rs +++ b/crates/trusted-server-core/src/inspect/config.rs @@ -43,7 +43,8 @@ pub const CONFIG_PATHS: [&str; 2] = [CONFIG_PAGE_PATH, CONFIG_JSON_PATH]; const NOT_PUBLISHED: &str = "This publisher does not publish its configuration."; const PATTERN_FORM: &str = "A pattern is keys joined by `.`, with `[]` for every element of a \ - list or `[N]` for one, such as `handlers[0].path`"; + list or `[N]` for one, such as \ + `proxy.asset_routes[0].origin_url`"; /// The settings table that names the domain's permission policy. const PERMISSIONS_TABLE: &str = "permissions"; @@ -107,7 +108,8 @@ pub enum PathStep { Index(usize), } -/// A concrete path written the way a pattern is, such as `handlers[0].path`. +/// A concrete path written the way a pattern is, such as +/// `proxy.asset_routes[0].origin_url`. #[must_use] pub fn render_path(steps: &[PathStep]) -> String { let mut out = String::new(); @@ -136,7 +138,7 @@ enum PatternStep { /// Values in the view named by keys joined by `.`, with `[]` for every /// element of a list and `[N]` for one, such as -/// `proxy.asset_routes[].origin_url` or `handlers[0].path`. +/// `proxy.asset_routes[].origin_url` or `proxy.asset_routes[0].prefix`. /// /// A pattern names the values at exactly its own depth. A key holding `.`, /// `[` or `]` cannot be named. diff --git a/crates/trusted-server-core/src/inspect/config/tests.rs b/crates/trusted-server-core/src/inspect/config/tests.rs index 6b61962640..1d00380b85 100644 --- a/crates/trusted-server-core/src/inspect/config/tests.rs +++ b/crates/trusted-server-core/src/inspect/config/tests.rs @@ -7,9 +7,8 @@ use crate::config_payload::settings_from_config_blob_with; use crate::integrations::{IntegrationBuilder, ModuleSecretSetting}; use crate::permissions::{SAMPLE_POLICY_NAME, SAMPLE_POLICY_TEXT, policy_sha256}; use crate::platform::{PlatformError, PlatformSecretStore, PolicyOrigin, StoreId, StoreName}; -use crate::redacted::Redacted; use crate::secret_resolution::ResolvedSecrets; -use crate::settings::{ProxyAssetRoute, RequestSigning}; +use crate::settings::ProxyAssetRoute; use crate::test_support::tests::{crate_test_settings_str, sample_permissions}; /// Every value this store hands back starts with this, so a view can be @@ -110,7 +109,7 @@ fn served(settings: &Settings) -> (String, String) { ) } -/// The value at a concrete path, such as `handlers[0].path`. +/// The value at a concrete path, such as `proxy.asset_routes[0].origin_url`. fn at<'v>(value: &'v Value, path: &str) -> Option<&'v Value> { let pattern = PathPattern::parse(path).expect("a concrete path"); let mut node = value; @@ -147,7 +146,7 @@ fn unmarked(value: &Value) -> Value { #[test] fn a_pattern_names_keys_every_element_or_one_element() { let every = PathPattern::parse("proxy.asset_routes[].origin_url").expect("well formed"); - let one = PathPattern::parse("handlers[1].path").expect("well formed"); + let one = PathPattern::parse("proxy.asset_routes[1].origin_url").expect("well formed"); let route = |index| { vec![ PathStep::Key("proxy".to_owned()), @@ -156,22 +155,15 @@ fn a_pattern_names_keys_every_element_or_one_element() { PathStep::Key("origin_url".to_owned()), ] }; - let handler = |index| { - vec![ - PathStep::Key("handlers".to_owned()), - PathStep::Index(index), - PathStep::Key("path".to_owned()), - ] - }; assert!(every.matches(&route(0)) && every.matches(&route(7))); - assert!(one.matches(&handler(1))); - assert!(!one.matches(&handler(0)), "should name only its own index"); + assert!(one.matches(&route(1))); + assert!(!one.matches(&route(0)), "should name only its own index"); assert!( !every.matches(&route(0)[..3]), "should name only values at its own depth" ); - assert_eq!(render_path(&handler(1)), "handlers[1].path"); + assert_eq!(render_path(&route(1)), "proxy.asset_routes[1].origin_url"); } #[test] @@ -221,8 +213,6 @@ fn documents_holding_every_declared_secret() -> Vec<(&'static str, Value)> { let mut main = document(""); main["publisher"]["proxy_secret"] = json!("proxy"); main["ec"]["hmac"]["passphrase"] = json!("hmac"); - main["handlers"][0]["password"] = json!("handler-0"); - main["handlers"][1]["password"] = json!("handler-1"); main["ec"]["partners"] = json!([{ "name": "Example Partner", "source_domain": "partner.example.com", @@ -378,11 +368,7 @@ fn settings_with_every_sensitive_field() -> (Settings, Vec<(&'static str, &'stat "proxy.asset_routes[0].origin_url", "https://bucket-canary.example.com", ), - ("handlers[0].path", "^/path-canary"), - ("handlers[0].username", "username-canary"), ("ec.ec_store", "identity-store-canary"), - ("request_signing.config_store_id", "config-store-canary"), - ("request_signing.secret_store_id", "secret-store-canary"), ("auction.creative_store", "creative-store-canary"), ]; let value = |path: &str| { @@ -400,14 +386,7 @@ fn settings_with_every_sensitive_field() -> (Settings, Vec<(&'static str, &'stat "/assets/", value("proxy.asset_routes[0].origin_url"), )]; - settings.handlers[0].path = value("handlers[0].path"); - settings.handlers[0].username = Redacted::new(value("handlers[0].username")); settings.ec.ec_store = Some(value("ec.ec_store")); - settings.request_signing = Some(RequestSigning { - enabled: false, - config_store_id: value("request_signing.config_store_id"), - secret_store_id: value("request_signing.secret_store_id"), - }); settings.auction.creative_store = value("auction.creative_store"); (settings, expected) } @@ -426,7 +405,7 @@ fn every_value_sensitive_by_default_is_masked() { assert!(!page.contains(value), "the page carries {path}"); } assert!( - is_masked(&view, "handlers[0].password"), + is_masked(&view, "publisher.proxy_secret"), "a Redacted value is masked too: {:?}", view.masked ); @@ -448,7 +427,10 @@ fn an_ordinary_serialization_is_unchanged() { for (path, value) in expected { assert_eq!(at(&normal, path), Some(&json!(value)), "{path}"); } - assert_eq!(at(&normal, "handlers[0].password"), Some(&json!("pass"))); + assert_eq!( + at(&normal, "publisher.proxy_secret"), + Some(&json!("unit-test-proxy-secret")) + ); } #[test] @@ -610,11 +592,30 @@ fn a_path_masked_beneath_a_key_holding_a_secret_lists_the_renamed_key() { } } +/// Two asset routes, for a list holding values masked by default and a +/// secret. +const ASSET_ROUTES: &str = r#" + [[proxy.asset_routes]] + prefix = "/assets/" + origin_url = "https://assets.example.com" + + [proxy.asset_routes.auth] + type = "s3_sigv4" + region = "us-east-1" + access_key_id = "route-access" + secret_access_key = "route-secret" + + [[proxy.asset_routes]] + prefix = "/media/" + origin_url = "https://media.example.com" +"#; + #[test] fn show_reveals_a_value_masked_by_default() { - let settings = load(document( - "[inspect]\nshow = [\"publisher.origin_url\", \"handlers[].path\"]", - )) + let settings = load(document(&format!( + "{ASSET_ROUTES}\n[inspect]\nshow = [\"publisher.origin_url\", \ + \"proxy.asset_routes[].origin_url\"]" + ))) .expect("should load"); let view = view(&settings); @@ -624,10 +625,10 @@ fn show_reveals_a_value_masked_by_default() { Some(&json!("https://origin.test-publisher.com")) ); assert_eq!( - at(&view.settings, "handlers[1].path"), - Some(&json!("^/_ts/admin")) + at(&view.settings, "proxy.asset_routes[1].origin_url"), + Some(&json!("https://media.example.com")) ); - assert!(!view.masked.iter().any(|path| path.ends_with(".path"))); + assert!(!view.masked.iter().any(|path| path.ends_with(".origin_url"))); assert!(!is_masked(&view, "publisher.origin_url")); } @@ -652,16 +653,16 @@ fn a_pattern_not_honored_as_written_is_refused_saying_why() { "show names `publisher.proxy_secret`, which is a secret", ), ( - r#"show = ["handlers[].password"]"#, - "show names `handlers[].password`, which is a secret", + r#"show = ["proxy.asset_routes[].auth.secret_access_key"]"#, + "show names `proxy.asset_routes[].auth.secret_access_key`, which is a secret", ), ( r#"hide = ["publisher.no_such_setting"]"#, "hide names `publisher.no_such_setting`, which matches no value", ), ( - r#"show = ["handlers[9].path"]"#, - "show names `handlers[9].path`, which matches no value", + r#"show = ["proxy.asset_routes[9].origin_url"]"#, + "show names `proxy.asset_routes[9].origin_url`, which matches no value", ), ( r#"show = ["publisher.domain"]"#, @@ -684,9 +685,10 @@ fn a_pattern_not_honored_as_written_is_refused_saying_why() { `publisher.origin_url` in the same list already names everything it does", ), ( - r#"show = ["handlers[].path", "handlers[0].path"]"#, - "show names `handlers[0].path`, which changes nothing, because \ - `handlers[].path` in the same list already names everything it does", + r#"show = ["proxy.asset_routes[].origin_url", "proxy.asset_routes[0].origin_url"]"#, + "show names `proxy.asset_routes[0].origin_url`, which changes nothing, because \ + `proxy.asset_routes[].origin_url` in the same list already names everything \ + it does", ), ( "show = [\"publisher.origin_url\"]\nhide = [\"publisher.origin_url\"]", @@ -700,7 +702,7 @@ fn a_pattern_not_honored_as_written_is_refused_saying_why() { ] { let message = format!( "{:?}", - load(document(&format!("[inspect]\n{section}"))) + load(document(&format!("{ASSET_ROUTES}\n[inspect]\n{section}"))) .expect_err("the document should be refused") ); diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index 7a650f1648..5d325da105 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -1057,16 +1057,6 @@ mod tests { #[test] fn rejects_unknown_proxy_mode() { let toml = r#" - [[handlers]] - path = "^/secure" - username = "user" - password = "pass" - - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -1080,8 +1070,7 @@ mod tests { assume_single_jurisdiction = true [request_signing] - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" + enabled = false [proxy] modules = ["js_asset_proxy"] diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index d0bdaf996e..e55e1f0ac0 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -422,7 +422,7 @@ pub struct RequestFilterInput<'a> { /// The permission state resolved for this request at the start of the /// request cycle, so a filter reads the same permissions the rest of the /// request uses rather than resolving its own. `None` only on paths that - /// build no EC context, such as batch sync and admin diagnostics. + /// build no EC context, such as batch sync. pub permissions: Option<&'a crate::permissions::PermissionState>, /// Whether the request matches a registered integration proxy route. pub is_integration_route: bool, @@ -506,7 +506,7 @@ pub struct RequestFilterRegistryInput<'a> { pub geo_info: Option<&'a GeoInfo>, /// The permission state resolved for this request at the start of the /// request cycle, passed on to every filter. `None` only on paths that - /// build no EC context, such as batch sync and admin diagnostics. + /// build no EC context, such as batch sync. pub permissions: Option<&'a crate::permissions::PermissionState>, } diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index d91b74f66e..f559547142 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -5,7 +5,6 @@ //! //! # Modules //! -//! - [`auth`]: Basic authentication enforcement helpers //! - [`constants`]: Application-wide constants and configuration values //! - [`cookies`]: Cookie parsing and generation utilities //! - [`error`]: Error types and error handling utilities @@ -32,9 +31,8 @@ pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; -pub mod auth; pub mod cache_policy; -pub mod cache_purge; +pub mod closed_paths; pub mod config; pub mod config_payload; pub mod consent; diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 88db7a9eab..b0d3f72751 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -813,6 +813,9 @@ pub struct PermissionPolicy { maps: Arc, } +/// The name of the policy [`PermissionPolicy::granting_nothing`] builds. +const GRANTING_NOTHING_NAME: &str = "granting-nothing"; + impl PermissionPolicy { /// The policy's name, the stem of its file. #[must_use] @@ -831,6 +834,25 @@ impl PermissionPolicy { pub fn maps(&self) -> &PermissionMaps { &self.maps } + + /// A policy that grants nothing, for a services graph that decides no + /// request's permissions and carries a policy only because every graph + /// does. It is built from the types rather than loaded, so no policy + /// text exists for it, and its digest is that of empty text. + #[must_use] + pub fn granting_nothing() -> Arc { + Arc::new(Self { + reference: PolicyReference { + policy: GRANTING_NOTHING_NAME.to_owned(), + sha256: policy_sha256(""), + }, + maps: Arc::new( + PermissionMaps::empty() + .with_default_rules(CountryRules::with_default(Acquisition::Denied)) + .with_default_jurisdiction(Jurisdiction::NonRegulated), + ), + }) + } } /// Checks `text` against the digest `reference` pins and parses it into a @@ -2825,4 +2847,22 @@ signals: "the sample's rules should be parsed once for every test" ); } + + #[test] + fn the_policy_granting_nothing_grants_nothing() { + let policy = PermissionPolicy::granting_nothing(); + let maps = policy.maps(); + + assert!( + maps.baseline(None, None).permissions().is_empty(), + "no permission should be set without a signal" + ); + assert!( + maps.resolve(Some("GB"), |_| true).permissions().is_empty(), + "no permission should be set when every signal grants" + ); + assert_eq!(maps.default_jurisdiction(), Jurisdiction::NonRegulated); + assert_eq!(policy.name(), GRANTING_NOTHING_NAME); + assert_eq!(policy.sha256(), policy_sha256("")); + } } diff --git a/crates/trusted-server-core/src/platform/template_cache.rs b/crates/trusted-server-core/src/platform/template_cache.rs index e5c9c1de5a..62873d58d2 100644 --- a/crates/trusted-server-core/src/platform/template_cache.rs +++ b/crates/trusted-server-core/src/platform/template_cache.rs @@ -207,9 +207,9 @@ impl TemplateCacheKey { /// Surrogate key for a reader-facing URL, as an operator would type it. /// -/// A free function because both halves of the purge path need it and neither can build a -/// whole [`TemplateCacheKey`]: the endpoint and the CLI have a URL, not an origin identity, -/// a template fingerprint, or the origin's `Vary` values. +/// A free function because the service tags a cached page with it and `ts cache purge +/// --page` works the same key out from a URL alone, without the origin identity, template +/// fingerprint or origin `Vary` values a whole [`TemplateCacheKey`] needs. /// /// # Canonicalization /// @@ -819,17 +819,6 @@ pub trait PlatformTemplateCache: Send + Sync { /// Purge every cached variant for one publisher URL. async fn purge_url(&self, key: &TemplateCacheKey) -> Result<(), TemplateCacheError>; - - /// Purge one already-derived surrogate key. - /// - /// Exists because [`Self::purge_url`] needs a whole [`TemplateCacheKey`], which a - /// purge caller cannot build: an operator or a CMS webhook has a URL, not an origin - /// identity, a template fingerprint, or the origin's `Vary` values. Pair it with - /// [`reader_url_surrogate_key`]. - async fn purge_url_surrogate_key(&self, key: &str) -> Result<(), TemplateCacheError>; - - /// Purge every stored template. The rollback lever. - async fn purge_all(&self) -> Result<(), TemplateCacheError>; } /// A template read from the cache. @@ -874,17 +863,6 @@ impl PlatformTemplateCache for UnavailableTemplateCache { async fn purge_url(&self, _key: &TemplateCacheKey) -> Result<(), TemplateCacheError> { Err(TemplateCacheError::Unsupported) } - - /// Reports unsupported rather than succeeding. A purge surface that silently does - /// nothing is worse than one that refuses: an operator mid-incident would read the - /// success and stop looking. - async fn purge_url_surrogate_key(&self, _key: &str) -> Result<(), TemplateCacheError> { - Err(TemplateCacheError::Unsupported) - } - - async fn purge_all(&self) -> Result<(), TemplateCacheError> { - Err(TemplateCacheError::Unsupported) - } } #[cfg(test)] @@ -1197,19 +1175,6 @@ mod tests { } } - #[test] - fn an_adapter_without_a_template_cache_refuses_a_surrogate_purge() { - let outcome = futures::executor::block_on( - UnavailableTemplateCache.purge_url_surrogate_key("ts-template-readerurl-abc"), - ); - - assert!( - matches!(outcome, Err(TemplateCacheError::Unsupported)), - "a purge surface that silently does nothing is worse than one that refuses: \ - an operator mid-incident would read the success and stop looking" - ); - } - #[test] fn the_reader_facing_key_ignores_origin_rewriting() { // `url` is the origin-rewritten target URI, not what an operator types. Two diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 0c5871d650..32c126688d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4525,7 +4525,7 @@ pub(crate) struct SharedRequestInputs { pub(crate) method_is_cacheable: bool, /// A request host was resolved. The post-processed output is host-dependent. pub(crate) host_present: bool, - /// The request carried an `Authorization` value that did not pass edge auth unchanged. + /// The request carried an `Authorization` header, which is bound for the origin. pub(crate) authorization_disqualifies: bool, /// Cookie policy refuses this cache's key. Readthrough additionally refuses /// configured cookie dimensions that only the template key can represent. @@ -4580,7 +4580,7 @@ fn apply_origin_cache_intent( if bypass { request.with_cache_bypass() } else if readthrough_applies { - // The same reader-facing key and all-scope key used by the purge endpoint. + // The reader-facing key and the all-scope key that `ts cache purge` names. // Fastly accepts multiple space-separated surrogate keys without changing TTL. request.with_shared_cache(format!( "{TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY} {}", @@ -4840,18 +4840,10 @@ pub async fn handle_publisher_request( // may start before the auction is dispatched, so nothing downstream can read // these headers any more. // - // Capture cache eligibility before the origin send consumes the request. One - // unchanged marked Authorization value passed edge auth; unmarked, replaced, - // or repeated values remain pass-through and bypass sharing. - let authorization_value_count = req.headers().get_all(header::AUTHORIZATION).iter().count(); - let authorization_disqualifies = match authorization_value_count { - 0 => false, - 1 => req - .extensions() - .get::() - .is_none_or(|marker| !marker.matches(req.headers())), - _ => true, - }; + // Capture cache eligibility before the origin send consumes the request. An + // Authorization header is bound for the origin, so its response is never + // shared. + let authorization_disqualifies = req.headers().contains_key(header::AUTHORIZATION); // Classify cookies once before the origin consumes the request. The same decision // governs lookup and storage, so a bypass cookie can never read a warm template. let (key_cookie_names, bypass_cookie_names, cookie_independent) = settings @@ -6416,9 +6408,8 @@ pub(crate) enum TemplateCacheBypassReason { /// still-positive origin lifetime rather than inventing one. #[display("origin response has no positive shared freshness")] NoPositiveFreshness, - /// The request was authenticated. #1009 describes a Basic-Auth-gated - /// deployment, so an authorized response entering a shared cache is a live - /// concern rather than a hypothetical one. + /// The request carried credentials, which are bound for the origin, so its + /// response is one reader's and never enters a shared cache. #[display("request carried Authorization")] AuthorizedRequest, /// Not a 200. This is also what covers a bot protection module's block, which @@ -7102,10 +7093,8 @@ pub const PAGE_BIDS_PATH: &str = "/_ts/page-bids"; /// The alias is bidirectional in practice: the current tsjs bundle requests /// [`PAGE_BIDS_PATH`] first and falls back here when that path does not serve /// page-bids on a deployment. That covers a server rolled back to before the -/// rename, and an operator `[[handlers]]` auth regex broad enough to cover -/// `/_ts` (which would answer the canonical path with `401`). Both are -/// transitional — an affected operator must narrow the regex before the alias -/// is removed. +/// rename, and a gate in front of the service that answers the canonical path +/// with `401`. Both are transitional. /// /// Removal is tracked by IABTechLab/trusted-server#970: drop this const, its /// four adapter registrations, and the client fallback once access logs show no @@ -7247,8 +7236,8 @@ pub async fn handle_page_bids( // separates them by the `X-TSJS-Page-Bids` value. A pre-rename bundle sends // `1` and disappears on its own as caches turn over. The current bundle // sends `fallback` and does *not* — it only reaches the alias when the - // canonical path is unusable on this deployment (an operator `[[handlers]]` - // regex covering `/_ts`, or a server rolled back past the rename), which + // canonical path is unusable on this deployment (a gate in front of the + // service covering `/_ts`, or a server rolled back past the rename), which // persists until that is fixed. Treating the two as one number would make // #970 wait forever on a config problem. The value is client-supplied, so // it is a diagnostic hint only; the gate above does not trust it. @@ -7262,8 +7251,8 @@ pub async fn handle_page_bids( if is_client_fallback { log::warn!( "page-bids: served deprecated alias {PAGE_BIDS_LEGACY_PATH} to a current \ - tsjs bundle that could not use {PAGE_BIDS_PATH} — check `[[handlers]]` for \ - a pattern covering `/_ts`; see IABTechLab/trusted-server#970" + tsjs bundle that could not use {PAGE_BIDS_PATH}, so check what answers \ + that path ahead of this service; see IABTechLab/trusted-server#970" ); } else { log::info!( @@ -10474,17 +10463,6 @@ mod tests { ) -> Result<(), crate::platform::TemplateCacheError> { Ok(()) } - - async fn purge_url_surrogate_key( - &self, - _key: &str, - ) -> Result<(), crate::platform::TemplateCacheError> { - Ok(()) - } - - async fn purge_all(&self) -> Result<(), crate::platform::TemplateCacheError> { - Ok(()) - } } impl RecordingCache { @@ -10629,10 +10607,6 @@ mod tests { /// Force the lookup transaction to fail, for the fail-open + telemetry /// contract. A backend outage must never become a publisher outage. fail_lookup: AtomicBool, - /// Surrogate keys a purge asked for. This double stores by cache key, so it - /// cannot resolve a surrogate key to entries the way the platform does — - /// recording the request is what a test can assert on. - purged_surrogate_keys: Arc>>, } struct MemoryTemplateReservation { @@ -10817,26 +10791,6 @@ mod tests { .remove(&key.to_cache_key()); Ok(()) } - - /// Records the key so a test can assert what a purge asked for. - /// - /// This double stores by cache key, not by surrogate key, so it cannot - /// resolve one to the other the way the platform does. - async fn purge_url_surrogate_key( - &self, - key: &str, - ) -> Result<(), crate::platform::TemplateCacheError> { - self.purged_surrogate_keys - .lock() - .expect("should lock purged surrogate keys") - .push(key.to_owned()); - Ok(()) - } - - async fn purge_all(&self) -> Result<(), crate::platform::TemplateCacheError> { - self.entries.lock().expect("should lock entries").clear(); - Ok(()) - } } /// Settings with the ad stack **live**, not merely configured. @@ -12400,37 +12354,7 @@ mod tests { } #[tokio::test] - async fn a_pass_through_authorization_never_reaches_the_shared_template() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - - let mut request = navigation_request(); - request.headers_mut().insert( - header::AUTHORIZATION, - HeaderValue::from_static("Basic dXNlcjpwYXNz"), - ); - - let response = run(&settings, &services, request).await; - assert_eq!( - response - .headers() - .get(HEADER_X_TS_TEMPLATE_CACHE) - .and_then(|value| value.to_str().ok()), - Some("bypass-request"), - "a credential TS did not terminate is bound for the origin, so its response must never be shared" - ); - assert_eq!( - cache.lookups.lock().expect("should lock lookups").len(), - 0, - "an unterminated authorization must bypass before the lookup, not after it" - ); - } - - #[tokio::test] - async fn repeated_authorization_never_reaches_the_shared_template() { + async fn an_authorization_never_reaches_the_shared_template() { let stub = Arc::new(StubHttpClient::new()); let cache = Arc::new(MemoryTemplateCache::default()); let settings = Arc::new(settings_with_mode("esi")); @@ -12442,64 +12366,6 @@ mod tests { header::AUTHORIZATION, HeaderValue::from_static("Basic dXNlcjpwYXNz"), ); - request.headers_mut().append( - header::AUTHORIZATION, - HeaderValue::from_static("Bearer publisher-origin-credential"), - ); - request - .extensions_mut() - .insert(crate::auth::EdgeTerminatedAuthorization::for_test( - "Basic dXNlcjpwYXNz", - )); - - let response = run(&settings, &services, request).await; - assert_eq!( - response - .headers() - .get(HEADER_X_TS_TEMPLATE_CACHE) - .and_then(|value| value.to_str().ok()), - Some("bypass-request"), - "an ambiguous authorization must remain bound for the origin even if a marker is present" - ); - assert_eq!( - cache.lookups.lock().expect("should lock lookups").len(), - 0, - "repeated authorization must bypass before the shared template lookup" - ); - } - - #[tokio::test] - async fn a_replaced_edge_terminated_authorization_bypasses_the_shared_template() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let mut settings = settings_with_mode("esi"); - settings.handlers[0] = toml::from_str( - r#" - path = "^/article" - username = "user" - password = "pass" - "#, - ) - .expect("should parse article auth handler"); - let settings = Arc::new(settings); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - queue_shareable_html(&stub); - - let mut request = navigation_request(); - request.headers_mut().insert( - header::AUTHORIZATION, - HeaderValue::from_static("Basic dXNlcjpwYXNz"), - ); - assert!( - crate::auth::enforce_basic_auth(&settings, &mut request) - .expect("should evaluate auth") - .is_none(), - "the original credential should pass edge auth" - ); - request.headers_mut().insert( - header::AUTHORIZATION, - HeaderValue::from_static("Bearer publisher-origin-credential"), - ); let response = run(&settings, &services, request).await; assert_eq!( @@ -12508,66 +12374,12 @@ mod tests { .get(HEADER_X_TS_TEMPLATE_CACHE) .and_then(|value| value.to_str().ok()), Some("bypass-request"), - "a replacement credential was not validated at the edge and must bypass sharing" + "an authorization is bound for the origin, so its response must never be shared" ); assert_eq!( cache.lookups.lock().expect("should lock lookups").len(), 0, - "a replaced authorization must bypass before the shared template lookup" - ); - } - - #[tokio::test] - async fn an_edge_terminated_authorization_still_shares_its_template() { - let stub = Arc::new(StubHttpClient::new()); - let cache = Arc::new(MemoryTemplateCache::default()); - let settings = Arc::new(settings_with_mode("esi")); - let services = services(Arc::clone(&stub), Arc::clone(&cache)); - - // One origin response for two requests: the warm read is asserted by the - // fixture running dry, exactly as in the unauthenticated case. - queue_shareable_html(&stub); - - let authorized_navigation = || { - let mut request = navigation_request(); - request.headers_mut().insert( - header::AUTHORIZATION, - HeaderValue::from_static("Basic dXNlcjpwYXNz"), - ); - request.extensions_mut().insert( - crate::auth::EdgeTerminatedAuthorization::for_test("Basic dXNlcjpwYXNz"), - ); - request - }; - - let cold = run(&settings, &services, authorized_navigation()).await; - assert_eq!( - cold.headers() - .get(HEADER_X_TS_TEMPLATE_CACHE) - .and_then(|value| value.to_str().ok()), - Some("miss-stored"), - "a credential this edge terminated must be allowed to fill the shared template" - ); - let first = body_of(cold).await; - - let warm = run(&settings, &services, authorized_navigation()).await; - assert_eq!( - warm.headers() - .get(HEADER_X_TS_TEMPLATE_CACHE) - .and_then(|value| value.to_str().ok()), - Some("hit"), - "the second gated request must read the template the first one stored" - ); - let second = body_of(warm).await; - - assert_eq!( - stub.recorded_request_uris().len(), - 1, - "the warm gated request must not fetch the origin" - ); - assert_eq!( - second, first, - "the gated warm response must be byte-identical to the stored template" + "an authorization must bypass before the lookup, not after it" ); } diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index d35e51a151..6308d6d049 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -1,18 +1,17 @@ //! HTTP endpoint handlers for request signing operations. //! -//! This module provides endpoint handlers for JWKS retrieval, signature verification, -//! key rotation, and key deactivation operations. +//! This module provides endpoint handlers for JWKS retrieval and signature +//! verification, and the key ID rules key creation follows. use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::{Request, Response, StatusCode, header}; use serde::{Deserialize, Serialize}; -use crate::error::{IntoHttpResponse, TrustedServerError}; +use crate::error::TrustedServerError; use crate::http_util::enforce_max_body_size; use crate::platform::RuntimeServices; use crate::request_signing::discovery::TrustedServerDiscovery; -use crate::request_signing::rotation::KeyRotationManager; use crate::request_signing::signing; use crate::settings::Settings; @@ -99,7 +98,6 @@ pub struct VerifySignatureResponse { } const VERIFY_MAX_BODY_BYTES: usize = 4096; -const ADMIN_MAX_BODY_BYTES: usize = 4096; /// Will verify a signature given a payload and kid /// Useful for testing integration with signatures @@ -160,62 +158,11 @@ pub fn handle_verify_signature( Ok(json_response(StatusCode::OK, response_json)) } -/// JSON request body for the key-rotation endpoint. -#[derive(Debug, Deserialize, Serialize)] -pub struct RotateKeyRequest { - /// Optional explicit key identifier for the new signing key. - #[serde(skip_serializing_if = "Option::is_none")] - pub kid: Option, -} - -/// JSON response body for the key-rotation endpoint. -#[derive(Debug, Deserialize, Serialize)] -pub struct RotateKeyResponse { - /// Whether the rotation operation succeeded. - pub success: bool, - /// Human-readable summary of the rotation result. - pub message: String, - /// Newly generated or supplied key identifier. - pub new_kid: String, - /// Previously active key identifier, if one existed. - pub previous_kid: Option, - /// Active key identifiers after the rotation completes. - pub active_kids: Vec, - /// Public JWK associated with the newly active key. - pub jwk: serde_json::Value, - /// Error detail when rotation fails. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -struct SigningStoreIds<'a> { - config_store_id: &'a str, - secret_store_id: &'a str, -} - const MAX_KID_LENGTH: usize = 128; -fn signing_store_ids( - settings: &Settings, -) -> Result, Report> { - settings - .request_signing - .as_ref() - .map(|setting| SigningStoreIds { - config_store_id: setting.config_store_id.as_str(), - secret_store_id: setting.secret_store_id.as_str(), - }) - .ok_or_else(|| { - TrustedServerError::Configuration { - message: "missing signing storage configuration".to_string(), - } - .into() - }) -} - -/// Validates the structural constraints every kid must satisfy regardless of -/// operation: non-empty, length-bounded, and limited to a safe charset so a kid -/// cannot smuggle a CSV separator or other control characters into storage. +/// Validates the structural constraints every kid must satisfy: non-empty, +/// length-bounded, and limited to a safe charset so a kid cannot smuggle a CSV +/// separator or other control characters into storage. fn validate_kid_format(kid: &str) -> Result<(), Report> { if kid.is_empty() || kid.len() > MAX_KID_LENGTH { return Err(Report::new(TrustedServerError::BadRequest { @@ -235,27 +182,23 @@ fn validate_kid_format(kid: &str) -> Result<(), Report> { Ok(()) } -/// Validates a kid for creation/rotation. +/// Validates a kid for a new key. /// -/// Enforces the **portable-KID contract**: a kid created through any adapter must -/// be storable by every platform's key-name encoder, so create/rotate validates -/// against the strictest common denominator. Concretely the kid must start with a -/// lowercase ASCII letter, on top of the structural [`validate_kid_format`] -/// charset checks. +/// Enforces the **portable-KID contract**: a new kid must be storable by every +/// platform's key-name encoder, so creation validates against the strictest +/// common denominator. Concretely the kid must start with a lowercase ASCII +/// letter, on top of the structural [`validate_kid_format`] charset checks. /// /// That floor is currently set by the Fermyon Spin variable encoder /// (`spin_variable_name` in the Spin adapter's `platform.rs`), which requires a /// lowercase ASCII leading character: it would otherwise alias digit-leading keys /// (`1foo` and `n1foo` both map to `v_n1foo`) or reject uppercase/punctuation /// leading keys (`KidA`, `_kid`, `-kid`, `.kid`, `:kid`) at storage time. -/// Enforcing the floor here turns those into a client-correctable 400 on every -/// adapter instead of a runtime 5xx on Spin. [`kid_is_creatable`] exposes this -/// predicate so adapter crates can pin the contract with a test — core must never -/// accept a kid its encoder rejects. System-generated KIDs (`ts-YYYY-MM-DD`) -/// start with `t` and are unaffected. -/// -/// Deactivation/deletion deliberately uses the looser [`validate_kid_format`] so -/// legacy KIDs created under earlier validation rules remain removable. +/// Checking the floor here refuses such a kid for every platform rather than +/// leaving it to fail at storage time on Spin. [`kid_is_creatable`] exposes +/// this predicate so adapter crates can pin the contract with a test, because +/// core must never accept a kid its encoder rejects. System-generated KIDs +/// (`ts-YYYY-MM-DD`) start with `t` and are unaffected. fn validate_kid(kid: &str) -> Result<(), Report> { validate_kid_format(kid)?; @@ -268,233 +211,28 @@ fn validate_kid(kid: &str) -> Result<(), Report> { Ok(()) } -/// Returns whether `kid` satisfies the create/rotate portable-KID contract -/// enforced by [`validate_kid`]. +/// Returns whether `kid` satisfies the portable-KID contract that +/// [`validate_kid`] checks for a new key. /// -/// Exposed so platform adapter crates can assert their key-name encoder accepts -/// every kid this validation admits, pinning the cross-adapter contract against -/// silent drift between this validation floor and an adapter's storage encoder. +/// Exposed for the tool that creates keys, which asks before it writes +/// anything, and so platform adapter crates can assert their key-name encoder +/// accepts every kid this validation admits, pinning the cross-adapter contract +/// against silent drift between this validation floor and an adapter's storage +/// encoder. #[must_use] pub fn kid_is_creatable(kid: &str) -> bool { validate_kid(kid).is_ok() } -/// Rotates the current active kid by generating and saving a new one. -/// -/// # Response contract -/// -/// Returns `200 OK` with `success: true` on success, `400 Bad Request` for an -/// invalid operator-supplied `kid`, or `500 Internal Server Error` when rotation -/// fails. Failure responses include `success: false` and a populated `error` -/// field. Unlike [`handle_verify_signature`], the error field contains internal -/// detail — this is intentional because this endpoint is auth-gated and -/// operator-facing only. -/// -/// # Errors -/// -/// Returns an error if the request signing settings are missing or JSON parsing fails. -pub fn handle_rotate_key( - settings: &Settings, - services: &RuntimeServices, - req: Request, -) -> Result, Report> { - let SigningStoreIds { - config_store_id, - secret_store_id, - } = signing_store_ids(settings)?; - - let body = request_body_bytes(req.into_body(), "rotate-key")?; - enforce_max_body_size(&body, ADMIN_MAX_BODY_BYTES, "rotate-key")?; - let rotate_req: RotateKeyRequest = if body.is_empty() { - RotateKeyRequest { kid: None } - } else { - serde_json::from_slice(&body).change_context(TrustedServerError::Configuration { - message: "invalid JSON request body".into(), - })? - }; - - let manager = KeyRotationManager::new(config_store_id, secret_store_id); - let validation_result = if let Some(kid) = rotate_req.kid.as_deref() { - validate_kid(kid) - } else { - Ok(()) - }; - let result = validation_result.and_then(|()| manager.rotate_key(services, rotate_req.kid)); - - match result { - Ok(result) => { - let jwk_value = serde_json::to_value(&result.jwk).map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("failed to serialize JWK: {}", e), - }) - })?; - - let response = RotateKeyResponse { - success: true, - message: "Key rotated successfully".to_string(), - new_kid: result.new_kid, - previous_kid: result.previous_kid, - active_kids: result.active_kids, - jwk: jwk_value, - error: None, - }; - - let response_json = serde_json::to_string(&response).map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("failed to serialize response: {}", e), - }) - })?; - - Ok(json_response(StatusCode::OK, response_json)) - } - Err(e) => { - let status = e.current_context().status_code(); - let response = RotateKeyResponse { - success: false, - message: "Key rotation failed".to_string(), - new_kid: String::new(), - previous_kid: None, - active_kids: vec![], - jwk: serde_json::json!({}), - error: Some(format!("{}", e)), - }; - - let response_json = serde_json::to_string(&response).map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("failed to serialize response: {}", e), - }) - })?; - - Ok(json_response(status, response_json)) - } - } -} - -/// JSON request body for the key-deactivation endpoint. -#[derive(Debug, Deserialize, Serialize)] -pub struct DeactivateKeyRequest { - /// Key identifier to deactivate or delete. - pub kid: String, - /// Whether the key should be deleted from storage after deactivation. - #[serde(default)] - pub delete: bool, -} - -/// JSON response body for the key-deactivation endpoint. -#[derive(Debug, Deserialize, Serialize)] -pub struct DeactivateKeyResponse { - /// Whether the deactivation or deletion succeeded. - pub success: bool, - /// Human-readable summary of the operation result. - pub message: String, - /// Key identifier that was deactivated or deleted. - pub deactivated_kid: String, - /// Whether the key was deleted from storage. - pub deleted: bool, - /// Active key identifiers remaining after the operation. - pub remaining_active_kids: Vec, - /// Error detail when the operation fails. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// Deactivates or deletes an active signing key. -/// -/// # Response contract +/// Returns whether `kid` has the shape of a stored key id, which +/// [`validate_kid_format`] checks. /// -/// Returns `200 OK` with `success: true` on success, `400 Bad Request` for an -/// invalid operator-supplied `kid`, or `500 Internal Server Error` when -/// deactivation fails. Failure responses include `success: false` and a populated -/// `error` field. Like [`handle_rotate_key`] and unlike -/// [`handle_verify_signature`], the error field contains internal detail — this -/// is intentional because this endpoint is auth-gated and operator-facing only. -/// -/// # Errors -/// -/// Returns an error if the request signing settings are missing or JSON parsing fails. -pub fn handle_deactivate_key( - settings: &Settings, - services: &RuntimeServices, - req: Request, -) -> Result, Report> { - let SigningStoreIds { - config_store_id, - secret_store_id, - } = signing_store_ids(settings)?; - - let body = request_body_bytes(req.into_body(), "deactivate-key")?; - enforce_max_body_size(&body, ADMIN_MAX_BODY_BYTES, "deactivate-key")?; - let deactivate_req: DeactivateKeyRequest = - serde_json::from_slice(&body).change_context(TrustedServerError::Configuration { - message: "invalid JSON request body".into(), - })?; - - let manager = KeyRotationManager::new(config_store_id, secret_store_id); - - // Use the looser format validation here so legacy KIDs created under earlier - // validation rules (e.g. digit- or uppercase-leading) can still be - // deactivated or deleted. The stricter validate_kid only gates new key - // creation/rotation. - let result = validate_kid_format(&deactivate_req.kid).and_then(|()| { - if deactivate_req.delete { - manager.delete_key(services, &deactivate_req.kid) - } else { - manager.deactivate_key(services, &deactivate_req.kid) - } - }); - - match result { - Ok(()) => { - let remaining_keys = manager.list_active_keys(services).unwrap_or_else(|e| { - log::warn!("failed to list active keys after deactivation: {}", e); - vec![] - }); - - let response = DeactivateKeyResponse { - success: true, - message: if deactivate_req.delete { - "Key deleted successfully".to_string() - } else { - "Key deactivated successfully".to_string() - }, - deactivated_kid: deactivate_req.kid, - deleted: deactivate_req.delete, - remaining_active_kids: remaining_keys, - error: None, - }; - - let response_json = serde_json::to_string(&response).map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("failed to serialize response: {}", e), - }) - })?; - - Ok(json_response(StatusCode::OK, response_json)) - } - Err(e) => { - let status = e.current_context().status_code(); - let response = DeactivateKeyResponse { - success: false, - message: if deactivate_req.delete { - "Key deletion failed".to_string() - } else { - "Key deactivation failed".to_string() - }, - deactivated_kid: deactivate_req.kid.clone(), - deleted: false, - remaining_active_kids: vec![], - error: Some(format!("{}", e)), - }; - - let response_json = serde_json::to_string(&response).map_err(|e| { - Report::new(TrustedServerError::Configuration { - message: format!("failed to serialize response: {}", e), - }) - })?; - - Ok(json_response(status, response_json)) - } - } +/// Looser than [`kid_is_creatable`], so that a key created under an earlier +/// rule can still be deactivated and deleted. Exposed for the tool that +/// retires keys, which asks before it reads or writes anything. +#[must_use] +pub fn kid_is_well_formed(kid: &str) -> bool { + validate_kid_format(kid).is_ok() } #[cfg(test)] @@ -719,266 +457,6 @@ mod tests { assert!(result.is_err(), "Malformed JSON should error"); } - #[test] - fn test_handle_rotate_key_with_empty_body() { - let settings = crate::test_support::tests::create_test_settings(); - let req = build_request(Method::POST, "https://test.com/admin/keys/rotate", None); - - let resp = handle_rotate_key(&settings, &noop_services(), req) - .expect("should return a response even when stores are unavailable"); - - assert_eq!( - resp.status(), - StatusCode::INTERNAL_SERVER_ERROR, - "should return 500 when store writes fail" - ); - - let body = response_body_string(resp); - let response: RotateKeyResponse = - serde_json::from_str(&body).expect("should deserialize rotate response"); - - assert!( - !response.success, - "should report failure when store writes fail" - ); - assert!( - response.error.is_some(), - "should include error detail in failure response" - ); - } - - #[test] - fn test_handle_rotate_key_with_custom_kid() { - let settings = crate::test_support::tests::create_test_settings(); - - let req_body = RotateKeyRequest { - kid: Some("test-custom-key".to_string()), - }; - - let body_json = serde_json::to_string(&req_body).expect("should serialize rotate request"); - let req = build_request( - Method::POST, - "https://test.com/admin/keys/rotate", - Some(&body_json), - ); - - let resp = handle_rotate_key(&settings, &noop_services(), req) - .expect("should return a response even when stores are unavailable"); - - assert_eq!( - resp.status(), - StatusCode::INTERNAL_SERVER_ERROR, - "should return 500 when store writes fail" - ); - - let body = response_body_string(resp); - let response: RotateKeyResponse = - serde_json::from_str(&body).expect("should deserialize rotate response"); - - assert!( - !response.success, - "should report failure when store writes fail" - ); - assert!( - response.error.is_some(), - "should include error detail in failure response" - ); - } - - #[test] - fn test_handle_rotate_key_invalid_json() { - let settings = crate::test_support::tests::create_test_settings(); - let req = build_request( - Method::POST, - "https://test.com/admin/keys/rotate", - Some("invalid json"), - ); - - let result = handle_rotate_key(&settings, &noop_services(), req); - assert!(result.is_err(), "Invalid JSON should return error"); - } - - #[test] - fn test_handle_rotate_key_rejects_invalid_kid() { - let settings = crate::test_support::tests::create_test_settings(); - - let req_body = RotateKeyRequest { - kid: Some("bad,kid".to_string()), - }; - - let body_json = serde_json::to_string(&req_body).expect("should serialize rotate request"); - let req = build_request( - Method::POST, - "https://test.com/admin/keys/rotate", - Some(&body_json), - ); - - let resp = handle_rotate_key(&settings, &noop_services(), req) - .expect("should return a response for invalid kid"); - - assert_eq!( - resp.status(), - StatusCode::BAD_REQUEST, - "should reject malformed kid as a bad request" - ); - - let body = response_body_string(resp); - let response: RotateKeyResponse = - serde_json::from_str(&body).expect("should deserialize rotate response"); - - assert!( - !response.success, - "should report failure when supplied kid is invalid" - ); - assert!( - response - .error - .as_deref() - .is_some_and(|error| error.contains("kid must contain only")), - "should explain the kid character restrictions" - ); - } - - #[test] - fn test_handle_deactivate_key_request() { - let settings = crate::test_support::tests::create_test_settings(); - - let req_body = DeactivateKeyRequest { - kid: "test-old-key".to_string(), - delete: false, - }; - - let body_json = - serde_json::to_string(&req_body).expect("should serialize deactivate request"); - let req = build_request( - Method::POST, - "https://test.com/admin/keys/deactivate", - Some(&body_json), - ); - - let resp = handle_deactivate_key(&settings, &noop_services(), req) - .expect("should return a response even when stores are unavailable"); - - assert_eq!( - resp.status(), - StatusCode::INTERNAL_SERVER_ERROR, - "should return 500 when active-kids cannot be read" - ); - - let body = response_body_string(resp); - let response: DeactivateKeyResponse = - serde_json::from_str(&body).expect("should deserialize deactivate response"); - - assert!( - !response.success, - "should report failure when store reads fail" - ); - assert!( - response.error.is_some(), - "should include error detail in failure response" - ); - } - - #[test] - fn test_handle_deactivate_key_with_delete() { - let settings = crate::test_support::tests::create_test_settings(); - - let req_body = DeactivateKeyRequest { - kid: "test-old-key".to_string(), - delete: true, - }; - - let body_json = - serde_json::to_string(&req_body).expect("should serialize deactivate request"); - let req = build_request( - Method::POST, - "https://test.com/admin/keys/deactivate", - Some(&body_json), - ); - - let resp = handle_deactivate_key(&settings, &noop_services(), req) - .expect("should return a response even when stores are unavailable"); - - assert_eq!( - resp.status(), - StatusCode::INTERNAL_SERVER_ERROR, - "should return 500 when active-kids cannot be read" - ); - - let body = response_body_string(resp); - let response: DeactivateKeyResponse = - serde_json::from_str(&body).expect("should deserialize deactivate response"); - - assert!( - !response.success, - "should report failure when store reads fail" - ); - assert!( - !response.deleted, - "should not report deletion when the operation failed" - ); - assert!( - response.error.is_some(), - "should include error detail in failure response" - ); - } - - #[test] - fn test_handle_deactivate_key_invalid_json() { - let settings = crate::test_support::tests::create_test_settings(); - let req = build_request( - Method::POST, - "https://test.com/admin/keys/deactivate", - Some("invalid json"), - ); - - let result = handle_deactivate_key(&settings, &noop_services(), req); - assert!(result.is_err(), "Invalid JSON should return error"); - } - - #[test] - fn test_handle_deactivate_key_rejects_invalid_kid() { - let settings = crate::test_support::tests::create_test_settings(); - - let req_body = DeactivateKeyRequest { - kid: "bad kid".to_string(), - delete: false, - }; - - let body_json = - serde_json::to_string(&req_body).expect("should serialize deactivate request"); - let req = build_request( - Method::POST, - "https://test.com/admin/keys/deactivate", - Some(&body_json), - ); - - let resp = handle_deactivate_key(&settings, &noop_services(), req) - .expect("should return a response for invalid kid"); - - assert_eq!( - resp.status(), - StatusCode::BAD_REQUEST, - "should reject malformed kid as a bad request" - ); - - let body = response_body_string(resp); - let response: DeactivateKeyResponse = - serde_json::from_str(&body).expect("should deserialize deactivate response"); - - assert!( - !response.success, - "should report failure when supplied kid is invalid" - ); - assert!( - response - .error - .as_deref() - .is_some_and(|error| error.contains("kid must contain only")), - "should explain the kid character restrictions" - ); - } - #[test] fn verify_signature_rejects_oversized_body() { let settings = crate::test_support::tests::create_test_settings(); @@ -1018,63 +496,6 @@ mod tests { ); } - #[test] - fn rotate_key_rejects_oversized_body() { - let settings = crate::test_support::tests::create_test_settings(); - let oversized = "x".repeat(ADMIN_MAX_BODY_BYTES + 1); - let req = build_request( - Method::POST, - "https://test.com/admin/keys/rotate", - Some(&oversized), - ); - let err = handle_rotate_key(&settings, &noop_services(), req) - .expect_err("should reject oversized body"); - assert_eq!( - err.current_context().status_code(), - StatusCode::PAYLOAD_TOO_LARGE, - "should return 413 for rotate-key body over limit" - ); - } - - #[test] - fn rotate_key_rejects_streaming_body() { - let settings = crate::test_support::tests::create_test_settings(); - let req = build_streaming_request(Method::POST, "https://test.com/admin/keys/rotate"); - let err = handle_rotate_key(&settings, &noop_services(), req) - .expect_err("should reject streaming rotate body"); - assert_eq!( - err.current_context().status_code(), - StatusCode::BAD_REQUEST, - "should return 400 for streaming rotate body" - ); - assert!( - matches!( - err.current_context(), - TrustedServerError::BadRequest { message } - if message == "rotate-key request body must be buffered, not streamed" - ), - "should explain that rotate bodies must be buffered" - ); - } - - #[test] - fn deactivate_key_rejects_oversized_body() { - let settings = crate::test_support::tests::create_test_settings(); - let oversized = "x".repeat(ADMIN_MAX_BODY_BYTES + 1); - let req = build_request( - Method::POST, - "https://test.com/admin/keys/deactivate", - Some(&oversized), - ); - let err = handle_deactivate_key(&settings, &noop_services(), req) - .expect_err("should reject oversized body"); - assert_eq!( - err.current_context().status_code(), - StatusCode::PAYLOAD_TOO_LARGE, - "should return 413 for deactivate-key body over limit" - ); - } - #[test] fn validate_kid_accepts_allowed_operator_supplied_ids() { validate_kid("azAZ09-_.:").expect("should accept allowed kid characters"); @@ -1115,7 +536,7 @@ mod tests { fn validate_kid_rejects_non_lowercase_leading_ids() { // The Spin variable encoder requires a lowercase ASCII leading character, // so uppercase- and punctuation-leading KIDs that pass validate_kid_format - // must be rejected on the create/rotate path to avoid a runtime 5xx on Spin. + // must be rejected for a new key, which Spin could not store. for kid in &["KidA", "_kid", "-kid", ".kid", ":kid"] { assert!( validate_kid(kid).is_err(), @@ -1127,63 +548,32 @@ mod tests { } #[test] - fn validate_kid_format_allows_digit_leading_for_legacy_removal() { - // Deactivation/deletion uses validate_kid_format so digit-leading KIDs - // created under earlier rules stay removable, while the stricter - // validate_kid still blocks them on the create/rotate path. + fn validate_kid_format_allows_digit_leading_ids() { + // The leading-letter rule belongs to validate_kid alone, so the + // structural check accepts a digit-leading kid that a new key may not + // take. for kid in &["2026-key", "0abc", "9xyz", "1-key"] { validate_kid_format(kid) - .unwrap_or_else(|e| panic!("format check should accept legacy kid {kid}: {e:?}")); + .unwrap_or_else(|e| panic!("format check should accept kid {kid}: {e:?}")); assert!( validate_kid(kid).is_err(), - "create/rotate validation must still reject digit-leading kid: {kid}" + "new-key validation must still reject digit-leading kid: {kid}" ); } } #[test] - fn deactivate_allows_legacy_digit_leading_kid_past_validation() { - // A digit-leading legacy kid must pass validation and reach storage - // (which fails with the noop test stores -> 500) rather than being - // rejected up front as a 400 bad request. - let settings = crate::test_support::tests::create_test_settings(); - let req_body = DeactivateKeyRequest { - kid: "1legacy-key".to_string(), - delete: true, - }; - let body_json = - serde_json::to_string(&req_body).expect("should serialize deactivate request"); - let req = build_request( - Method::POST, - "https://test.com/admin/keys/deactivate", - Some(&body_json), - ); - - let resp = handle_deactivate_key(&settings, &noop_services(), req) - .expect("should return a response for a legacy digit-leading kid"); - - assert_ne!( - resp.status(), - StatusCode::BAD_REQUEST, - "legacy digit-leading kid must not be rejected as a bad request on deactivation" - ); - } - - #[test] - fn test_rotate_key_request_deserialization() { - let json = r#"{"kid":"custom-key"}"#; - let req: RotateKeyRequest = - serde_json::from_str(json).expect("should deserialize rotate key request"); - assert_eq!(req.kid, Some("custom-key".to_string())); - } - - #[test] - fn test_deactivate_key_request_deserialization() { - let json = r#"{"kid":"old-key","delete":true}"#; - let req: DeactivateKeyRequest = - serde_json::from_str(json).expect("should deserialize deactivate key request"); - assert_eq!(req.kid, "old-key"); - assert!(req.delete); + fn a_well_formed_kid_need_not_be_one_a_new_key_may_take() { + for kid in ["2026-key", "KidA", "-kid", "ts-2026-01-01"] { + assert!(kid_is_well_formed(kid), "should accept {kid}"); + } + for kid in ["2026-key", "KidA", "-kid"] { + assert!(!kid_is_creatable(kid), "should not create {kid}"); + } + let too_long = "a".repeat(129); + for kid in ["", "kid-a,kid-b", "kid a", "kid/a", &too_long] { + assert!(!kid_is_well_formed(kid), "should refuse {kid:?}"); + } } #[test] diff --git a/crates/trusted-server-core/src/request_signing/mod.rs b/crates/trusted-server-core/src/request_signing/mod.rs index 65f97dcb59..8e0d20c1f7 100644 --- a/crates/trusted-server-core/src/request_signing/mod.rs +++ b/crates/trusted-server-core/src/request_signing/mod.rs @@ -17,8 +17,8 @@ //! [`crate::platform::PlatformConfigStore::put`] / //! [`crate::platform::PlatformConfigStore::delete`] and //! [`crate::platform::PlatformSecretStore::create`] / -//! [`crate::platform::PlatformSecretStore::delete`]. These identifiers come -//! from the request-signing settings in `trusted-server.toml`. +//! [`crate::platform::PlatformSecretStore::delete`]. `ts keys` takes these +//! identifiers as `--config-store-id` and `--secret-store-id`. use std::sync::LazyLock; diff --git a/crates/trusted-server-core/src/request_signing/rotation.rs b/crates/trusted-server-core/src/request_signing/rotation.rs index 2d7222b705..e971ff6d2f 100644 --- a/crates/trusted-server-core/src/request_signing/rotation.rs +++ b/crates/trusted-server-core/src/request_signing/rotation.rs @@ -167,8 +167,7 @@ impl KeyRotationManager { signing_key: &SigningKey, ) -> Result<(), Report> { // The platform secret-store write interface is string-based, so signing - // keys are persisted as base64 text. The Fastly adapter applies its own - // transport-level base64 encoding when calling the management API. + // keys are persisted as base64 text. let key_b64 = general_purpose::STANDARD.encode(signing_key.as_bytes()); services diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index d04facc902..301b8f799f 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -234,11 +234,6 @@ mod tests { fn settings_with_response_headers(headers: &[(&str, &str)]) -> Settings { let mut s = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 395ac0dd22..388f2b14c0 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1570,109 +1570,36 @@ impl Rewrite { } } -#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] -#[serde(deny_unknown_fields)] -pub struct Handler { - #[serde(serialize_with = "crate::redacted::sensitive")] - #[validate(length(min = 1), custom(function = validate_path))] - pub path: String, - #[validate(custom(function = validate_redacted_not_empty))] - pub username: Redacted, - #[validate(custom(function = validate_redacted_not_empty))] - pub password: Redacted, - #[serde(skip, default)] - #[validate(skip)] - regex: OnceLock>, -} - -impl Handler { - /// Known handler password placeholders that must not be used in deployments. - pub const PASSWORD_PLACEHOLDERS: &[&str] = &[ - "replace-with-admin-password-32-bytes", - "replace-with-admin-password", - "change-me-admin-password", - ]; - - /// Returns `true` if `password` matches a known placeholder value - /// (case-insensitive). - #[must_use] - pub fn is_placeholder_password(password: &str) -> bool { - let password = password.trim(); - Self::PASSWORD_PLACEHOLDERS - .iter() - .any(|placeholder| placeholder.eq_ignore_ascii_case(password)) - } - - fn compiled_regex(&self) -> Result<&Regex, Report> { - match self - .regex - .get_or_init(|| Regex::new(&self.path).map_err(|err| err.to_string())) - { - Ok(regex) => Ok(regex), - Err(message) => Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Handler path regex `{}` failed to compile: {message}", - self.path - ), - })), - } - } - - /// Eagerly compile the handler regex to fail fast during startup. - /// - /// # Errors - /// - /// Returns a configuration error if the handler path regex does not compile. - pub fn prepare_runtime(&self) -> Result<(), Report> { - self.compiled_regex().map(|_| ()) - } - - /// Determine whether this handler applies to the request path. - /// - /// # Errors - /// - /// Returns a configuration error if the handler path regex does not compile. - pub fn matches_path(&self, path: &str) -> Result> { - self.compiled_regex().map(|regex| regex.is_match(path)) - } -} - #[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct RequestSigning { #[serde(default = "default_request_signing_enabled")] pub enabled: bool, - #[serde(serialize_with = "crate::redacted::sensitive")] - pub config_store_id: String, - #[serde(serialize_with = "crate::redacted::sensitive")] - pub secret_store_id: String, + /// The removed `config_store_id`. The field never holds a value, because + /// reading one always fails. + #[serde(default, skip_serializing)] + #[allow( + dead_code, + reason = "the field exists so that reading the removed key fails with directions" + )] + config_store_id: RemovedSigningStoreId, + /// The removed `secret_store_id`, refused as `config_store_id` is. + #[serde(default, skip_serializing)] + #[allow( + dead_code, + reason = "the field exists so that reading the removed key fails with directions" + )] + secret_store_id: RemovedSigningStoreId, } impl RequestSigning { - /// Reserved example store-id values from the config template, plus the - /// empty string, that must not be deployed while request signing is enabled. - pub const STORE_ID_PLACEHOLDERS: &[&str] = &[ - "", - "", - ]; - - /// Returns `true` if `store_id` is empty or a known template placeholder - /// (case-insensitive). - #[must_use] - pub fn is_placeholder_store_id(store_id: &str) -> bool { - let store_id = store_id.trim(); - store_id.is_empty() - || Self::STORE_ID_PLACEHOLDERS - .iter() - .any(|p| p.eq_ignore_ascii_case(store_id)) - } - - /// Returns `true` if `store_id` cannot be deployed as-is: a placeholder, or - /// a value with surrounding whitespace that the key-management routes would - /// forward to the management API verbatim. + /// Request signing switched on or off. #[must_use] - pub fn is_unusable_store_id(store_id: &str) -> bool { - Self::is_placeholder_store_id(store_id) || store_id != store_id.trim() + pub fn new(enabled: bool) -> Self { + Self { + enabled, + ..Self::default() + } } } @@ -2696,14 +2623,6 @@ fn default_certificate_check() -> bool { true } -fn is_admin_placeholder_password(password: &str) -> bool { - Handler::is_placeholder_password(password) - || matches!( - password.trim().to_ascii_lowercase().as_str(), - "changeme" | "password" | "admin" - ) -} - impl Default for Proxy { fn default() -> Self { Self { @@ -3663,6 +3582,25 @@ refused_table! { [.]" } +refused_table! { + /// The `[[handlers]]` list, which gated paths behind HTTP Basic + /// authentication. + RemovedHandlers => "Configuration list `[[handlers]]` is not read, and no request is \ + asked for a password. Remove every `[[handlers]]` entry. Nothing is served beneath \ + /_ts/admin, so rotate signing keys with `ts keys`, purge the cache with \ + `ts cache purge`, and put a password on a path of the publisher's own at its origin \ + or in the hosting platform" +} + +refused_table! { + /// A store id under `[request_signing]`, which the service does not read. + RemovedSigningStoreId => "`[request_signing]` does not take `config_store_id` or \ + `secret_store_id`. The service reads its signing keys from the stores linked as \ + `jwks_store` and `signing_keys` and writes neither. Remove both keys, and give the \ + ids to `ts keys rotate` and `ts keys deactivate` as `--config-store-id` and \ + `--secret-store-id`" +} + refused_table! { /// The `[permission_signal]` table, renamed `[permission-signal]`. RenamedPermissionSignalTable => "Configuration table `[permission_signal]` is now \ @@ -3997,9 +3935,14 @@ pub struct Settings { /// `[cmp]` or `[tag]`, each named for the type of module it selects. #[serde(flatten)] pub sections: TypeSections, - #[serde(default, deserialize_with = "vec_from_seq_or_map")] - #[validate(nested)] - pub handlers: Vec, + /// The `[[handlers]]` list, kept so a configuration carrying one is + /// told that no path is gated by a password. + #[serde(default, skip_serializing)] + #[allow( + dead_code, + reason = "the field exists so that reading the removed list fails with directions" + )] + handlers: RemovedHandlers, #[serde(default, deserialize_with = "map_from_obj_or_str")] pub response_headers: HashMap, pub request_signing: Option, @@ -4273,8 +4216,6 @@ impl Settings { settings .geo .validate_jurisdiction_acknowledgment(&settings.ec)?; - settings.validate_admin_coverage()?; - settings.validate_admin_handler_passwords()?; Ok(settings) } @@ -4297,10 +4238,6 @@ impl Settings { .validate_metadata_keys()?; self.validate_asset_image_optimizer_profile_sets()?; - for handler in &self.handlers { - handler.prepare_runtime()?; - } - if let Some(co) = &mut self.creative_opportunities { co.compile_slots(); // Parse `gam_unit_path` templates once here (mirrors the compiled @@ -4396,11 +4333,6 @@ impl Settings { )); } } - for handler in &self.handlers { - if Handler::is_placeholder_password(handler.password.expose()) { - insecure_fields.push(format!("handlers[{}].password", handler.path)); - } - } if Publisher::is_placeholder_domain(&self.publisher.domain) { insecure_fields.push("publisher.domain".to_owned()); } @@ -4410,22 +4342,6 @@ impl Settings { if Publisher::is_placeholder_origin_url(&self.publisher.origin_url) { insecure_fields.push("publisher.origin_url".to_owned()); } - // Checked whenever the block is present, not just when it is enabled: - // the key rotate/deactivate admin routes are registered unconditionally - // and read these store IDs without consulting `enabled`, so placeholder - // IDs behind a disabled block would still reach key management at - // runtime. Surrounding whitespace is rejected too: the placeholder check - // trims for comparison but the raw value is what `signing_store_ids` - // forwards to `KeyRotationManager`, so a padded id would validate yet - // reach the management API unusable. - if let Some(request_signing) = &self.request_signing { - if RequestSigning::is_unusable_store_id(&request_signing.config_store_id) { - insecure_fields.push("request_signing.config_store_id".to_owned()); - } - if RequestSigning::is_unusable_store_id(&request_signing.secret_store_id) { - insecure_fields.push("request_signing.secret_store_id".to_owned()); - } - } if insecure_fields.is_empty() { return Ok(()); @@ -4480,159 +4396,6 @@ impl Settings { self.proxy.asset_route_for_path(path) } - /// Resolve the first handler whose regex matches the request path. - /// - /// # Errors - /// - /// Returns a configuration error if any handler regex does not compile. - pub fn handler_for_path( - &self, - path: &str, - ) -> Result, Report> { - for handler in &self.handlers { - if handler.matches_path(path)? { - return Ok(Some(handler)); - } - } - - Ok(None) - } - - /// Returns whether `path` is within the reserved Trusted Server admin - /// namespace. - #[must_use] - pub(crate) fn is_admin_path(path: &str) -> bool { - path == "/_ts/admin" || path.starts_with("/_ts/admin/") - } - - /// Known admin endpoint paths that must be covered by a handler. - /// - /// [`from_toml`](Self::from_toml) rejects configurations - /// where any of these paths lack a matching handler, ensuring admin - /// endpoints are always protected by authentication. - /// Update [`ADMIN_ENDPOINTS`](Self::ADMIN_ENDPOINTS) when adding new - /// admin routes to `crates/trusted-server-adapter-fastly/src/app.rs`. - /// - /// The `/_ts/admin/ec/{id}` entry is the canonical router pattern. Its - /// coverage is checked via [`admin_auth_probes`](Self::admin_auth_probes), - /// while validation errors continue to report this operator-facing route - /// template. - pub(crate) const ADMIN_ENDPOINTS: &[&str] = &[ - "/_ts/admin/keys/rotate", - "/_ts/admin/keys/deactivate", - "/_ts/admin/ec", - "/_ts/admin/ec/{id}", - "/_ts/admin/eids", - "/_ts/admin/cache/purge", - ]; - - /// Probes that establish handler coverage for the dynamic - /// `/_ts/admin/ec/{id}` route. - /// - /// Coverage cannot be sampled: the router accepts any single segment after - /// `/_ts/admin/ec/` and basic auth runs on the raw path before routing, so - /// a handler that matches only some ID shapes leaves the rest of the route - /// surface — including malformed IDs, which still reach the admin handler — - /// unauthenticated at configuration time and fail-closed at runtime. - /// - /// Both probes must match the same configuration for the route to count as - /// covered. The bare prefix rejects handlers anchored to specific ID - /// shapes; the concrete ID rejects handlers anchored to the prefix itself - /// (`^/_ts/admin/ec/$`). Together they admit only prefix-level matchers - /// such as `^/_ts/admin` or `^/_ts/admin/ec/`. - const ADMIN_EC_ID_AUTH_PROBES: [&str; 2] = [ - "/_ts/admin/ec/", - concat!( - "/_ts/admin/ec/", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ".Ab12Z9", - ), - ]; - - fn admin_auth_probes(path: &'static str) -> [&'static str; 2] { - match path { - "/_ts/admin/ec/{id}" => Self::ADMIN_EC_ID_AUTH_PROBES, - path => [path, path], - } - } - - /// Returns admin endpoint paths that no configured handler covers. - /// - /// Called during settings finalization to enforce that every admin endpoint - /// has a handler. An empty return - /// value means all admin endpoints are properly covered. - /// - /// # Errors - /// - /// Returns [`TrustedServerError::Configuration`] if any handler has an invalid path regex. - pub(crate) fn uncovered_admin_endpoints( - &self, - ) -> Result, Report> { - let mut uncovered = Vec::new(); - for &path in Self::ADMIN_ENDPOINTS { - let mut covered = true; - for probe in Self::admin_auth_probes(path) { - let mut probe_covered = false; - for handler in &self.handlers { - if handler.matches_path(probe)? { - probe_covered = true; - break; - } - } - covered &= probe_covered; - } - if !covered { - uncovered.push(path); - } - } - Ok(uncovered) - } - - /// Validates that every admin endpoint is covered by at least one handler. - /// - /// # Errors - /// - /// Returns [`TrustedServerError::Configuration`] listing any uncovered - /// admin endpoints. - pub(crate) fn validate_admin_coverage(&self) -> Result<(), Report> { - let uncovered = self.uncovered_admin_endpoints()?; - if uncovered.is_empty() { - return Ok(()); - } - Err(Report::new(TrustedServerError::Configuration { - message: format!( - "No handler covers admin endpoint(s): {}. \ - Add a [[handlers]] entry with a path regex matching /_ts/admin/ \ - to protect admin access.", - uncovered.join(", ") - ), - })) - } - - /// Rejects placeholder and well-known weak handler passwords. - /// - /// Applies to every handler rather than to handlers inferred to cover an - /// admin endpoint: handler selection is first-match-wins over operator - /// regexes, so a narrow handler can shadow the admin namespace for paths no - /// probe enumerates. Handlers are Trusted Server's own basic-auth gates, so - /// a placeholder password is never valid on any of them. - pub(crate) fn validate_admin_handler_passwords( - &self, - ) -> Result<(), Report> { - for handler in &self.handlers { - if is_admin_placeholder_password(handler.password.expose()) { - return Err(Report::new(TrustedServerError::Configuration { - message: format!( - "Handler `{}` uses a placeholder password; configure a strong secret", - handler.path - ), - })); - } - } - - Ok(()) - } - /// Every section that selects modules, with its name: `[proxy]`, /// `[auction]` and each module type's own section. pub fn module_sections(&self) -> impl Iterator { @@ -5014,14 +4777,6 @@ fn validate_proxy_origin_url(value: &str) -> Result<(), ValidationError> { Ok(()) } -fn validate_path(value: &str) -> Result<(), ValidationError> { - Regex::new(value).map(|_| ()).map_err(|err| { - let mut validation_error = ValidationError::new("invalid_regex"); - validation_error.add_param("value".into(), &value); - validation_error.add_param("message".into(), &err.to_string()); - validation_error - }) -} fn from_value_or_str<'de, D, T>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -5435,7 +5190,7 @@ module = \"none\"", // hand-written `Debug` impl prints only integration IDs, never values. // // Do not use `..Struct::default()` anywhere in this function. A default - // spread would let a new secret field be added to `Handler`, + // spread would let a new secret field be added to `EcPartner`, // `TinybirdSettings`, or any other struct built here without forcing // anyone to consider it. The compile break is the prompt; the canary // list below is still maintained by hand. List every field explicitly. @@ -5443,8 +5198,6 @@ module = \"none\"", fn settings_debug_output_redacts_every_secret_field() { const CANARY_PROXY_SECRET: &str = "CANARY-PROXY-SECRET-0123456789"; const CANARY_EC_PASSPHRASE: &str = "CANARY-EC-PASSPHRASE-0123456789"; - const CANARY_HANDLER_USERNAME: &str = "CANARY-HANDLER-USERNAME-0123456789"; - const CANARY_HANDLER_PASSWORD: &str = "CANARY-HANDLER-PASSWORD-0123456789"; const CANARY_EC_PARTNER_API_TOKEN: &str = "CANARY-EC-PARTNER-API-TOKEN-0123456789"; const CANARY_EC_PARTNER_TS_PULL_TOKEN: &str = "CANARY-EC-PARTNER-TS-PULL-TOKEN-0123456789"; const CANARY_TRUSTED_CLIENT_IP_SHARED_SECRET: &str = @@ -5461,13 +5214,6 @@ module = \"none\"", settings.publisher.proxy_secret = Redacted::new(CANARY_PROXY_SECRET.to_string()); select_hmac_module(&mut settings.ec, HMAC_MODULE_KEY, CANARY_EC_PASSPHRASE); - settings.handlers = vec![Handler { - path: "^/secure".to_string(), - username: Redacted::new(CANARY_HANDLER_USERNAME.to_string()), - password: Redacted::new(CANARY_HANDLER_PASSWORD.to_string()), - regex: OnceLock::new(), - }]; - settings.ec.partners = vec![EcPartner { name: "canary-partner".to_string(), source_domain: "canary-partner.example".to_string(), @@ -5533,15 +5279,13 @@ module = \"none\"", "should redact secret fields in Settings debug output" ); assert!( - debug.contains("^/secure"), - "should leave non-secret handler path visible in debug output" + debug.contains("canary-partner.example"), + "should leave a non-secret value visible in debug output" ); let canaries = [ ("publisher.proxy_secret", CANARY_PROXY_SECRET), ("ec.hmac.passphrase", CANARY_EC_PASSPHRASE), - ("handlers[].username", CANARY_HANDLER_USERNAME), - ("handlers[].password", CANARY_HANDLER_PASSWORD), ("ec.partners[].api_token", CANARY_EC_PARTNER_API_TOKEN), ( "ec.partners[].ts_pull_token", @@ -5979,6 +5723,140 @@ module = \"none\"", } } + /// The shared fixture with one `[[handlers]]` entry ahead of everything + /// else, as a configuration written for a build that asked for passwords + /// carries it. + fn settings_str_with_a_handler() -> String { + format!( + "[[handlers]]\npath = \"^/_ts/admin\"\nusername = \"admin\"\n\ + password = \"admin-pass\"\n{}", + crate_test_settings_str() + ) + } + + #[test] + fn toml_settings_refuse_a_handlers_list_with_directions() { + let error = Settings::from_toml(&settings_str_with_a_handler()) + .expect_err("should refuse a configuration that still gates a path"); + + let rendered = format!("{error:?}"); + assert!( + rendered.contains("`[[handlers]]` is not read"), + "should say the list is not read: {rendered}" + ); + assert!( + rendered.contains("ts keys") && rendered.contains("ts cache purge"), + "should say where administration went: {rendered}" + ); + } + + #[test] + fn json_settings_refuse_a_handlers_list_with_directions() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should load the test settings fixture"); + let mut value = serde_json::to_value(settings) + .expect("should serialize the test settings fixture to JSON"); + value["handlers"] = json!([ + { "path": "^/secure", "username": "user", "password": "handler_password" }, + ]); + + let error = Settings::from_json_value(value) + .expect_err("should refuse a stored document that still gates a path"); + + assert!( + format!("{error:?}").contains("`[[handlers]]` is not read"), + "should say the list is not read: {error:?}" + ); + } + + #[test] + fn an_empty_handlers_list_is_refused_too() { + let toml = format!("handlers = []\n{}", crate_test_settings_str()); + + let error = Settings::from_toml(&toml).expect_err("should refuse the key itself"); + + assert!( + format!("{error:?}").contains("`[[handlers]]` is not read"), + "should say the list is not read: {error:?}" + ); + } + + #[test] + fn settings_are_written_without_a_handlers_list() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should load the test settings fixture"); + + let value = serde_json::to_value(&settings).expect("should serialize the settings"); + + assert!( + value.get("handlers").is_none(), + "should write nothing a later read would refuse" + ); + } + + #[test] + fn toml_settings_refuse_a_signing_store_id_with_directions() { + for key in ["config_store_id", "secret_store_id"] { + let fixture = crate_test_settings_str(); + assert!( + fixture.contains("[request_signing]\n"), + "the fixture should have the section the key is added to" + ); + let toml = fixture.replace( + "[request_signing]\n", + &format!("[request_signing]\n{key} = \"01GEXAMPLE\"\n"), + ); + + let error = Settings::from_toml(&toml).expect_err("should refuse the removed key"); + + let rendered = format!("{error:?}"); + assert!( + rendered.contains("does not take `config_store_id` or `secret_store_id`"), + "should say the key is not read, for {key}: {rendered}" + ); + assert!( + rendered.contains("ts keys rotate"), + "should say where the ids go, for {key}: {rendered}" + ); + } + } + + #[test] + fn json_settings_refuse_a_signing_store_id_with_directions() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should load the test settings fixture"); + let mut value = serde_json::to_value(settings) + .expect("should serialize the test settings fixture to JSON"); + value["request_signing"]["secret_store_id"] = json!("01GEXAMPLE"); + + let error = Settings::from_json_value(value).expect_err("should refuse the removed key"); + + assert!( + format!("{error:?}").contains("--secret-store-id"), + "should say where the id goes: {error:?}" + ); + } + + #[test] + fn request_signing_is_written_as_enabled_alone() { + let mut settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should load the test settings fixture"); + settings.request_signing = Some(RequestSigning::new(true)); + + let value = serde_json::to_value(&settings).expect("should serialize the settings"); + + assert_eq!( + value["request_signing"], + json!({ "enabled": true }), + "should write nothing a later read would refuse" + ); + let read = Settings::from_json_value(value).expect("should read what was written"); + assert!( + read.request_signing.is_some_and(|signing| signing.enabled), + "should keep request signing on across the round trip" + ); + } + #[test] fn json_settings_rejects_legacy_auction_provider_list_with_migration_guidance() { let settings = Settings::from_toml(&crate_test_settings_str()) @@ -7717,18 +7595,6 @@ origin_host_header_overide = "www.example.com""#, } } - #[test] - fn prepare_runtime_rejects_invalid_handler_regex() { - let toml_str = crate_test_settings_str().replace(r#"path = "^/secure""#, r#"path = "(""#); - - let err = Settings::from_toml(&toml_str).expect_err("should reject invalid handler regex"); - assert!( - err.to_string() - .contains("Handler path regex `(` failed to compile"), - "should describe the invalid handler regex" - ); - } - #[test] fn test_settings_missing_required_fields() { let re = Regex::new(r"origin_url = .*").expect("regex should compile"); @@ -7936,36 +7802,6 @@ source_domain = "partner.example.com" ); } - #[test] - fn is_placeholder_handler_password_rejects_known_template_value() { - assert!( - Handler::is_placeholder_password("replace-with-admin-password-32-bytes"), - "init-template handler password should be rejected" - ); - } - - #[test] - fn reject_placeholder_secrets_includes_handler_passwords() { - let mut settings = - Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); - settings.publisher.proxy_secret = Redacted::new("unit-test-proxy-secret".to_owned()); - select_hmac_module( - &mut settings.ec, - HMAC_MODULE_KEY, - "test-secret-key-32-bytes-minimum", - ); - settings.handlers[0].password = - Redacted::new("replace-with-admin-password-32-bytes".to_owned()); - - let err = settings - .reject_placeholder_secrets() - .expect_err("should reject placeholder handler password"); - assert!( - format!("{err:?}").contains("handlers"), - "error should mention handler password field" - ); - } - fn test_partner_with_pull_token(ts_pull_token: &str) -> EcPartner { test_partner_with_tokens(None, ts_pull_token) } @@ -8043,26 +7879,6 @@ source_domain = "partner.example.com" ); } - #[test] - fn is_unusable_store_id_rejects_placeholders_empty_and_padded_values() { - for placeholder in RequestSigning::STORE_ID_PLACEHOLDERS { - assert!( - RequestSigning::is_unusable_store_id(placeholder), - "should reject placeholder store id '{placeholder}'" - ); - } - for bad in ["", " ", " 01GCFG ", "01GCFG "] { - assert!( - RequestSigning::is_unusable_store_id(bad), - "should reject unusable store id '{bad}'" - ); - } - assert!( - !RequestSigning::is_unusable_store_id("01GCFG"), - "should accept a clean store id" - ); - } - #[test] fn test_settings_empty_toml() { let toml_str = ""; @@ -8091,83 +7907,6 @@ source_domain = "partner.example.com" assert!(settings.is_err(), "Should fail when sections are missing"); } - #[test] - fn test_handlers_override_with_env() { - let toml_str = crate_test_settings_str(); - - let origin_key = format!( - "{}{}PUBLISHER{}ORIGIN_URL", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - // Override handler 0 via env vars - let path_key_0 = format!( - "{}{}HANDLERS{}0{}PATH", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let username_key_0 = format!( - "{}{}HANDLERS{}0{}USERNAME", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let password_key_0 = format!( - "{}{}HANDLERS{}0{}PASSWORD", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - // Admin handler at index 1 (required for admin endpoint coverage) - let path_key_1 = format!( - "{}{}HANDLERS{}1{}PATH", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let username_key_1 = format!( - "{}{}HANDLERS{}1{}USERNAME", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - let password_key_1 = format!( - "{}{}HANDLERS{}1{}PASSWORD", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - - temp_env::with_vars( - [ - (origin_key, Some("https://origin.test-publisher.com")), - (path_key_0, Some("^/env-handler")), - (username_key_0, Some("env-user")), - (password_key_0, Some("env-pass")), - (path_key_1, Some("^/_ts/admin")), - (username_key_1, Some("admin")), - (password_key_1, Some("admin-pass")), - ], - || { - let settings = - Settings::from_toml_and_env(&toml_str).expect("Settings should load from env"); - assert_eq!(settings.handlers.len(), 2); - let handler = &settings.handlers[0]; - assert_eq!(handler.path, "^/env-handler"); - assert_eq!(handler.username.expose(), "env-user"); - assert_eq!(handler.password.expose(), "env-pass"); - }, - ); - } - #[test] fn test_ec_partners_override_with_indexed_env() { let toml_str = crate_test_settings_str(); @@ -8312,8 +8051,10 @@ source_domain = "partner.example.com" ); } + /// An environment variable can give a handler to a configuration that + /// carries none, and that is refused as the list itself is. #[test] - fn test_invalid_handler_override_fails_during_runtime_preparation() { + fn a_handler_given_by_an_environment_variable_is_refused_too() { let toml_str = crate_test_settings_str(); let origin_key = format!( @@ -8334,9 +8075,14 @@ source_domain = "partner.example.com" origin_key, Some("https://origin.test-publisher.com"), || { - temp_env::with_var(path_key, Some("("), || { - let _ = Settings::from_toml_and_env(&toml_str) - .expect_err("should reject invalid handler regex override"); + temp_env::with_var(path_key, Some("^/secure"), || { + let error = Settings::from_toml_and_env(&toml_str) + .expect_err("should refuse a handler an environment variable gives"); + + assert!( + format!("{error:?}").contains("`[[handlers]]` is not read"), + "should say the list is not read: {error:?}" + ); }); }, ); @@ -8610,11 +8356,6 @@ source_domain = "partner.example.com" let from_toml = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "example.com" cookie_domain = ".example.com" @@ -8646,11 +8387,6 @@ source_domain = "partner.example.com" // silently breaking traffic. let result = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "example.com" cookie_domain = ".example.com" @@ -10146,8 +9882,6 @@ source_domain = "partner.example.com" ); } - // --- admin endpoint coverage --- - #[test] fn test_publisher_rejects_cookie_domain_with_metacharacters() { for bad_domain in [ @@ -10174,356 +9908,9 @@ source_domain = "partner.example.com" ); } - /// Helper that returns a settings TOML string WITHOUT any admin handler, - /// for tests that need to verify uncovered-admin-endpoint behaviour. - fn settings_str_without_admin_handler() -> String { - r#" - [[handlers]] - path = "^/secure" - username = "user" - password = "pass" - - [publisher] - domain = "test-publisher.com" - cookie_domain = ".test-publisher.com" - origin_url = "https://origin.test-publisher.com" - proxy_secret = "unit-test-proxy-secret" - - [ec] - module = "hmac" - - [ec.hmac] - passphrase = "test-secret-key-32-bytes-minimum" - - [geo] - assume_single_jurisdiction = true - - [request_signing] - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" - "# - .to_string() - } - - #[test] - fn uncovered_admin_endpoints_returns_all_when_no_handler_covers_admin() { - // Deserialize directly to bypass from_toml's admin validation, - // since this test exercises uncovered_admin_endpoints itself. - let settings: Settings = - toml::from_str(&settings_str_without_admin_handler()).expect("should deserialize TOML"); - let uncovered = settings - .uncovered_admin_endpoints() - .expect("should check admin coverage"); - assert_eq!( - uncovered, - vec![ - "/_ts/admin/keys/rotate", - "/_ts/admin/keys/deactivate", - "/_ts/admin/ec", - "/_ts/admin/ec/{id}", - "/_ts/admin/eids", - "/_ts/admin/cache/purge", - ], - "should report every admin endpoint as uncovered" - ); - } - - #[test] - fn uncovered_admin_endpoints_returns_empty_when_handler_covers_admin() { - let settings = create_test_settings(); - let uncovered = settings - .uncovered_admin_endpoints() - .expect("should check admin coverage"); - assert!( - uncovered.is_empty(), - "should report no uncovered admin endpoints when handler covers /_ts/admin" - ); - } - - #[test] - fn uncovered_admin_endpoints_detects_partial_coverage() { - let toml_str = settings_str_without_admin_handler() - + r#" - [[handlers]] - path = "^/_ts/admin/keys/rotate$" - username = "admin" - password = "secret" - "#; - // Deserialize directly to bypass from_toml's admin validation, - // since this test exercises uncovered_admin_endpoints itself. - let settings: Settings = toml::from_str(&toml_str).expect("should deserialize TOML"); - let uncovered = settings - .uncovered_admin_endpoints() - .expect("should check admin coverage"); - assert_eq!( - uncovered, - vec![ - "/_ts/admin/keys/deactivate", - "/_ts/admin/ec", - "/_ts/admin/ec/{id}", - "/_ts/admin/eids", - "/_ts/admin/cache/purge", - ], - "should detect the admin endpoints not covered by the narrow handler" - ); - } - - #[test] - fn from_toml_rejects_literal_parameter_template_auth_coverage() { - let toml_str = crate_test_settings_str().replace( - r#"path = "^/_ts/admin" - username = "admin" - password = "admin-pass""#, - r#"path = "^/_ts/admin/(keys/rotate|keys/deactivate|ec|eids|cache/purge)$" - username = "admin" - password = "strong-test-password" - - [[handlers]] - path = "^/_ts/admin/ec/[{]id[}]$" - username = "admin" - password = "strong-test-password""#, - ); - - let error = Settings::from_toml(&toml_str) - .expect_err("should reject literal parameter-template auth coverage"); - let message = format!("{error:?}"); - assert!( - message.contains("/_ts/admin/ec/{id}"), - "should identify the concrete EC route as uncovered, got: {message}" - ); - } - - #[test] - fn from_toml_rejects_lowercase_only_dynamic_admin_ec_auth_coverage() { - let toml_str = crate_test_settings_str().replace( - r#"path = "^/_ts/admin" - username = "admin" - password = "admin-pass""#, - r#"path = "^/_ts/admin/(keys/rotate|keys/deactivate|ec|eids|cache/purge)$" - username = "admin" - password = "strong-test-password" - - [[handlers]] - path = "^/_ts/admin/ec/[a-f0-9]{64}[.][a-z0-9]{6}$" - username = "admin" - password = "strong-test-password""#, - ); - - let error = Settings::from_toml(&toml_str) - .expect_err("should reject lowercase-only dynamic EC auth coverage"); - let message = format!("{error:?}"); - assert!( - message.contains("/_ts/admin/ec/{id}"), - "should identify the mixed-case EC route as uncovered, got: {message}" - ); - } - - #[test] - fn from_toml_rejects_placeholder_password_on_shadowing_admin_handler() { - // Handler selection is first-match-wins, so a narrow handler placed - // ahead of the admin matcher governs the EC IDs it matches. No probe - // enumerates those IDs, so the placeholder check cannot be limited to - // handlers inferred to cover an admin endpoint. - let toml_str = crate_test_settings_str().replace( - r#"path = "^/_ts/admin" - username = "admin" - password = "admin-pass""#, - r#"path = "^/_ts/admin/ec/[a-f0-9]{64}[.]zzzzzz$" - username = "admin" - password = "change-me-admin-password" - - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "strong-test-password""#, - ); - - let error = Settings::from_toml(&toml_str) - .expect_err("should reject placeholder password on shadowing admin handler"); - let message = format!("{error:?}"); - assert!( - message.contains("placeholder password"), - "should identify the placeholder handler password, got: {message}" - ); - } - - #[test] - fn from_toml_rejects_weak_password_on_non_admin_handler() { - let toml_str = crate_test_settings_str().replace( - r#"path = "^/_ts/admin" - username = "admin" - password = "admin-pass""#, - r#"path = "^/_ts/admin" - username = "admin" - password = "strong-test-password" - - [[handlers]] - path = "^/private" - username = "admin" - password = "changeme""#, - ); - - let error = Settings::from_toml(&toml_str) - .expect_err("should reject a weak password on any handler"); - let message = format!("{error:?}"); - assert!( - message.contains("placeholder password"), - "should identify the weak handler password, got: {message}" - ); - } - - #[test] - fn from_toml_rejects_sampled_id_only_dynamic_admin_ec_auth_coverage() { - // A handler anchored to the full EC ID grammar still leaves the rest of - // the route surface (malformed IDs, which the router accepts and the - // admin handler rejects with 400) unauthenticated, so coverage must not - // be inferred from ID-shaped samples. - let toml_str = crate_test_settings_str().replace( - r#"path = "^/_ts/admin" - username = "admin" - password = "admin-pass""#, - r#"path = "^/_ts/admin/(keys/rotate|keys/deactivate|ec|eids|cache/purge)$" - username = "admin" - password = "strong-test-password" - - [[handlers]] - path = "^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$" - username = "admin" - password = "strong-test-password""#, - ); - - let error = Settings::from_toml(&toml_str) - .expect_err("should reject ID-sampled dynamic EC auth coverage"); - let message = format!("{error:?}"); - assert!( - message.contains("/_ts/admin/ec/{id}"), - "should identify the dynamic EC route as uncovered, got: {message}" - ); - } - - #[test] - fn from_toml_rejects_prefix_anchored_admin_ec_auth_coverage() { - // `^/_ts/admin/ec/$` matches the prefix probe but no actual lookup. - let toml_str = crate_test_settings_str().replace( - r#"path = "^/_ts/admin" - username = "admin" - password = "admin-pass""#, - r#"path = "^/_ts/admin/(keys/rotate|keys/deactivate|ec|eids|cache/purge)$" - username = "admin" - password = "strong-test-password" - - [[handlers]] - path = "^/_ts/admin/ec/$" - username = "admin" - password = "strong-test-password""#, - ); - - let error = Settings::from_toml(&toml_str) - .expect_err("should reject prefix-anchored dynamic EC auth coverage"); - let message = format!("{error:?}"); - assert!( - message.contains("/_ts/admin/ec/{id}"), - "should identify the dynamic EC route as uncovered, got: {message}" - ); - } - - #[test] - fn from_toml_accepts_prefix_matcher_admin_ec_auth_coverage() { - let toml_str = crate_test_settings_str().replace( - r#"path = "^/_ts/admin" - username = "admin" - password = "admin-pass""#, - r#"path = "^/_ts/admin/(keys/rotate|keys/deactivate|ec|eids|cache/purge)$" - username = "admin" - password = "strong-test-password" - - [[handlers]] - path = "^/_ts/admin/ec/" - username = "admin" - password = "strong-test-password""#, - ); - - Settings::from_toml(&toml_str) - .expect("should accept a prefix-level matcher for the dynamic EC route"); - } - - #[test] - fn from_toml_and_env_rejects_config_without_admin_handler() { - let origin_key = format!( - "{}{}PUBLISHER{}ORIGIN_URL", - ENVIRONMENT_VARIABLE_PREFIX, - ENVIRONMENT_VARIABLE_SEPARATOR, - ENVIRONMENT_VARIABLE_SEPARATOR - ); - temp_env::with_var( - origin_key, - Some("https://origin.test-publisher.com"), - || { - let result = Settings::from_toml_and_env(&settings_str_without_admin_handler()); - assert!( - result.is_err(), - "should reject configuration when admin endpoints are not covered" - ); - let err = format!("{:?}", result.unwrap_err()); - assert!( - err.contains("No handler covers admin endpoint"), - "error should mention uncovered admin endpoints, got: {err}" - ); - }, - ); - } - - #[test] - fn from_toml_rejects_admin_handler_placeholder_password() { - let toml_str = crate_test_settings_str() - .replace(r#"password = "admin-pass""#, r#"password = "changeme""#); - - let result = Settings::from_toml(&toml_str); - assert!( - result.is_err(), - "should reject placeholder password on admin handler" - ); - let err = format!("{:?}", result.expect_err("should reject placeholder")); - assert!( - err.contains("placeholder password"), - "error should mention placeholder admin password, got: {err}" - ); - } - - #[test] - fn from_toml_accepts_non_placeholder_admin_password() { - let settings = Settings::from_toml(&crate_test_settings_str()) - .expect("should accept non-placeholder admin password"); - assert_eq!(settings.handlers.len(), 2, "should parse handlers"); - } - - #[test] - fn from_toml_rejects_config_without_admin_handler() { - let result = Settings::from_toml(&settings_str_without_admin_handler()); - assert!( - result.is_err(), - "should reject configuration when admin endpoints are not covered" - ); - let err = format!("{:?}", result.expect_err("should be an error")); - assert!( - err.contains("No handler covers admin endpoint"), - "error should mention uncovered admin endpoints, got: {err}" - ); - } - - /// Verifies that [`Settings::ADMIN_ENDPOINTS`] stays in sync with the - /// admin route table in `crates/trusted-server-adapter-fastly/src/app.rs`. - /// - /// If this test fails, a route was added or removed in the Fastly - /// router without updating `ADMIN_ENDPOINTS` (or vice versa). #[test] fn settings_parses_creative_opportunities_section() { let toml = r#" -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "unit-test-admin-secret" - [publisher] domain = "example.com" cookie_domain = ".example.com" @@ -10609,11 +9996,6 @@ formats = [{ width = 300, height = 250 }] #[test] fn settings_rejects_invalid_creative_opportunity_slot_id() { let toml = r#" -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "unit-test-admin-secret" - [publisher] domain = "example.com" cookie_domain = ".example.com" @@ -10651,11 +10033,6 @@ formats = [{ width = 300, height = 250 }] // id injected via env is rejected by from_toml_and_env (the build-time // path uses the same validation against the merged config). let toml = r#" -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "unit-test-admin-secret" - [publisher] domain = "example.com" cookie_domain = ".example.com" @@ -10699,11 +10076,6 @@ gam_network_id = "21765378893" fn creative_opportunity_settings_toml(slot_body: &str) -> String { format!( r#" -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "unit-test-admin-secret" - [publisher] domain = "example.com" cookie_domain = ".example.com" @@ -10839,48 +10211,6 @@ formats = [{{ width = 300, height = 250 }}] "an unset device selector should be omitted rather than serialized as null, got {json}" ); } - - #[test] - fn admin_endpoints_match_fastly_router() { - let router_source = include_str!("../../trusted-server-adapter-fastly/src/app.rs"); - - for endpoint in Settings::ADMIN_ENDPOINTS { - assert!( - router_source.contains(endpoint), - "ADMIN_ENDPOINTS lists \"{endpoint}\" but it was not found in \ - crates/trusted-server-adapter-fastly/src/app.rs — remove it from ADMIN_ENDPOINTS or \ - add the route back to the router" - ); - } - - // Also verify we haven't missed any admin routes in the router. - // Best-effort: only detects string-literal routes in the NamedRoute - // table. If you define admin routes differently (e.g. via constants), - // add them to ADMIN_ENDPOINTS manually. - let admin_routes_in_router: Vec<&str> = router_source - .lines() - .filter_map(|line| { - let trimmed = line.trim(); - // Route entries look like: path: "/_ts/admin/...", - if trimmed.starts_with("path: ") && trimmed.contains("\"/_ts/admin/") { - let start = trimmed.find("\"/_ts/admin/")?; - let rest = &trimmed[start + 1..]; - let end = rest.find('"')?; - Some(&rest[..end]) - } else { - None - } - }) - .collect(); - - for route in &admin_routes_in_router { - assert!( - Settings::ADMIN_ENDPOINTS.contains(route), - "Router has admin route \"{route}\" that is missing from \ - Settings::ADMIN_ENDPOINTS — add it to ensure auth coverage" - ); - } - } } #[cfg(test)] diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index d3cb084928..fe0d304883 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -9,16 +9,6 @@ pub mod tests { #[must_use] pub fn crate_test_settings_str() -> String { r#" - [[handlers]] - path = "^/secure" - username = "user" - password = "pass" - - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -42,8 +32,7 @@ pub mod tests { passphrase = "test-secret-key-32-bytes-minimum" [request_signing] - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" + enabled = false "# .to_owned() } @@ -247,10 +236,6 @@ pub mod template { #[must_use] pub fn template_with_resolved_required_secrets() -> String { EXAMPLE_TEMPLATE - .replace( - "password = \"handler_password\"", - "password = \"unit-test-resolved-handler-password-0001\"", - ) .replace( "proxy_secret = \"publisher_proxy_secret\"", "proxy_secret = \"unit-test-resolved-publisher-proxy-secret-0001\"", @@ -330,11 +315,6 @@ pub mod nextjs_auction { pub fn settings() -> Settings { let mut settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -718,11 +698,6 @@ pub mod policy_failure { pub fn load_error() -> Report { let mut settings = Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "handler_password" - [publisher] domain = "publisher.example" cookie_domain = ".publisher.example" diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index 5b8c643a94..a481949b59 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -1,8 +1,3 @@ -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "integration_admin_password" - [publisher] domain = "localhost" cookie_domain = "localhost" @@ -41,8 +36,6 @@ api_token = "integration_partner_token_bravo" [request_signing] enabled = false -config_store_id = "app_config" -secret_store_id = "secrets" # Only the GPT diagnostics overlay runs, which is what the browser tests need. # A settings table its section does not select refuses startup, so no other diff --git a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml index be4e008684..7034023059 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -59,10 +59,6 @@ key = "placeholder" data = "placeholder" - [[local_server.kv_stores.opid_store]] - key = "placeholder" - data = "placeholder" - [[local_server.kv_stores.creative_store]] key = "placeholder" data = "placeholder" @@ -108,13 +104,6 @@ key = "ts-2025-10-A" data = "NVnTYrw5xoyTJDOwoUWoPJO3A6UCCXOJJUzgGTxxx7k=" - [[local_server.secret_stores.api-keys]] - key = "api_key" - data = "test-api-key" - - [[local_server.secret_stores.trusted_server_secrets]] - key = "integration_admin_password" - data = "integration-admin-password-32-bytes-ok" [[local_server.secret_stores.trusted_server_secrets]] key = "integration_proxy_secret" data = "integration-test-proxy-secret-32-bytes-ok" diff --git a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs index d5c2e29bde..7b256c00e0 100644 --- a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs +++ b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs @@ -336,10 +336,6 @@ mod tests { fn integration_secret_store() -> IntegrationSecretStore { IntegrationSecretStore { values: HashMap::from([ - ( - "integration_admin_password".to_owned(), - b"integration-admin-password-32-bytes-ok".to_vec(), - ), ( "integration_proxy_secret".to_owned(), b"integration-test-proxy-secret-32-bytes-ok".to_vec(), diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index b21aec8cc3..5cf84e24e5 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -117,6 +117,51 @@ mod tests { ); } + /// The committed development environment files. + const DEVELOPMENT_FILES: [(&str, &str); 2] = [ + (".env.dev", include_str!("../../../../.env.dev")), + (".env.example", include_str!("../../../../.env.example")), + ]; + + /// Every store that an uncommented `*_STORE=` line of a development + /// file names is declared in `fastly.toml` and in the Viceroy template, so + /// no name points at a store the local runtime does not have. + #[test] + fn every_store_a_development_file_names_is_declared() { + for (config_name, config) in [ + ("fastly.toml", FASTLY_CONFIG), + ("Viceroy integration template", VICEROY_TEMPLATE), + ] { + let parsed: toml::Value = + toml::from_str(config).expect("should parse Fastly configuration"); + let local_server = &parsed["local_server"]; + let declares = |store: &str| { + ["kv_stores", "config_stores", "secret_stores"] + .into_iter() + .any(|kind| { + local_server + .get(kind) + .and_then(|stores| stores.get(store)) + .is_some() + }) + }; + + for (file_name, contents) in DEVELOPMENT_FILES { + for (variable, store) in contents + .lines() + .filter(|line| !line.trim_start().starts_with('#')) + .filter_map(|line| line.split_once('=')) + .filter(|(variable, _)| variable.trim().ends_with("_STORE")) + { + assert!( + declares(store.trim()), + "{file_name} sets {variable} to `{store}`, which {config_name} does not declare" + ); + } + } + } + } + #[test] fn local_fastly_secret_store_is_exposed_under_its_logical_id() { for (name, config) in [ @@ -152,11 +197,7 @@ mod tests { .as_array() .expect("fastly.toml should define trusted_server_secrets"); - for key in [ - "publisher_proxy_secret", - "ec_passphrase", - "handler_password", - ] { + for key in ["publisher_proxy_secret", "ec_passphrase"] { assert!( entries .iter() diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs index daf757a0f7..3a0e8f9b84 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -20,10 +20,6 @@ fn permissions_dir() -> PathBuf { /// Secret-store entries referenced by the integration app-config fixture. const INTEGRATION_SECRET_ENV: &[(&str, &str)] = &[ - ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_ADMIN_PASSWORD", - "integration-admin-password-32-bytes-ok", - ), ( "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PROXY_SECRET", "integration-test-proxy-secret-32-bytes-ok", diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index 33a6cfd8b7..12caa94efe 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -2,7 +2,7 @@ //! //! Sends identical requests to all host-runnable adapters and asserts that: //! - Response status codes match -//! - Critical headers (X-Geo-Info-Available, WWW-Authenticate on 401) match +//! - Critical headers (X-Geo-Info-Available) match //! //! Fastly parity is verified via cargo test-fastly + Viceroy in CI. @@ -29,17 +29,9 @@ use trusted_server_core::test_support::nextjs_auction; /// all routers are built through their `routes_with_settings` testing seams /// from this known-good config, which carries the repository's sample policy /// as a loaded domain carries its own. -/// The handler regex is the production-shaped `^/_ts/admin`, matching -/// `Settings::ADMIN_ENDPOINTS` and the default config, so the canonical -/// `/_ts/admin/keys/*` routes are auth-gated exactly as in production. fn test_settings() -> Settings { Settings::from_toml( r#" - [[handlers]] - path = "^/_ts/admin" - username = "admin" - password = "admin-pass" - [publisher] domain = "test-publisher.example.com" cookie_domain = ".test-publisher.example.com" @@ -667,159 +659,6 @@ async fn verify_signature_route_parity() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_rotate_unauthenticated_parity() { - // Both adapters must return 401 for unauthenticated admin requests on the - // canonical `/_ts/admin/keys/*` path that production config auth-gates. - // The authenticated-path divergence (Axum→501 no-KV, CF→4xx no-KV) - // is separate and not covered here. - let (axum_status, axum_headers) = axum_post_headers("/_ts/admin/keys/rotate", "{}").await; - let (cf_status, cf_headers) = cf_post_headers("/_ts/admin/keys/rotate", "{}").await; - let (spin_status, spin_headers) = spin_post_headers("/_ts/admin/keys/rotate", "{}").await; - - assert_eq!( - axum_status, 401, - "Axum must return 401 for unauthenticated admin route" - ); - assert_eq!( - cf_status, 401, - "Cloudflare must return 401 for unauthenticated admin route" - ); - assert_eq!( - spin_status, 401, - "Spin must return 401 for unauthenticated admin route" - ); - assert_eq!( - axum_status, cf_status, - "Axum and Cloudflare must return the same status for unauthenticated admin route" - ); - assert_eq!( - cf_status, spin_status, - "Cloudflare and Spin must return the same status for unauthenticated admin route" - ); - - let axum_www_auth = axum_headers - .get("www-authenticate") - .expect("Axum 401 must include WWW-Authenticate") - .to_str() - .expect("should be valid UTF-8"); - let cf_www_auth = cf_headers - .get("www-authenticate") - .expect("Cloudflare 401 must include WWW-Authenticate") - .to_str() - .expect("should be valid UTF-8"); - assert_eq!( - axum_www_auth, cf_www_auth, - "WWW-Authenticate header value must match across adapters for /admin/keys/rotate" - ); - assert!( - axum_www_auth.starts_with("Basic"), - "WWW-Authenticate must use Basic scheme: {axum_www_auth:?}" - ); - let spin_www_auth = spin_headers - .get("www-authenticate") - .expect("Spin 401 must include WWW-Authenticate") - .to_str() - .expect("should be valid UTF-8"); - assert_eq!( - cf_www_auth, spin_www_auth, - "WWW-Authenticate value must match: cf={cf_www_auth:?} spin={spin_www_auth:?}" - ); - assert!( - spin_www_auth.starts_with("Basic"), - "Spin WWW-Authenticate must use Basic scheme: {spin_www_auth:?}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_deactivate_unauthenticated_parity() { - // Mirror of admin_rotate_unauthenticated_parity for the deactivate endpoint. - let (axum_status, axum_headers) = axum_post_headers("/_ts/admin/keys/deactivate", "{}").await; - let (cf_status, cf_headers) = cf_post_headers("/_ts/admin/keys/deactivate", "{}").await; - let (spin_status, spin_headers) = spin_post_headers("/_ts/admin/keys/deactivate", "{}").await; - - assert_eq!( - axum_status, 401, - "Axum must return 401 for unauthenticated admin/keys/deactivate" - ); - assert_eq!( - cf_status, 401, - "Cloudflare must return 401 for unauthenticated admin/keys/deactivate" - ); - assert_eq!( - spin_status, 401, - "Spin must return 401 for unauthenticated admin/keys/deactivate" - ); - assert_eq!( - axum_status, cf_status, - "Axum and Cloudflare must return the same status for unauthenticated admin/keys/deactivate" - ); - assert_eq!( - cf_status, spin_status, - "Cloudflare and Spin must return the same status for unauthenticated admin/keys/deactivate" - ); - - let axum_www_auth = axum_headers - .get("www-authenticate") - .expect("Axum 401 on admin/keys/deactivate must include WWW-Authenticate") - .to_str() - .expect("should be valid UTF-8"); - let cf_www_auth = cf_headers - .get("www-authenticate") - .expect("Cloudflare 401 on admin/keys/deactivate must include WWW-Authenticate") - .to_str() - .expect("should be valid UTF-8"); - assert_eq!( - axum_www_auth, cf_www_auth, - "WWW-Authenticate header value must match across adapters for /admin/keys/deactivate" - ); - assert!( - axum_www_auth.starts_with("Basic"), - "WWW-Authenticate must use Basic scheme: {axum_www_auth:?}" - ); - let spin_www_auth = spin_headers - .get("www-authenticate") - .expect("Spin 401 on admin/keys/deactivate must include WWW-Authenticate") - .to_str() - .expect("should be valid UTF-8"); - assert_eq!( - cf_www_auth, spin_www_auth, - "WWW-Authenticate value must match: cf={cf_www_auth:?} spin={spin_www_auth:?}" - ); - assert!( - spin_www_auth.starts_with("Basic"), - "Spin WWW-Authenticate must use Basic scheme: {spin_www_auth:?}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn spin_legacy_admin_aliases_are_denied_locally_not_proxied() { - // The production handler regex `^/_ts/admin` only matches the canonical - // `/_ts/admin/keys/*` paths. Legacy aliases must therefore fail closed with a - // local 404 instead of reaching either the admin handlers or the publisher - // fallback. - for alias in ["/admin/keys/rotate", "/admin/keys/deactivate"] { - let (canonical_status, _) = spin_post_headers(&format!("/_ts{alias}"), "{}").await; - assert_eq!( - canonical_status, 401, - "canonical /_ts{alias} must challenge unauthenticated callers" - ); - - for method in ["POST", "GET"] { - let (alias_status, alias_headers) = - spin_authorized_json(method, alias, r#"{"key_id":"leak-me"}"#).await; - assert_eq!( - alias_status, 404, - "Spin legacy {method} {alias} must be denied locally with 404" - ); - assert!( - !alias_headers.contains_key("www-authenticate"), - "Spin legacy {method} {alias} must not issue an admin auth challenge" - ); - } - } -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn geo_header_parity_on_all_responses() { let routes_to_check: &[(&str, &str, &str)] = &[ @@ -879,14 +718,11 @@ async fn geo_header_parity_on_all_responses() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn auction_not_challenged_by_auth_parity() { +async fn auction_is_routed_parity() { let (axum_status, _) = axum_post_headers("/auction", r#"{"adUnits":[]}"#).await; let (cf_status, _) = cf_post_headers("/auction", r#"{"adUnits":[]}"#).await; let (spin_status, _) = spin_post_headers("/auction", r#"{"adUnits":[]}"#).await; - assert_ne!(axum_status, 401, "Axum /auction must not 401"); - assert_ne!(cf_status, 401, "Cloudflare /auction must not 401"); - assert_ne!(spin_status, 401, "Spin /auction must not 401"); assert_ne!(axum_status, 404, "Axum /auction must be routed (not 404)"); assert_ne!( cf_status, 404, @@ -957,7 +793,6 @@ async fn spin_auction_ignores_spoofed_forwarded_headers() { ) .await; - assert_ne!(spoofed_status, 401, "Spin /auction must not 401"); assert_ne!( spoofed_status, 404, "Spin /auction must be routed (not 404)" @@ -1022,177 +857,40 @@ async fn unknown_route_returns_same_status_parity() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn legacy_admin_aliases_are_denied_locally_not_proxied() { - // The production handler regex `^/_ts/admin` only matches the canonical - // `/_ts/admin/keys/*` paths, so the legacy `/admin/keys/*` aliases are not - // auth-gated. They must be denied locally with 404 — never routed to the key - // handlers (which would execute admin operations), and never left unrouted to - // fall through to the publisher fallback, which forwards the request - // (including any `Authorization` header and key-management body) to the origin - // and leaks admin credentials. - // - // Primary guard: assert the route tables directly. The legacy aliases must - // be registered (to the local deny) so they can never reach the publisher - // fallback as an unrouted path. - let cf_registered: Vec<(String, String)> = cf_router() - .routes() - .iter() - .map(|route| (route.method().to_string(), route.path().to_string())) - .collect(); - let spin_registered: Vec<(String, String)> = spin_router() - .routes() - .iter() - .map(|route| (route.method().to_string(), route.path().to_string())) - .collect(); - let cf_is_registered = - |method: &str, path: &str| cf_registered.iter().any(|(m, p)| m == method && p == path); - let spin_is_registered = |method: &str, path: &str| { - spin_registered - .iter() - .any(|(m, p)| m == method && p == path) - }; - - for path in ["/_ts/admin/keys/rotate", "/_ts/admin/keys/deactivate"] { - assert!( - cf_is_registered("POST", path), - "Cloudflare POST {path} must be a registered route" - ); - assert!( - spin_is_registered("POST", path), - "Spin POST {path} must be a registered route" - ); - } - for path in ["/admin/keys/rotate", "/admin/keys/deactivate"] { - for method in ["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] { - assert!( - cf_is_registered(method, path), - "Cloudflare {method} {path} must be a registered route" - ); - assert!( - spin_is_registered(method, path), - "Spin {method} {path} must be a registered route" - ); - } - } - - // Secondary guard: confirm runtime behavior. Canonical paths challenge - // unauthenticated callers (401). Legacy aliases are denied locally with 404 - // and carry no auth challenge — a reintroduced key handler at these ungated - // paths would not return 404, and the publisher fallback would not either, so - // 404 proves the local deny ran. - for alias in ["/admin/keys/rotate", "/admin/keys/deactivate"] { - let (canonical_status, _) = cf_post_headers(&format!("/_ts{alias}"), "{}").await; - assert_eq!( - canonical_status, 401, - "Cloudflare canonical /_ts{alias} must challenge unauthenticated callers" - ); - let (spin_canonical_status, _) = spin_post_headers(&format!("/_ts{alias}"), "{}").await; - assert_eq!( - spin_canonical_status, 401, - "Spin canonical /_ts{alias} must challenge unauthenticated callers" - ); - +async fn key_administration_paths_are_answered_here_and_never_proxied() { + // A request to a key administration path can carry an operator's + // credentials and a key payload. Every adapter must answer it with 404 and + // never reach the publisher fallback, which forwards the request, + // `Authorization` header and body included, to the origin. A key handler + // at one of these paths would not return 404, and the publisher fallback + // would not either, so 404 proves the request was answered here. + for path in [ + "/_ts/admin/keys/rotate", + "/_ts/admin/keys/deactivate", + "/admin/keys/rotate", + "/admin/keys/deactivate", + ] { for method in ["POST", "GET"] { let (axum_status, axum_headers) = - axum_authorized_json(method, alias, r#"{"key_id":"leak-me"}"#).await; + axum_authorized_json(method, path, r#"{"key_id":"leak-me"}"#).await; let (cf_status, cf_headers) = - cf_authorized_json(method, alias, r#"{"key_id":"leak-me"}"#).await; + cf_authorized_json(method, path, r#"{"key_id":"leak-me"}"#).await; let (spin_status, spin_headers) = - spin_authorized_json(method, alias, r#"{"key_id":"leak-me"}"#).await; - assert_eq!( - axum_status, 404, - "Axum legacy {method} {alias} must be denied locally with 404" - ); - assert_eq!( - cf_status, 404, - "Cloudflare legacy {method} {alias} must be denied locally with 404" - ); - assert_eq!( - axum_status, cf_status, - "legacy {method} {alias} status must match across adapters" - ); - assert_eq!( - cf_status, spin_status, - "legacy {method} {alias} status must match across adapters" - ); - assert!( - !axum_headers.contains_key("www-authenticate"), - "Axum legacy {method} {alias} must not issue an admin auth challenge" - ); - assert!( - !cf_headers.contains_key("www-authenticate"), - "Cloudflare legacy {method} {alias} must not issue an admin auth challenge" - ); - assert!( - !spin_headers.contains_key("www-authenticate"), - "Spin legacy {method} {alias} must not issue an admin auth challenge" - ); - } - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_cache_purge_not_implemented_parity() { - // The template cache is Fastly-backed, so the other three adapters answer 501 rather - // than letting the path fall through to the publisher origin and 404 — a CMS purge - // webhook needs to tell "not supported here" from "no such endpoint". - let body = r#"{"scope":"all"}"#; - - let (axum_status, _) = axum_authorized_json("POST", "/_ts/admin/cache/purge", body).await; - let (cf_status, _) = cf_authorized_json("POST", "/_ts/admin/cache/purge", body).await; - let (spin_status, _) = spin_authorized_json("POST", "/_ts/admin/cache/purge", body).await; - - assert_eq!(axum_status, 501, "Axum must answer cache purge with 501"); - assert_eq!( - cf_status, 501, - "Cloudflare must answer cache purge with 501" - ); - assert_eq!(spin_status, 501, "Spin must answer cache purge with 501"); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_cache_purge_unauthenticated_parity() { - // Guards the test above from passing for the wrong reason. Without credentials the - // path must 401, which proves the 501s were reached through auth rather than being - // the 401s of a probe that never arrived at a handler. - let body = r#"{"scope":"all"}"#; - - let (axum_status, _) = axum_post_headers("/_ts/admin/cache/purge", body).await; - let (cf_status, _) = cf_post_headers("/_ts/admin/cache/purge", body).await; - let (spin_status, _) = spin_post_headers("/_ts/admin/cache/purge", body).await; - - for (adapter, status) in [ - ("Axum", axum_status), - ("Cloudflare", cf_status), - ("Spin", spin_status), - ] { - assert_eq!( - status, 401, - "{adapter} must require auth on the cache purge path" - ); - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_cache_purge_rejects_credential_forwarding_methods() { - // The guard the Fastly route exists for, asserted cross-adapter: a method the route - // does not claim falls through to the publisher with the Authorization header still - // attached. Every method must be answered locally, never forwarded. - for method in ["GET", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] { - let (axum_status, _) = axum_authorized_json(method, "/_ts/admin/cache/purge", "").await; - let (cf_status, _) = cf_authorized_json(method, "/_ts/admin/cache/purge", "").await; - let (spin_status, _) = spin_authorized_json(method, "/_ts/admin/cache/purge", "").await; - - for (adapter, status) in [ - ("Axum", axum_status), - ("Cloudflare", cf_status), - ("Spin", spin_status), - ] { - assert_eq!( - status, 501, - "{adapter} must answer {method} locally; a fallthrough would ship the \ - admin credential to the origin" - ); + spin_authorized_json(method, path, r#"{"key_id":"leak-me"}"#).await; + for (adapter, status, headers) in [ + ("Axum", axum_status, &axum_headers), + ("Cloudflare", cf_status, &cf_headers), + ("Spin", spin_status, &spin_headers), + ] { + assert_eq!( + status, 404, + "{adapter} {method} {path} must be answered here with 404" + ); + assert!( + !headers.contains_key("www-authenticate"), + "{adapter} {method} {path} must not ask for credentials" + ); + } } } } diff --git a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts index fc18beb5e9..ed07fae993 100644 --- a/crates/trusted-server-js/lib/src/integrations/gpt/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/gpt/index.ts @@ -1499,10 +1499,10 @@ const PAGE_BIDS_PATH = '/_ts/page-bids'; * Deprecated alias of {@link PAGE_BIDS_PATH}, kept registered server-side so * pre-rename bundles keep working. This bundle falls back to it when the * canonical path does not serve page-bids: a server rolled back to before the - * rename does not register the canonical path, and an operator `[[handlers]]` - * auth regex broad enough to cover `/_ts` answers it with `401` that no - * anonymous browser fetch can satisfy. Without the fallback either case - * silently drops ads on every SPA navigation. + * rename does not register the canonical path, and a gate in front of the + * service that covers `/_ts` answers it with `401` that no anonymous browser + * fetch can satisfy. Without the fallback either case silently drops ads on + * every SPA navigation. * * Removed together with the server-side alias in IABTechLab/trusted-server#970. */ @@ -1547,7 +1547,7 @@ async function fetchPageBids( signal, }); if (!res.ok) { - // 401: an operator auth handler regex covers this path. 404: this server + // 401: a gate in front of the service covers this path. 404: this server // does not know the route. Either way the other path may still answer. // 403 (cross-site gate) and 5xx would repeat on both, so they do not. return { data: null, wrongEndpoint: res.status === 401 || res.status === 404 }; diff --git a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts index 647de8967b..12b26e3469 100644 --- a/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/gpt/spa_hook.test.ts @@ -782,9 +782,9 @@ describe('installSpaAuctionHook', () => { }); it('falls back to the deprecated alias when the canonical path is behind Basic Auth', async () => { - // An operator `[[handlers]]` regex broad enough to cover `/_ts` answers the - // canonical path with 401 that no anonymous browser fetch can satisfy. - // Without the fallback, every SPA navigation on that deployment loses ads. + // A gate in front of the service that covers `/_ts` answers the canonical + // path with 401 that no anonymous browser fetch can satisfy. Without the + // fallback, every SPA navigation on that deployment loses ads. document.body.innerHTML = '
'; fetchStub.mockResolvedValueOnce({ ok: false, status: 401 }).mockResolvedValueOnce({ ok: true, diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 1f0a0e4f92..95f3982232 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -7,7 +7,7 @@ Quick reference for all Trusted Server HTTP endpoints. - [First-Party Endpoints](#first-party-endpoints) - Core ad serving and proxying - [Edge Cookie Endpoints](#edge-cookie-endpoints) - Identity sync and enrichment - [Request Signing](#request-signing-endpoints) - Cryptographic signing and key management -- [Admin Diagnostics](#admin-diagnostic-endpoints) - Protected EC troubleshooting +- [The Administration Prefix](#the-administration-prefix) - Paths answered here and never forwarded - [Inspection Endpoints](#inspection-endpoints) - What a deployment shows to anyone who asks - [TSJS Library](#tsjs-library-endpoint) - JavaScript library serving - [Utility Endpoints](#utility-endpoints) - Optional operational helpers @@ -38,11 +38,6 @@ authority, scheme, and client-address headers in the innermost middleware. | normal | `/.well-known/trusted-server.json` | `GET` | literal | `always` | real | real | real | real | | normal | `/__ts/page-bids` | `GET` | literal | `always` | real | real | real | real | | normal | `/__ts/page-bids` | `OPTIONS` | literal | `always` | guarded | guarded | guarded | guarded | -| normal | `/_ts/admin/ec/{id}` | `GET` | template | `always` | real | unsupported | unsupported | unsupported | -| normal | `/_ts/admin/ec` | `GET` | literal | `always` | real | unsupported | unsupported | unsupported | -| normal | `/_ts/admin/eids` | `GET` | literal | `always` | real | real | real | real | -| normal | `/_ts/admin/keys/deactivate` | `POST` | literal | `always` | real | unsupported | unsupported | unsupported | -| normal | `/_ts/admin/keys/rotate` | `POST` | literal | `always` | real | unsupported | unsupported | unsupported | | normal | `/_ts/api/v1/batch-sync` | `POST` | literal | `always` | real | — | — | — | | normal | `/_ts/api/v1/identify` | `GET`, `OPTIONS` | literal | `always` | real | — | — | — | | normal | `/_ts/clear-tester` | `GET` | literal | `settings.tester_cookie.enabled` | real | — | — | — | @@ -55,8 +50,6 @@ authority, scheme, and client-address headers in the innermost middleware. | normal | `/_ts/permissions.json` | `GET` | literal | `always` | real | real | real | real | | normal | `/_ts/permissions` | `GET` | literal | `always` | real | real | real | real | | normal | `/_ts/set-tester` | `GET` | literal | `settings.tester_cookie.enabled` | real | — | — | — | -| normal | `/admin/keys/deactivate` | `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT` | literal | `always` | guarded | guarded | guarded | guarded | -| normal | `/admin/keys/rotate` | `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT` | literal | `always` | guarded | guarded | guarded | guarded | | normal | `/auction` | `POST` | literal | `always` | real | real | real | real | | normal | `/first-party/click` | `GET` | literal | `always` | real | real | real | real | | normal | `/first-party/proxy-rebuild` | `GET`, `POST` | literal | `always` | real | real | real | real | @@ -83,9 +76,8 @@ Each endpoint contract below states authentication, request and response shape, status behavior, cache and CORS behavior, configuration gates, rate limiting, and an example or an explicit not-applicable value. -`Auth: none` means that the endpoint has no built-in authentication. An -operator can still wrap any path with a `[[handlers]]` Basic-auth rule. Unless -an endpoint says otherwise, it has no endpoint-specific CORS policy and no +`Auth: none` means that the endpoint has no built-in authentication. Unless an +endpoint says otherwise, it has no endpoint-specific CORS policy and no in-process rate limiter. Responses still pass through the adapter's standard response finalizer. Upstream proxies can preserve selected upstream headers; that is not a blanket CORS grant. @@ -122,15 +114,8 @@ Endpoint-specific CORS: `Access-Control-Allow-Origin: *`. ```json { - "masked": [ - "handlers[0].password", - "handlers[0].path", - "handlers[0].username", - "publisher.origin_url", - "publisher.proxy_secret" - ], + "masked": ["publisher.origin_url", "publisher.proxy_secret"], "settings": { - "handlers": [{ "password": "XXXX", "path": "XXXX", "username": "XXXX" }], "publisher": { "cookie_domain": ".example.com", "domain": "example.com", @@ -760,7 +745,7 @@ curl -I "https://edge.example.com/first-party/click?tsurl=https://advertiser.com URL signing endpoint. Returns a signed first-party proxy URL for a valid HTTP or HTTPS target. When `proxy.allowed_domains` is non-empty, the endpoint checks the parsed target host before signing. An empty list permits every valid host. -**Contract:** Auth: none unless an operator adds a handler. GET accepts a `url` +**Contract:** Auth: none. GET accepts a `url` query value; POST accepts `{ "url": "..." }` and is limited to 64 KiB. `200` returns `{href, base}` and gives `href` a 30-second `tsexp`. A disallowed host returns `403`, an oversized POST returns `413`, and malformed/unsupported URLs @@ -978,205 +963,21 @@ curl -X POST https://edge.example.com/verify-signature \ --- -### POST /\_ts/admin/keys/rotate +### Key rotation -Generates and activates a new signing key. - -**Authentication:** Requires basic auth (configured via `handlers` in `trusted-server.toml`) - -**Contract:** Fastly implements this route; Axum, Cloudflare, and Spin return -`501`. Startup requires complete Basic-auth coverage of `/_ts/admin`. The body -is empty or JSON `{ "kid": "..." }`, limited to 4 KiB. A supplied KID must be -1–128 characters, use ASCII alphanumerics plus `-_.:`, and start with a -lowercase ASCII letter. Success returns `200`; invalid KIDs return structured -`400`; store/rotation failures return structured `500`. Successful and failure -bodies use the same schema and set `success` accordingly. The handler defines -no dedicated cache, CORS, or rate-limit policy. - -**Request Body (Optional):** - -```json -{ - "kid": "custom-key-id" -} -``` - -If omitted, auto-generates date-based ID (e.g., `ts-2025-01-15-A`). - -**Response:** - -```json -{ - "success": true, - "message": "Key rotated successfully", - "new_kid": "ts-2025-01-15-A", - "previous_kid": "ts-2025-01-14-A", - "active_kids": ["ts-2025-01-15-A", "ts-2025-01-14-A"], - "jwk": { "kty": "OKP", "crv": "Ed25519", "kid": "ts-2025-01-15-A" } -} -``` - -**Example:** - -```bash -curl -X POST https://edge.example.com/_ts/admin/keys/rotate \ - -u admin:password \ - -H "Content-Type: application/json" -``` - -**Behavior:** - -- Keeps both new and previous key active -- Updates `current-kid` to new key -- Preserves old key for graceful transition - -See [Key Rotation Guide](./key-rotation.md) for workflow details. - ---- - -### POST /\_ts/admin/keys/deactivate - -Deactivates or deletes a signing key. - -**Authentication:** Requires basic auth - -**Contract:** Fastly implements this route; Axum, Cloudflare, and Spin return -`501`. The JSON body is limited to 4 KiB. Deactivation accepts legacy KIDs that -satisfy the 1–128 character and safe-character rules but do not satisfy the -new-key lowercase-leading rule. Success returns `200`; invalid KIDs return a -structured `400`; storage failures return a structured `500`. The handler -defines no dedicated cache, CORS, or rate-limit policy. - -**Request Body:** - -```json -{ - "kid": "ts-2025-01-14-A", - "delete": false -} -``` - -| Field | Type | Required | Description | -| -------- | ------- | -------- | ------------------------------------------------- | -| `kid` | string | Yes | Key ID to deactivate | -| `delete` | boolean | No | If true, permanently removes key (default: false) | - -**Response:** - -```json -{ - "success": true, - "message": "Key deactivated successfully", - "deactivated_kid": "ts-2025-01-14-A", - "deleted": false, - "remaining_active_kids": ["ts-2025-01-15-A"] -} -``` - -**Example:** - -```bash -curl -X POST https://edge.example.com/_ts/admin/keys/deactivate \ - -u admin:password \ - -H "Content-Type: application/json" \ - -d '{"kid":"ts-2025-01-14-A","delete":true}' -``` +Key rotation has no HTTP route. An operator rotates and retires keys with +`ts keys rotate` and `ts keys deactivate`, which run the rotation library in +core against the signing stores. See [Key Rotation](./key-rotation.md). --- -## Admin Diagnostic Endpoints - -These endpoints expose sensitive identity and cookie data and require HTTP Basic Authentication. Configure a handler that covers the entire `/_ts/admin` namespace; startup rejects configurations that do not protect every admin route, including handlers that match only some `/_ts/admin/ec/{id}` values — the dynamic route needs a prefix-level matcher such as `^/_ts/admin` or `^/_ts/admin/ec/`. The whole `/_ts/admin` prefix is reserved: any admin path that reaches publisher fallback — unknown, malformed, or percent-encoded (`/_ts/admin%2Fec`) — is answered locally with `404` and is never proxied, so an admin `Authorization` header and request body never reach the publisher origin. The retired non-`/_ts` `/admin/keys` aliases are reserved the same way. Normal diagnostic-handler responses after successful authentication are JSON with `Cache-Control: no-store`. Missing or invalid credentials receive the shared plaintext `401 Unauthorized` Basic-auth challenge. Unexpected configuration or KV failures use the adapter's shared plaintext `5xx` error response. Those authentication and internal-error responses are outside the diagnostic JSON and cache-header contract. - -The diagnostic handlers have no CORS grant or in-process rate limiter. Apply an -edge policy if operator access also needs request-rate enforcement. - -The examples below use fictional IDs and values only. - -### GET /\_ts/admin/ec +## The Administration Prefix -### GET /\_ts/admin/ec/`{id}` +The whole `/_ts/admin` prefix is closed. No route is served beneath it, and a request to any path beneath it, as sent or percent-encoded (`/_ts/admin%2Fec`), is answered here with `404` and `Cache-Control: no-store` before the publisher fallback, so an `Authorization` header and a request body sent to it never reach the publisher's origin. The `/admin/keys` aliases outside `/_ts` are closed the same way. -Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID created by the module this deployment selects, such as the built-in HMAC module's `hmac~{64 hex}.{6 alphanumeric}` form. The built-in HMAC module also still reads the bare legacy `{64 hex}.{6 alphanumeric}` form, and a deployment with no module selected accepts both of those forms. The bare route uses the request's `ts-ec` cookie. +No request is asked for a password. -This lookup is implemented only by the Fastly adapter because the identity graph is stored in Fastly KV. Other adapters return `501 Not Implemented`. - -**Response fields:** - -- `ec_id` is the EC ID as requested, and `kv_key` is the identity-graph key the record was read from. The key is `ec_id` in the normalized form the identity graph stores, which is the same string as `ec_id` for an identifier the built-in HMAC module issued. `store` and `generation` identify the raw KV lookup. -- `entry` preserves the stored JSON shape, including unknown and legacy fields. Derived `created_iso` and `consent.updated_iso` fields are added only when absent. -- `metadata` preserves the stored metadata JSON shape. -- `tombstone` reports whether consent has been withdrawn. It is absent when the entry body cannot be parsed as JSON or deserialized as the typed EC schema. -- `auction.eids` previews the partner EIDs the stored record can contribute; `auction.skipped` explains filtered IDs. -- `entry_error`, `metadata_error`, and `raw_body` keep malformed or schema-incompatible records inspectable. - -The auction preview validates the stored record and partner configuration, but cannot reproduce live per-request consent checks. It must not be treated as proof that a specific auction request will receive those EIDs. - -**Status codes:** - -| Status | Meaning | -| ------ | ----------------------------------------------------------- | -| `200` | Record found, including inspectable corrupt records | -| `400` | Invalid explicit EC ID | -| `401` | Missing or invalid Basic credentials | -| `404` | Record not found, or the bare route has no `ts-ec` cookie | -| `405` | Method other than `GET` (`Allow: GET`) | -| `501` | EC identity graph unavailable on this adapter or deployment | -| `5xx` | Unexpected configuration or KV failure (plaintext) | - -```bash -curl -u 'admin:' \ - "https://edge.example.com/_ts/admin/ec/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.abc123" - -curl -u 'admin:' \ - --cookie "ts-ec=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.abc123" \ - "https://edge.example.com/_ts/admin/ec" -``` - -### GET /\_ts/admin/eids - -Parses the request's `ts-eids` and `sharedId` cookies and previews which configured partner IDs cookie ingestion would match or drop. It performs request inspection only: it does not read or write KV and is available on every adapter. The preview lists candidate matches from the cookies alone; it does not apply the permission gate on identity-graph writes, does not see `/auction` request-body EIDs, and does not account for partner UIDs already stored in KV. - -After successful authentication this endpoint always returns `200 OK`; missing or malformed cookies are represented by `cookie_present`, `sharedid_present`, and `parse_error`. The `ingest.matched` and `ingest.unmatched` arrays show the ingestion preview. Each unmatched entry contains its `source` and either a `no_partner` reason when no configured partner recognizes it or `no_valid_uid` when the partner exists but every supplied UID is empty or exceeds the storage limit. - -The request body is not applicable. The `200` JSON response is `no-store`. -`401`, `405`, and unexpected `5xx` responses follow the shared admin behavior -above. The route is available on every adapter and depends on configured EC -partners, but not on the EC KV store. - -```json -{ - "ingest": { - "matched": [ - { - "source_domain": "configured.example", - "uid": "fictional-uid" - } - ], - "unmatched": [ - { - "source": "unknown.example", - "reason": "no_partner" - } - ] - } -} -``` - -```bash -curl -u 'admin:' \ - --cookie "sharedId=fictional-shared-id" \ - "https://edge.example.com/_ts/admin/eids" -``` - -Malformed diagnostic paths return a local `404`, and unsupported methods return a local `405`; they are never forwarded to the publisher origin. - -### Legacy /admin/keys/rotate and /admin/keys/deactivate aliases - -All seven fallback methods return local `404 text/plain`. These retired aliases -accept no schema, invoke no key operation, forward nothing to the publisher, -and define no cache, CORS, or rate-limit policy. Example: `curl -i -https://edge.example.com/admin/keys/rotate` must not reach the origin. +What is held against a reader's own Edge Cookie is shown to that reader at [`GET /_ts/data`](#get-ts-data). --- @@ -1196,8 +997,8 @@ stores, publisher proxying, or integrations are usable. ### GET /\_ts/debug/ja4 -**Contract:** Fastly-only; gated by `[debug].ja4_endpoint_enabled`. Auth: none -unless wrapped by a handler. The request has no body. Enabled requests return +**Contract:** Fastly-only; gated by `[debug].ja4_endpoint_enabled`. Auth: none. +The request has no body. Enabled requests return `200 text/plain` with JA4, HTTP/2 fingerprint, cipher, TLS version, user-agent, and client-hint values. Disabled requests return `404`. The response is `no-store, private` and varies on the user-agent/client-hint fields. It defines @@ -1353,8 +1154,8 @@ true. An integration no section selects registers no route, so its path continues through normal routing and can reach the publisher fallback. Duplicate registrations are startup errors. None of these routes has built-in caller authentication or -an in-process rate limiter; `[[handlers]]` and platform controls remain -available when a deployment needs either. +an in-process rate limiter; platform controls remain available when a +deployment needs either. | Route family | Request and success contract | Errors, cache, and CORS | Example | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | @@ -1469,41 +1270,15 @@ No EC ID response header is emitted. EC identity is maintained with the `ts-ec` ## Authentication and policy boundaries -### Basic Authentication - -Endpoints under protected paths require HTTP Basic Authentication: - -**Configuration:** - -```toml -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "admin_password" -``` - -`password` is a key in the Trusted Server secret store. Provision the actual -Basic Authentication password under `admin_password`. - -**Usage:** - -```bash -curl -u 'admin:' https://edge.example.com/_ts/admin/keys/rotate -``` - -**Protected Endpoints:** - -- `/_ts/admin/keys/rotate` -- `/_ts/admin/keys/deactivate` -- `/_ts/admin/ec` -- `/_ts/admin/ec/{id}` -- `/_ts/admin/eids` -- Any paths matching configured `handlers` patterns +### Authentication -The EC partner APIs use their own Bearer-token contract. Signed first-party -proxy routes use `tstoken` instead of HTTP authentication. All other built-in -routes are unauthenticated unless the operator deliberately wraps them with a -handler. +Trusted Server asks no request for a password. The EC partner APIs use their +own Bearer-token contract, and signed first-party proxy routes use `tstoken` +instead of HTTP authentication. All other built-in routes are unauthenticated. +A deployment that needs a password on a path of the publisher's own sets it at +the origin or in the hosting platform. An `Authorization` header a reader +sends is passed to the origin with the request, and the response to it is +never stored in a shared cache. ### Rate limiting diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 0db1d0dd98..f5910a88b0 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -53,13 +53,11 @@ Native Axum dev/test adapter (native binary): **Current limitations compared to the Fastly adapter:** -| Feature | Axum dev server | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| KV store | Unavailable — synthetic-ID and consent routes degrade gracefully | -| Geo lookup | Always returns `None` | -| Config/secret-store writes | Return an error (read-only via env vars) | -| Admin key management (`/_ts/admin/keys/*`) | Returns 501 Not Implemented. Retired `/admin/keys` aliases, including trailing, descendant, and percent-encoded forms, are denied locally with 404 and are not proxied to the publisher fallback | -| Auction fan-out ordering | Requests run concurrently via `tokio::spawn`; `select` returns first-to-complete but does not replicate Fastly's priority-queue tie-breaking | +| Feature | Axum dev server | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| KV store | Unavailable — synthetic-ID and consent routes degrade gracefully | +| Geo lookup | Always returns `None` | +| Auction fan-out ordering | Requests run concurrently via `tokio::spawn`; `select` returns first-to-complete but does not replicate Fastly's priority-queue tie-breaking | ### trusted-server-adapter-spin diff --git a/docs/guide/axum-dev.md b/docs/guide/axum-dev.md index 503009d7e3..225d227e15 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -31,7 +31,6 @@ directly. The script reads the envelope and exports it as Secrets use `TRUSTED_SERVER_SECRET_{STORE}_{KEY}`. The exact required variables are: -- `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD` - `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET` - `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE` @@ -69,10 +68,8 @@ local debugging sessions. ## Runtime boundaries -Axum implements the read-only `/_ts/admin/eids` diagnostic. EC record lookup -and key rotation are registered but return not-supported responses because the -adapter has no request-time KV implementation. It sanitizes forwarded client-IP -headers but does not resolve `[trusted_client_ip]`; see the +Axum sanitizes forwarded client-IP headers but does not resolve +`[trusted_client_ip]`; see the [adapter matrix](./api-reference#adapter-and-startup-support). For an edge deployment, use the [Fastly](./fastly), diff --git a/docs/guide/cli.md b/docs/guide/cli.md index aa76b6d7f7..3ce991e5ef 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -23,6 +23,8 @@ commands are shown explicitly. | `ts auth logout` | Linux + macOS | Sign out (`wrangler logout` / `fastly profile delete` / `spin cloud logout`) | `ts auth logout --adapter ` | | `ts auth status` | Linux + macOS | Show the current session (`wrangler whoami` / `fastly profile list` / `spin cloud info`) | `ts auth status --adapter ` | | `ts build` | Linux + macOS | Build the project for a target adapter | `ts build --adapter [ADAPTER_ARGS]...` | +| `ts cache` | Linux + macOS | Shared template and origin cache commands | `ts cache ` | +| `ts cache purge` | Linux + macOS | Purge cached templates and tagged origin responses through the hosting platform's purge API | `ts cache purge [OPTIONS] --service-id ` | | `ts config` | Linux + macOS | Trusted Server app-config commands | `ts config ` | | `ts config ad-templates` | Linux + macOS | Diagnose server-side ad-template configuration and path matching | `ts config ad-templates ` | | `ts config ad-templates check` | Linux + macOS | Assert that a page path or URL matches the expected slot set | `ts config ad-templates check [OPTIONS] <--expected-slot \|--expect-no-slots> ` | @@ -43,6 +45,9 @@ commands are shown explicitly. | `ts dev proxy ca regenerate` | macOS only | Regenerate the per-machine CA (invalidates prior trust) | `ts dev proxy ca regenerate` | | `ts dev proxy ca uninstall` | macOS only | Remove the CA from the OS trust store | `ts dev proxy ca uninstall` | | `ts healthcheck` | Linux + macOS | Probe a deployed version until it reports healthy | `ts healthcheck [OPTIONS] --adapter --domain --service-id --version ` | +| `ts keys` | Linux + macOS | Rotate and retire request signing keys, with a Fastly API token | `ts keys ` | +| `ts keys deactivate` | Linux + macOS | Take a key out of the active set, and delete it with `--delete` | `ts keys deactivate [OPTIONS] --config-store-id --secret-store-id --kid ` | +| `ts keys rotate` | Linux + macOS | Create a signing key and make it the one the service signs with | `ts keys rotate [OPTIONS] --config-store-id --secret-store-id ` | | `ts prebid` | Linux + macOS | Trusted Server Prebid commands | `ts prebid ` | | `ts prebid client` | Linux + macOS | Generate a local external Prebid client bundle and update config metadata | `ts prebid client [OPTIONS]` | | `ts prebid server` | Linux + macOS | Configure and operate a self-hosted Prebid Server deployment | `ts prebid server [OPTIONS] ` | @@ -291,10 +296,13 @@ commands above do. `--staging` on `config push` / `config diff` writes and compares the `` key in the physical store selected by the current environment. It is mutually exclusive with `--key`. On Fastly, `ts` pushes a -`--key` other than the logical store ID itself, and only with `--local`. That -push makes the same policy checks, writes the named file's SHA-256 and not the -file into the blob, and keeps each file `--permissions` supplies once, under -`permissions_`, before the blob +`--key` other than the logical store ID itself, into the local store in +`fastly.toml` with `--local`, and otherwise into the live store that +`EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` selects, through the +`fastly` CLI with the token in `FASTLY_API_TOKEN`. That push makes the same +policy checks, writes the named file's SHA-256 and not the file into the blob, +and keeps each file `--permissions` supplies once, under `permissions_`, +before the blob, which a live push writes 60 seconds after it writes an entry ([One blob for each host](/guide/configuration#one-blob-for-each-host)). `config diff` compares only the logical store ID on Fastly, because EdgeZero refuses every other key, so the policy a host runs is checked on that host's @@ -336,6 +344,72 @@ metadata to tell a previously live version from a staged one, so pass the version to re-activate via `--rollback-to`. With `--staging`, it deactivates the staged `--version` instead and needs no `--rollback-to`. +## Request signing keys + +`ts keys` rotates and retires the keys request signing uses. It writes the two +stores the service links as `jwks_store` and `signing_keys` through the +`fastly` CLI, with the token in `FASTLY_API_TOKEN`, which needs the `global` +scope to write a store. The token reaches `fastly` in its environment and a +private key reaches it on standard input, so neither appears in a process list. + +```bash +export FASTLY_API_TOKEN= + +# Create a key and make it the one the service signs with. The old key stays +# active, so a signature made with it still verifies. +ts keys rotate --config-store-id --secret-store-id + +# Once nothing signed with the old key is in flight, take it out of the active +# set, and with --delete remove it from both stores. +ts keys deactivate --config-store-id \ + --secret-store-id --kid --delete +``` + +`ts keys rotate` names the key `ts-` unless `--kid` gives one, and adds +a random suffix when a key of that date exists. It refuses a `--kid` that does +not start with a lower case letter, that holds anything but letters, digits, +`-`, `_`, `.` and `:`, or that is longer than 128 characters. `ts keys +deactivate` refuses the current key, so rotate first, and takes any id made of +those characters, so that a key created under an earlier rule can still be +retired. Neither takes `current-kid` or `active-kids` for a key, which are the +two entries the config store keeps its key list in. + +Both print what they did as JSON. A `previous_kid` of `null` from `ts keys +rotate` means the store held no current key. That is right for a new store and +for no other, so check `--config-store-id` when it appears on a store in use. + +A command that cannot read the config store writes nothing, and `ts keys +rotate` never replaces a secret already stored under the new key's id. `ts keys +deactivate --delete` can be run again after a failure, because a key that is +already gone from a store counts as deleted there. See +[Key Rotation](./key-rotation.md). + +## Cache purge + +`ts cache purge` asks Fastly to purge what the service cached. It runs +`fastly service purge`, which the Fastly CLI has had since version 14.0.0, with +the token in `FASTLY_API_TOKEN`. A token with the `purge_select` scope is +enough. That scope allows a purge by surrogate key and does not allow a purge +of everything the service holds. + +```bash +export FASTLY_API_TOKEN= + +# Every cached template and tagged origin response. +ts cache purge --service-id --all + +# One page, as a reader addresses it. +ts cache purge --service-id --page https://example.com/article +``` + +Both purge by surrogate key. `--all` purges the `ts-template` key every cached +object carries, and `--page` purges the key the service derives from that page's +URL, which the command derives the same way. Neither runs +`fastly service purge --all`, so objects the service cached without one of these +keys are left alone. A success means the key was invalidated, not that an object +existed. See +[Configuration](./configuration.md) for when to purge. + ## Audit a public page `ts audit` loads a public page in a fresh headless Chrome/Chromium session, diff --git a/docs/guide/cloudflare.md b/docs/guide/cloudflare.md index efea957d8a..b4ecdd4360 100644 --- a/docs/guide/cloudflare.md +++ b/docs/guide/cloudflare.md @@ -46,10 +46,10 @@ sample permission policy through `--permissions`, which writes the blob into the local `trusted_server_config` namespace, and reads it back with `wrangler kv key get`. -The generated local Wrangler files define `handler_password`, -`publisher_proxy_secret`, and `ec_passphrase` separately. Edge Cookie identity -is off by default, so the smoke's settings select the built-in `hmac` module, -whose passphrase is the `ec_passphrase` secret. The smoke launches +The generated local Wrangler files define `publisher_proxy_secret` and +`ec_passphrase` separately. Edge Cookie identity is off by default, so the +smoke's settings select the built-in `hmac` module, whose passphrase is the +`ec_passphrase` secret. The smoke launches `wrangler dev` once for each omitted secret. Each case requires a 500 response and verifies the exact missing binding against Wrangler's runtime binding inventory. The Worker installs no logger, so no case waits for a log line. The @@ -65,17 +65,14 @@ the checked-in manifests. Local `[vars]` are only for the isolated smoke. For a deployed Worker, run `ts provision --adapter cloudflare` once, then `ts config push --adapter cloudflare --permissions `, which writes the settings and the policy file -they name into the remote config store. Store the three credential values with -`wrangler secret put handler_password`, `wrangler secret put -publisher_proxy_secret`, and `wrangler secret put ec_passphrase`; do not -commit them to a Wrangler manifest. Deploy only after the config store holds -the blob and the three secrets are set. - -Request-time store registries are unwired, so EC KV lookup and key rotation -are not available. The read-only `/_ts/admin/eids` diagnostic remains -available. Forwarded client-IP headers are sanitized, but `[trusted_client_ip]` -is not resolved. See the [API reference](./api-reference) for route-level -behavior. +they name into the remote config store. Store the two credential values with +`wrangler secret put publisher_proxy_secret` and `wrangler secret put +ec_passphrase`; do not commit them to a Wrangler manifest. Deploy only after +the config store holds the blob and the two secrets are set. + +Request-time store registries are unwired, so EC KV lookup is not available. +Forwarded client-IP headers are sanitized, but `[trusted_client_ip]` is not +resolved. See the [API reference](./api-reference) for route-level behavior. Compare the [Fastly](./fastly), [Spin](./spin), and [Axum](./axum-dev) adapter journeys. diff --git a/docs/guide/configuration-rules.md b/docs/guide/configuration-rules.md index 3782859d85..781a21f470 100644 --- a/docs/guide/configuration-rules.md +++ b/docs/guide/configuration-rules.md @@ -127,8 +127,7 @@ implementations compiled into the CLI. of the removed `[integrations]` and `[integration]` tables. - Every secret setting holding a non-empty key name rather than a value, with a secret store declared to hold it. -- Basic-auth coverage of the admin namespace, and the placeholder values the - template ships with. +- The placeholder values the template ships with. ### Checked when the service starts @@ -159,15 +158,15 @@ loaded, and these join it. The other tables configure Trusted Server itself rather than choose what runs. They keep their own keys and have no selector. -| Table | Configures | -| ---------------------------------------------------------- | --------------------------------------------------------------------- | -| `[publisher]` | the site, its origin and the proxy secret | -| `[auction]` | whether auctions run, the whole-auction timeout and creative handling | -| `[auction.bidders.]` | which demand provider a browser bidder code is sent to | -| `[creative_opportunities]` | server-rendered ad slots | -| `[proxy]`, `[cache]`, `[rewrite]` | first-party proxying, caching and URL rewriting | -| `[request_signing]`, `[trusted_client_ip]`, `[[handlers]]` | signing, client addresses and admin access | -| `[debug]`, `[tinybird]` | diagnostics and telemetry | +| Table | Configures | +| ------------------------------------------ | --------------------------------------------------------------------- | +| `[publisher]` | the site, its origin and the proxy secret | +| `[auction]` | whether auctions run, the whole-auction timeout and creative handling | +| `[auction.bidders.]` | which demand provider a browser bidder code is sent to | +| `[creative_opportunities]` | server-rendered ad slots | +| `[proxy]`, `[cache]`, `[rewrite]` | first-party proxying, caching and URL rewriting | +| `[request_signing]`, `[trusted_client_ip]` | signing and client addresses | +| `[debug]`, `[tinybird]` | diagnostics and telemetry | ## Why the file works this way @@ -195,16 +194,11 @@ that can be trusted in production and handed from one team to another. A site with every kind of component the rules cover. ```toml -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "handler_password" # key name in trusted_server_secrets - [publisher] domain = "example.com" cookie_domain = ".example.com" origin_url = "https://origin.example.com" -proxy_secret = "publisher_proxy_secret" # key name +proxy_secret = "publisher_proxy_secret" # key name in trusted_server_secrets [proxy] allowed_domains = ["assets.example.com"] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 61f203c3c3..ce2a2b3999 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -75,7 +75,7 @@ ts config push --adapter fastly --permissions standard.yaml ### Static secret references Static app-config credentials contain stable key names only. This includes -publisher, trusted-client-IP, EC, handler, Tinybird, DataDome, and S3 fields: +publisher, trusted-client-IP, EC, Tinybird, DataDome, and S3 fields: - `publisher.proxy_secret` - `trusted_client_ip.shared_secret`, when trusted client-IP forwarding is configured @@ -83,7 +83,6 @@ publisher, trusted-client-IP, EC, handler, Tinybird, DataDome, and S3 fields: - `ec.host_signals.passphrase`, when `[ec] module = "host_signals"` - `ec.partners[*].api_token`, when inbound identify or batch sync is used - `ec.partners[*].ts_pull_token`, when pull sync is enabled -- `handlers[*].password` - `tinybird.auction_token_secret`, when Tinybird auction telemetry is enabled - `bot-protection.datadome.server_side_key_secret_name`, when protection is enabled - `bot-protection.datadome.protection_test_bypass.credential_secret_name`, when the bypass is enabled @@ -126,7 +125,6 @@ serialization, runtime, and secret axes for every exceptional field: | `Ec.passphrase` | canonical | canonical | serialized | active | store resolved | | `EcPartner.api_token` | canonical | canonical | serialized | active | store resolved | | `EcPartner.ts_pull_token` | canonical | canonical | serialized | active | store resolved | -| `Handler.password` | canonical | canonical | serialized | active | store resolved | | `Publisher.proxy_secret` | canonical | canonical | serialized | active | store resolved | | `S3SigV4AuthConfig.access_key_id` | canonical | canonical | serialized | active | store resolved | | `S3SigV4AuthConfig.secret_access_key` | canonical | canonical | serialized | active | store resolved | @@ -239,14 +237,13 @@ fail and the service will return its startup-error response. | `[ec]` | one module | Edge Cookie identity, persistence, and partner sync | | `[[fetch]]` | nothing | Which page changes run on a page as it is fetched, and in what order | | `[geo]` | one module | Which module resolves location, if any | -| `[[handlers]]` | nothing | Ordered HTTP Basic-auth rules | | `[image_optimizer]` | nothing | Reusable Fastly Image Optimizer profiles | | `[inspect]` | nothing | What the configuration page at `/_ts/config` shows | | `[permission-signal]` | several modules | Which permission signals are acted on, in order | | `[permissions]` | nothing | The publisher's permission policy file, by name | | `[proxy]` | several modules | Proxy allowlist, TLS policy, asset routes, and the first-party script proxy module | | `[publisher]` | nothing | Publisher domain, origin, and proxy signing key | -| `[request_signing]` | nothing | Outbound Ed25519 request signing and management-store IDs | +| `[request_signing]` | nothing | Outbound Ed25519 request signing | | `[response_headers]` | nothing | Headers added to Trusted Server responses | | `[rewrite]` | nothing | First-party URL rewrite exclusions | | `[[serve]]` | nothing | Which page changes run on each reader's copy of a page, and in what order | @@ -278,8 +275,6 @@ policy = "standard" [request_signing] enabled = true -config_store_id = "01GXXX" -secret_store_id = "01GYYY" [auction.prebid] client_side_bidders = ["example-browser-bidder"] @@ -693,18 +688,18 @@ defaults. Each entry of `show` and `hide` is a path pattern, being keys joined by `.`, with `[]` for every element of a list and `[N]` for one, such as `publisher.origin_url`, `proxy.asset_routes[].origin_url` or -`handlers[0].path`. A pattern names the values at exactly its own depth. +`proxy.asset_routes[0].prefix`. A pattern names the values at exactly its own +depth. A masked value shows as `XXXX`, and the page lists every masked path. -| What | Masked | Can `show` reveal it | -| ---------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------- | -| A secret, meaning a value the settings loader fills from the secret store | Always | No | -| `publisher.origin_url` and `publisher.origin_host_header_override` | By default | Yes | -| `proxy.asset_routes[].origin_url` | By default | Yes | -| `handlers[].path` and `handlers[].username` | By default | Yes | -| `ec.ec_store`, `request_signing.config_store_id`, `request_signing.secret_store_id` and `auction.creative_store` | By default | Yes | -| Anything `hide` names | When named | It is the publisher's own choice | +| What | Masked | Can `show` reveal it | +| ------------------------------------------------------------------------- | ---------- | -------------------------------- | +| A secret, meaning a value the settings loader fills from the secret store | Always | No | +| `publisher.origin_url` and `publisher.origin_host_header_override` | By default | Yes | +| `proxy.asset_routes[].origin_url` | By default | Yes | +| `ec.ec_store` and `auction.creative_store` | By default | Yes | +| Anything `hide` names | When named | It is the publisher's own choice | A pattern that would not be honored as written refuses the configuration, both when a deployment is validated and when the settings load. That is a @@ -1134,31 +1129,31 @@ allows those too. Set `proxy.allowed_domains` to bound what the proxy serves. ## Request Signing -Configuration for Ed25519 request signing and JWKS management. +Configuration for Ed25519 request signing. ### `[request_signing]` -| Field | Type | Required | Description | -| ----------------- | ------- | ------------------- | --------------------------------------- | -| `enabled` | Boolean | No (default: false) | Enable request signing features | -| `config_store_id` | String | If enabled | Fastly Config Store ID for JWKS | -| `secret_store_id` | String | If enabled | Fastly Secret Store ID for private keys | +| Field | Type | Required | Description | +| --------- | ------- | ------------------- | ------------------------------- | +| `enabled` | Boolean | No (default: false) | Enable request signing features | **Example**: ```toml [request_signing] enabled = true -config_store_id = "01GXXX" # From Fastly dashboard -secret_store_id = "01GYYY" # From Fastly dashboard ``` +The service reads its keys from the stores linked as `jwks_store` and +`signing_keys`, so the section holds no store id. The ids are given to +[`ts keys`](./cli.md#request-signing-keys) on its command line, and a +configuration that still carries `config_store_id` or `secret_store_id` is +refused when it is validated and when the service starts. + **Environment Override**: ```bash TRUSTED_SERVER__REQUEST_SIGNING__ENABLED=true -TRUSTED_SERVER__REQUEST_SIGNING__CONFIG_STORE_ID=01GXXX -TRUSTED_SERVER__REQUEST_SIGNING__SECRET_STORE_ID=01GYYY ``` ### Store Setup @@ -1197,150 +1192,6 @@ fastly secret-store list See [Request Signing](/guide/request-signing) and [Key Rotation](/guide/key-rotation) for usage. -## Basic Authentication Handlers - -Path-based HTTP Basic Authentication. - -### `[[handlers]]` - -**Purpose**: Protect specific paths with username/password authentication. - -**Format**: Array of handler objects - -| Field | Type | Required | Description | -| ---------- | -------------- | -------- | --------------------------------- | -| `path` | String (Regex) | Yes | Regular expression matching paths | -| `username` | String | Yes | HTTP Basic Auth username | -| `password` | String | Yes | HTTP Basic Auth password | - -**Example**: - -```toml -# Single handler -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "admin_password" - -# Multiple handlers -[[handlers]] -path = "^/secure" -username = "user1" -password = "secure_handler_password" - -[[handlers]] -path = "^/api/private" -username = "api-user" -password = "api_handler_password" -``` - -**Environment Override**: - -```bash -# Handler 0 -TRUSTED_SERVER__HANDLERS__0__PATH="^/_ts/admin" -TRUSTED_SERVER__HANDLERS__0__USERNAME="admin" -TRUSTED_SERVER__HANDLERS__0__PASSWORD="admin_password" - -# Handler 1 -TRUSTED_SERVER__HANDLERS__1__PATH="^/api/private" -TRUSTED_SERVER__HANDLERS__1__USERNAME="api-user" -TRUSTED_SERVER__HANDLERS__1__PASSWORD="api_handler_password" -``` - -### Path Patterns - -**Regex Syntax**: Standard Rust regex patterns - -**Examples**: - -```toml -# Exact path -path = "^/_ts/admin$" # Only /_ts/admin - -# Prefix match -path = "^/_ts/admin" # /_ts/admin, /_ts/admin/users, /_ts/admin/settings - -# Multiple paths -path = "^/(admin|secure|private)" - -# File extension -path = "\\.pdf$" # All PDF files - -# Complex pattern -path = "^/api/v[0-9]+/private" # /api/v1/private, /api/v2/private -``` - -**Validation**: Application startup fails if regex is invalid. - -::: warning Admin coverage and passwords are validated at startup - -Startup fails when no handler covers an admin route. The dynamic -`/_ts/admin/ec/{id}` route accepts any segment after `/_ts/admin/ec/`, and -Basic Auth runs on the raw path before routing, so coverage cannot be inferred -from ID-shaped samples: a pattern such as -`^/_ts/admin/ec/hmac~[a-f0-9]{64}[.][A-Za-z0-9]{6}$` is rejected. Use a prefix-level -matcher (`^/_ts/admin`, or `^/_ts/admin/ec/` alongside the other admin -patterns). - -Handler expressions match the raw URI path, while a publisher origin may decode -percent-encoded aliases before routing. For a whole-site staging gate, use -`path = "^/"`; do not rely on a decoded-path prefix such as `^/secure` to protect -equivalent origin paths. - -Startup also fails when any handler, admin or not, uses a placeholder or -well-known weak password (`changeme`, `password`, `admin`, or a -`replace-with-…` template value). Handler selection is first-match-wins, so a -narrow handler ahead of the admin pattern governs the paths it matches. - -::: - -::: warning Scope patterns to the paths you mean - -Handler patterns are matched against the full request path, so a broad pattern -covers everything beneath it. The `/_ts/` namespace holds both admin routes and -browser-facing endpoints that anonymous visitors must be able to reach: - -| Path | Called by | -| ------------------------ | ------------------------------------ | -| `/_ts/page-bids` | Trusted Server JS, on SPA navigation | -| `/_ts/api/v1/identify` | Trusted Server JS, in the browser | -| `/_ts/api/v1/batch-sync` | Trusted Server JS, in the browser | - -A pattern such as `path = "^/_ts"` puts those behind Basic Auth. Browser -fetches never carry Basic credentials, so every visitor gets `401`, on -`/_ts/page-bids` that means no ads after any client-side navigation. Match the -admin routes specifically (`^/_ts/admin`) instead. - -Upgrading from a release before `/_ts/page-bids` existed: if any handler -pattern covers it, narrow the pattern. The Trusted Server JS bundle falls back -to the deprecated `/__ts/page-bids` alias in the meantime, but that alias is -scheduled for removal -([#970](https://github.com/IABTechLab/trusted-server/issues/970)). - -::: - -### Security Considerations - -**Password Storage**: - -- `handlers[*].password` is a key name in `trusted_server_secrets` -- Store the resolved password only in the platform secret store -- Rotate passwords through the store and restart/redeploy instances - -**Limitations**: - -- HTTP Basic Auth (not OAuth/JWT) -- Single username/password per path -- No role-based access control -- No rate limiting (add at edge) - -::: warning Production Use -Do not put handler passwords in `trusted-server.toml`, environment overlays, or -app-config blobs. Provision the referenced key in `trusted_server_secrets` -before pushing the config. -::: - ## URL Rewrite Configuration Control which domains are excluded from first-party rewriting. @@ -2839,14 +2690,6 @@ response and runs a new per-reader auction. Explicit `no-cache`, `no-store`, positive or malformed request `max-age`, range, and conditional requests still bypass the template cache. Check `X-TS-Template-Cache: hit` to verify template reuse. -Authorization has one narrow exception. A request carrying exactly the same -single Basic credential that Trusted Server just validated at the edge may share -a template; pass-through, repeated, appended, or replaced values still bypass. -Trusted Server does not remove the validated header, so it remains forwarded to -the publisher origin. If the origin uses that credential to select response -content, it must declare `Vary: Authorization`; that response is deliberately not -stored as a shared template. - `template_cache_vary` is necessary because lookup occurs before the origin can return `Vary`. Presence, empty values, repeated raw field values, host/scheme, origin identity, complete template-shaping settings, TSJS content, core build @@ -2972,11 +2815,6 @@ The two headers together are the reliable verification signal. Timing alone can vary with the origin, auction, compression, browser connection reuse, and local proxy buffering. -> **Upgrade note.** This release adds `/_ts/admin/cache/purge` to the admin endpoints -> startup validation covers. A configuration whose `[[handlers]]` enumerate admin paths -> individually, rather than using the `^/_ts/admin` prefix, fails to start until that path -> is covered too. The failure is at startup and explicit, not at request time. - Rollback must preserve configuration compatibility: 1. Change `assembly_mode` to `inline` and deploy/push that configuration. @@ -2992,17 +2830,16 @@ Rollback must preserve configuration compatibility: the lists loses variant separation and session bypass. Remove `origin_readthrough_enabled` even when rolling it back: `false` still serializes the field and older binaries reject it. -3. Purge the template cache with `ts cache purge --service --all`, or +3. Purge the template cache with `ts cache purge --service-id --all`, or `--page ` for a single reader-facing URL. Use its exact scheme, host, and port: `http://example.com/article` and `https://example.com/article` have different purge keys. A success acknowledges invalidation of the requested key, not that an - object existed. `--service` requires HTTPS, except for loopback development - services (`localhost`, `127.0.0.1`, or `::1`). The admin endpoint - `POST /_ts/admin/cache/purge` is the same operation for a CMS webhook. Either clears - the `ts-template` surrogate key; waiting out the bounded origin-derived lifetime also - works. With readthrough caching enabled, `--all` also purges tagged origin - documents, so the next requests refetch those documents from the origin. Check - whether the origin can absorb that load before purging during a traffic peak. + object existed. The command asks Fastly to purge, through the `fastly` CLI with the + token in `FASTLY_API_TOKEN`, and `--all` clears the `ts-template` surrogate key. + Waiting out the bounded origin-derived lifetime also works. With readthrough + caching enabled, `--all` also purges tagged origin documents, so the next requests + refetch those documents from the origin. Check whether the origin can absorb that + load before purging during a traffic peak. Run `scripts/template-cache-local-test.sh esi` before a rollout and `scripts/template-cache-local-test.sh inline` as its control. The harness uses a temporary @@ -3151,7 +2988,7 @@ saying nothing about this setting. next request with no deploy and restores the previous policy: ad-serving requests bypass, while non-ad traffic keeps the platform default. It does not disable origin caching globally. -2. Purge tagged objects with `ts cache purge --service --all`, +2. Purge tagged objects with `ts cache purge --service-id --all`, or `--page ` for one exact reader-facing URL. Both the template cache and opted-in origin readthrough objects carry the page key and `ts-template` purge-all key. The readthrough tags use the original reader URL, before origin @@ -3316,8 +3153,7 @@ selection is a deploy-time input: a managed `ts deploy --adapter fastly` links the physical store named by `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` to the service version under the logical ID. On Fastly the CLI rejects a `__KEY` selector other than -the logical ID, and it pushes a `--key` other than the logical ID only with -`--local`. +the logical ID, and it pushes a `--key` other than the logical ID itself. Keep the `__NAME` selector set in the shell or deploy job for every push, so `ts config push` writes into the same physical store the deployment links. @@ -3341,9 +3177,9 @@ Push each key in lower case. The runtime lowercases the request's host and answered `421`. ```bash -ts config push --adapter fastly --local --key www.publisher-a.example \ +ts config push --adapter fastly --key www.publisher-a.example \ --app-config publisher-a.toml --permissions standard.yaml -ts config push --adapter fastly --local --key www.publisher-b.example \ +ts config push --adapter fastly --key www.publisher-b.example \ --app-config publisher-b.toml --permissions standard.yaml fastly config-store-entry update --upsert \ @@ -3352,18 +3188,23 @@ fastly config-store-entry update --upsert \ --value '{host}' ``` -`ts config push` writes a key other than `trusted_server_config` only with -`--local`, into the local store in `fastly.toml`. Copy the entries it writes -into a live store with `fastly config-store-entry update`, the policy entries -first and a long value's chunks before its pointer. The selector is read on -every request, so the blobs come first. +`ts config push` writes a key other than `trusted_server_config` itself, +because EdgeZero writes no other key on Fastly. It writes the live store that +`EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` selects through the +`fastly` CLI, with the token in `FASTLY_API_TOKEN`, and with `--local` it +writes the local store in `fastly.toml` instead. The policy entries are +written first, and a long value's chunks before its pointer. The selector is +read on every request, so the blobs come first. Each host's blob names its permission policy file in `[permissions] policy` and pins the file's SHA-256, and carries no copy of the file. The push keeps each file `--permissions` supplies once, in an entry of its own under `permissions_`, written before the host's blob, and keeps an entry that already holds the file, so a hundred hosts that name three files store three -entries. A changed file is a new entry, so a host still running an earlier +entries. A live push that writes a policy entry waits 60 seconds before it +writes the host's blob, because the store is consistent key by key, and a +location that reads a blob before the entry it names answers that host +`500`. A changed file is a new entry, so a host still running an earlier blob still finds the file that blob pins. The blob at `trusted_server_config` carries its file, as on a service with one blob. `ts config diff` compares only that blob, so the policy a host runs is checked on that host's `/_ts/config`. @@ -3483,12 +3324,6 @@ from the file, and startup checks the rest and runs the first set again. - Every secret setting holds a key name and no secret value -**Handlers**: - -- `path` is a valid regex -- `username` is ordinary configuration and non-empty -- At least one handler covers the `/_ts/admin` namespace - **Integrations**: - Each integration validates its own block, selected or not, so a typo in a @@ -3512,8 +3347,8 @@ Everything above runs again on the loaded configuration, and these join it: running module supplies, or one named in the phase it does not run in, stops the service on its next start, because which middleware a build has is only known where its modules are registered -- Resolved secret values, so a passphrase shorter than 32 bytes, a placeholder - or a weak handler password fails here +- Resolved secret values, so a passphrase shorter than 32 bytes or a + placeholder fails here - The compiled `permissions.yaml` policy, and the `assume_single_jurisdiction` acknowledgment an Edge Cookie module needs when no geo module is selected - The checks only the host can make, being backend name prediction and @@ -3608,12 +3443,6 @@ trusted-server.dev.toml # Development overrides - Do not replace the key name with a plaintext value in the app config - Rotate the value in the platform secret store, then restart/redeploy -**"Invalid regex"**: - -- Handler `path` must be valid regex -- Test pattern: `echo "^/_ts/admin" | grep -E "^/_ts/admin"` -- Escape special characters: `\.`, `\$`, etc. - **"Integration configuration could not be parsed"**: - Check JSON syntax in env vars diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index e63138ac93..a3c26c1d30 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -451,12 +451,8 @@ Signing key not found: ts-2025-01-A 1. Check Config Store `current-kid` value 2. Verify corresponding key exists in Secret Store -3. Run key rotation to generate new key: - -```bash -curl -X POST https://edge.example.com/_ts/admin/keys/rotate \ - -u admin:password -``` +3. Rotate to a new key with `ts keys rotate`, as + [Key Rotation](./key-rotation.md) describes. --- @@ -474,13 +470,8 @@ curl -X POST https://edge.example.com/_ts/admin/keys/rotate \ **Solution:** -1. Initialize keys using rotation endpoint: - -```bash -curl -X POST https://edge.example.com/_ts/admin/keys/rotate \ - -u admin:password -``` - +1. Create the first key with `ts keys rotate`, as + [Key Rotation](./key-rotation.md) describes. 2. Verify Config Store has `active-kids` entry 3. Check Secret Store contains the key @@ -540,13 +531,11 @@ Config store not found: jwks_store - Service → Configuration → Config Stores - Add store with exact name from configuration -3. Update `trusted-server.toml`: +3. Switch request signing on in `trusted-server.toml`: ```toml [request_signing] enabled = true -config_store_id = "your-config-store-id" # From Fastly dashboard -secret_store_id = "your-secret-store-id" ``` --- diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 3b71049b0c..d7d980dfd2 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -449,11 +449,10 @@ The script creates an isolated application config, applies its publisher-origin overrides, selects the built-in `hmac` Edge Cookie module, names the repository's sample permission policy, and runs strict validation. It then executes `ts config push --adapter fastly --local` with the sample through -`--permissions`, adds all three required entries to +`--permissions`, adds both required entries to `[local_server.secret_stores.trusted_server_secrets]`, and starts -`fastly compute serve` -through Viceroy. The required keys are `handler_password`, -`publisher_proxy_secret`, and `ec_passphrase`. +`fastly compute serve` through Viceroy. The required keys are +`publisher_proxy_secret` and `ec_passphrase`. The check deliberately proves both halves of startup. With no config entry, it requires `/health` to return 200 while the publisher route returns 500 with the diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 2321fb8dc7..7550363167 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -100,11 +100,10 @@ export TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG="$( jq -r '.trusted_server_config' .edgezero/local-config-trusted_server_config.json )" -# Populate the three secret references from the starter config for this shell. +# Populate the two secret references from the starter config for this shell. # Use stable values only if you need existing proxy URLs or EC IDs to remain valid. export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET="$(openssl rand -base64 32)" export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE="$(openssl rand -base64 32)" -export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD="$(openssl rand -base64 32)" # Axum reads the policy file the settings name from this folder. export TRUSTED_SERVER_PERMISSIONS_DIR="$PWD/config/permissions" @@ -142,8 +141,8 @@ ephemeral secret-store values only into the current shell; do not put secret values in the TOML config, config-store blob, or a source-controlled environment file. -> **Dev server limitations:** The Axum adapter does not support KV store, -> geo lookup, config/secret-store writes, or admin key-management routes. +> **Dev server limitations:** The Axum adapter does not support KV store or +> geo lookup. > See [Architecture](/guide/architecture) for the full list. ### Build the Project diff --git a/docs/guide/integrations/datadome.md b/docs/guide/integrations/datadome.md index 4114ab90ac..8b275a5a13 100644 --- a/docs/guide/integrations/datadome.md +++ b/docs/guide/integrations/datadome.md @@ -176,7 +176,7 @@ A request is protected when all of the following are true: 7. No `protection_exclusion_rules` match. 8. The request does not contain a matching enabled `protection_test_bypass` credential while `FASTLY_IS_STAGING=1`. -Static assets are excluded by default using a case-insensitive file-extension regex. Trusted Server internal routes such as `/static/tsjs=`, `/integrations/`, `/first-party/`, admin routes, discovery routes, and signature-verification routes are also excluded by default. +Static assets are excluded by default using a case-insensitive file-extension regex. Trusted Server internal routes such as `/static/tsjs=`, `/integrations/`, `/first-party/`, discovery routes, and signature-verification routes are also excluded by default. Auction traffic at `/auction` is protected by default. diff --git a/docs/guide/key-rotation.md b/docs/guide/key-rotation.md index cde347612b..7a3214890f 100644 --- a/docs/guide/key-rotation.md +++ b/docs/guide/key-rotation.md @@ -4,13 +4,17 @@ Learn how to rotate signing keys to maintain security and manage the lifecycle o ## Overview -Key rotation is the process of generating new signing keys and transitioning from old keys to new ones. Trusted Server provides automated key rotation with: +Key rotation is the process of generating new signing keys and transitioning from old keys to new ones. The key rotation library in core, `KeyRotationManager` in `crates/trusted-server-core/src/request_signing/rotation.rs`, provides: - **Zero-downtime rotation** - Old and new keys work simultaneously - **Automatic key generation** - Date-based key identifiers - **Grace period support** - Multiple active keys during transition - **Safe deactivation** - Prevents removing the last active key +::: tip Run by an operator +Keys are rotated and retired with [`ts keys`](./cli.md#request-signing-keys), run by an operator holding a Fastly API token. Nothing in a running deployment rotates or retires keys, and there is no HTTP route for it. +::: + ## Why Rotate Keys? ### Security Reasons @@ -32,11 +36,11 @@ The Edge Cookie `ec.hmac.passphrase` is long-lived HMAC-SHA256 keying material u ## Prerequisites -Before you can rotate keys, you need to set up the required Fastly stores and API credentials. +Request signing needs two Fastly stores set up and linked to the service. ### Required Stores -Key rotation requires three Fastly stores: +Request signing reads two Fastly stores, and key rotation writes them: 1. **Config Store** (`jwks_store`) - Stores public JWKs and metadata - `current-kid` - The active key identifier @@ -47,8 +51,7 @@ Key rotation requires three Fastly stores: - Each key stored with its `kid` as the key name - Values are base64-encoded Ed25519 private keys -3. **Secret Store** (`api-keys`) - Stores Fastly API credentials - - `api_key` - Fastly API token for managing stores +The running service only reads these stores, so it needs no Fastly API token of its own. ### Creating Stores @@ -58,67 +61,28 @@ Key rotation requires three Fastly stores: # Create the config store fastly config-store create --name=jwks_store -# Get the store ID (you'll need this for configuration) +# Get the store ID (you'll need this for `ts keys`) fastly config-store list ``` Note the Config Store ID from the output. -#### 2. Create Secret Stores +#### 2. Create Secret Store ```bash # Create secret store for signing keys fastly secret-store create --name=signing_keys -# Create secret store for API credentials -fastly secret-store create --name=api-keys - -# Get the store IDs +# Get the store ID fastly secret-store list ``` -Note both Secret Store IDs from the output. +Note the Secret Store ID from the output. ::: tip Dashboard Alternative You can also create stores via the Fastly dashboard, but CLI commands are recommended for automation and reproducibility. ::: -### Creating Fastly API Key - -Key rotation uses the Fastly API to manage store contents. You need to create an API token: - -#### Step 1: Generate API Token - -1. Log in to the [Fastly Dashboard](https://manage.fastly.com) -2. Navigate to **Account → API Tokens → Personal Tokens** -3. Click **Create Token** -4. Configure the token: - - **Name**: `trusted-server-key-rotation` - - **Scope**: `global:read`, `global:write` (or scope to your specific service) - - **Expiration**: Set according to your security policy - -#### Step 2: Store API Token - -Store the API token in the `api-keys` secret store: - -```bash -# Read without echoing, then send the API key over stdin rather than argv. -read -rsp 'Fastly API token: ' FASTLY_ROTATION_API_TOKEN && printf '\n' -printf '%s' "$FASTLY_ROTATION_API_TOKEN" | fastly secret-store-entry create \ - --store-id= \ - --name=api_key \ - --stdin -unset FASTLY_ROTATION_API_TOKEN -``` - -::: warning Keep Your API Token Secure - -- Never commit API tokens to version control -- Store them only in Fastly Secret Store -- Rotate API tokens according to your security policy -- Use minimal required permissions - ::: - ### Linking Stores to Service Stores must be linked to your Compute service to be accessible at runtime. @@ -137,12 +101,6 @@ fastly service-version compute secret-store create \ --version= \ --secret-store-id= \ --name=signing_keys - -# Link API keys secret store -fastly service-version compute secret-store create \ - --version= \ - --secret-store-id= \ - --name=api-keys ``` ::: tip Dashboard Linking @@ -166,25 +124,22 @@ For local testing, configure stores in `fastly.toml`: [[local_server.secret_stores.signing_keys]] key = "ts-2025-01-01" data = "" - - [[local_server.secret_stores.api-keys]] - key = "api_key" - env = "FASTLY_KEY" # Load from environment variable ``` ### Configuration in trusted-server.toml -Update `trusted-server.toml` with your store IDs: +Switch request signing on in `trusted-server.toml`: ```toml [request_signing] enabled = true -config_store_id = "" # Your jwks_store ID -secret_store_id = " --secret-store-id ``` -#### Custom Key ID - -Specify a custom key identifier: - -```bash -curl -X POST https://your-domain/_ts/admin/keys/rotate \ - -H "Content-Type: application/json" \ - -d '{"kid": "production-2024-q1"}' -``` +It writes the private key to the secret store, the public JWK to the config +store, then `active-kids`, then `current-kid` last, and prints the new and +previous key ids, the active keys and the public JWK as JSON. `--kid` names the +key, which otherwise is `ts-`, with a random suffix when a key of that +date exists. A command that cannot read the config store writes nothing. -**Response**: - -```json -{ - "success": true, - "message": "Key rotated successfully", - "new_kid": "production-2024-q1", - "previous_kid": "ts-2024-01-15", - "active_kids": ["ts-2024-01-15", "production-2024-q1"], - "jwk": { ... } -} -``` +The command runs the rotation library in core, which another tool can call +directly. ### Using the Rust API ```rust use trusted_server_core::request_signing::KeyRotationManager; -// Initialize rotation manager -let manager = KeyRotationManager::new("jwks_store", "signing_keys")?; +// The IDs of the two stores. The stores are written through the platform's +// `RuntimeServices`. +let manager = KeyRotationManager::new("", ""); // Rotate with automatic kid -let result = manager.rotate_key(None)?; +let result = manager.rotate_key(&services, None)?; println!("New key: {}", result.new_kid); println!("Previous key: {:?}", result.previous_kid); println!("Active keys: {:?}", result.active_kids); -// Or rotate with custom kid -let custom_result = manager.rotate_key(Some("my-custom-key".to_string()))?; +// Or rotate with custom kid. `rotate_key` does not check the kid, so check it +// with `kid_is_creatable` first. +let custom_result = manager.rotate_key(&services, Some("my-custom-key".to_string()))?; ``` ## Managing Active Keys @@ -326,8 +242,8 @@ let custom_result = manager.rotate_key(Some("my-custom-key".to_string()))?; **Rust API**: ```rust -let manager = KeyRotationManager::new("jwks_store", "signing_keys")?; -let active_keys = manager.list_active_keys()?; +let manager = KeyRotationManager::new("", ""); +let active_keys = manager.list_active_keys(&services)?; for kid in active_keys { println!("Active key: {}", kid); @@ -360,78 +276,42 @@ Deactivate old keys after: 3. No more requests using the old key 4. Old signatures no longer need verification -### Deactivation Endpoint - -**Endpoint**: `POST /_ts/admin/keys/deactivate` - -#### Deactivate (Keep in Storage) - -Remove from active rotation but keep in storage: +Deactivate with `ts keys deactivate`, and add `--delete` to remove the key +from both stores as well: ```bash -curl -X POST https://your-domain/_ts/admin/keys/deactivate \ - -H "Content-Type: application/json" \ - -d '{ - "kid": "ts-2024-01-15", - "delete": false - }' +ts keys deactivate --config-store-id \ + --secret-store-id --kid ts-2024-01-15 --delete ``` -**Response**: - -```json -{ - "success": true, - "message": "Key deactivated successfully", - "deactivated_kid": "ts-2024-01-15", - "deleted": false, - "remaining_active_kids": ["ts-2024-02-15"] -} -``` - -#### Delete Permanently - -Remove from storage completely: - -```bash -curl -X POST https://your-domain/_ts/admin/keys/deactivate \ - -H "Content-Type: application/json" \ - -d '{ - "kid": "ts-2024-01-15", - "delete": true - }' -``` - -**Response**: - -```json -{ - "success": true, - "message": "Key deleted successfully", - "deactivated_kid": "ts-2024-01-15", - "deleted": true, - "remaining_active_kids": ["ts-2024-02-15"] -} -``` +A delete that fails part way can be run again, because a key that is already +gone from a store counts as deleted there. The library calls the command makes +are below. ### Using the Rust API ```rust -let manager = KeyRotationManager::new("jwks_store", "signing_keys")?; +let manager = KeyRotationManager::new("", ""); // Deactivate (keep in storage) -manager.deactivate_key("ts-2024-01-15")?; +manager.deactivate_key(&services, "ts-2024-01-15")?; // Delete completely -manager.delete_key("ts-2024-01-15")?; +manager.delete_key(&services, "ts-2024-01-15")?; ``` ### Safety Checks -The system prevents: +The library refuses to: + +- **Deactivate the last active key** - At least one key must remain active +- **Deactivate or delete the current key** - Rotate first, then retire the old key -- **Deleting the last active key** - At least one key must remain active -- **Invalid key IDs** - Returns error for non-existent keys +`ts keys deactivate` also refuses an ID that is not 1 to 128 letters, digits, +`-`, `_`, `.` and `:`, and the names `current-kid` and `active-kids`, before it +reads or writes anything. An ID that no store holds is not an error, because a +key that is gone counts as retired, so read `remaining_active_kids` in the +report to see what is left. ## Key Naming Conventions @@ -470,28 +350,9 @@ Use descriptive names for specific purposes: ### Strategy 1: Scheduled Rotation -Regular rotation on a fixed schedule: - -```bash -# Cron job: Rotate every 90 days -0 0 1 */3 * /usr/local/bin/rotate-keys.sh -``` - -**rotate-keys.sh**: - -```bash -#!/bin/bash -# Rotate signing keys -curl -X POST https://your-domain/_ts/admin/keys/rotate - -# Wait 30 days grace period -sleep $((30 * 24 * 60 * 60)) - -# Deactivate old key -OLD_KEY=$(date -d '90 days ago' +ts-%Y-%m-%d) -curl -X POST https://your-domain/_ts/admin/keys/deactivate \ - -d "{\"kid\": \"$OLD_KEY\", \"delete\": true}" -``` +Regular rotation on a fixed schedule, for example every 90 days with a 30 day +grace period before the old key is deleted. A scheduler runs `ts keys rotate`, +and `ts keys deactivate --delete` once the grace period has passed. ### Strategy 2: On-Demand Rotation @@ -597,18 +458,6 @@ Test rotation in staging first: ## Troubleshooting -### Rotation Failed - -**Error**: `Failed to create KeyRotationManager` - -**Solutions**: - -- Verify all required stores are created (see [Prerequisites](#prerequisites)) -- Check Fastly API token is stored in `api-keys` secret store as `api_key` -- Verify `config_store_id` and `secret_store_id` in `trusted-server.toml` match your actual store IDs -- Ensure stores are linked to your Compute service -- Confirm API token has `global:read` and `global:write` permissions - ### Cannot Deactivate Key **Error**: `Cannot deactivate the last active key` @@ -651,37 +500,19 @@ Test rotation in staging first: If a key is compromised: 1. **Immediate**: Rotate to new key - -```bash -curl -X POST /_ts/admin/keys/rotate -``` - 2. **Urgent**: Deactivate compromised key - -```bash -curl -X POST /_ts/admin/keys/deactivate \ - -d '{"kid": "compromised-key", "delete": false}' -``` - 3. **Investigation**: Review logs for misuse - 4. **Communication**: Notify partners of compromise - 5. **Cleanup**: Delete compromised key after investigation -```bash -curl -X POST /_ts/admin/keys/deactivate \ - -d '{"kid": "compromised-key", "delete": true}' -``` +Rotate with `ts keys rotate`, then retire the compromised key with +`ts keys deactivate --delete`, as [Rotating Keys](#rotating-keys) describes. ### Access Control -Restrict rotation endpoints: - -- Require authentication/authorization -- Use admin-only API keys -- Implement rate limiting -- Audit all rotation attempts +There is no rotation endpoint on the publisher's domain. Rotation writes the +signing stores, so only an operator holding platform credentials with write +access to those stores can rotate or retire a key. ## Next Steps diff --git a/docs/guide/onboarding.md b/docs/guide/onboarding.md index e8f07693ea..22411662e3 100644 --- a/docs/guide/onboarding.md +++ b/docs/guide/onboarding.md @@ -19,8 +19,8 @@ publisher-page request, it can: 4. expose configured integrations and third-party assets through first-party routes. -Not every request enters the publisher pipeline. Health, administrative, -integration, TSJS, and configured asset routes can terminate earlier. +Not every request enters the publisher pipeline. Health, integration, TSJS, +and configured asset routes can terminate earlier. ## Trace a Fastly publisher request @@ -81,7 +81,7 @@ See [Architecture](/guide/architecture) for component boundaries and | `crates/trusted-server-core/` | Runtime-neutral request, identity, consent, proxy, and ad logic | | `crates/trusted-server-core/src/publisher.rs` | Publisher fallback, auction dispatch, and response processing | | `crates/trusted-server-core/src/auction/` | Auction orchestration and provider implementations | -| `crates/trusted-server-core/src/ec/` | EC identity graph and administrative operations | +| `crates/trusted-server-core/src/ec/` | EC identity graph | | `crates/trusted-server-core/src/consent/` | Consent extraction, decoding, and enforcement | | `crates/trusted-server-core/src/integrations/` | Integration registry and the builder a module registers with | | `crates///` | One module each: a page integration or an auction implementation | diff --git a/docs/guide/request-signing.md b/docs/guide/request-signing.md index ecb6de9f5f..47c7abab62 100644 --- a/docs/guide/request-signing.md +++ b/docs/guide/request-signing.md @@ -216,10 +216,12 @@ Configure request signing in `trusted-server.toml`: ```toml [request_signing] -config_store_id = "jwks_store" -secret_store_id = "signing_keys" +enabled = true ``` +The service reads its keys from the two stores below by the names they are +linked under. + ### Fastly Config Store Setup Create the Config Store in Fastly: diff --git a/docs/guide/spin.md b/docs/guide/spin.md index fcd618a9a4..075fc2357f 100644 --- a/docs/guide/spin.md +++ b/docs/guide/spin.md @@ -58,7 +58,6 @@ repository-local Spin state is used. Spin encodes `v__v_` names. The smoke supplies these exact variables: -- `v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword` - `v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret` - `v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase` @@ -76,9 +75,8 @@ manifest, SQLite store, response captures, and logs. Use the same store mapping and encoded variables in the deployment provider; empty variable defaults fail closed. Spin's request-time store registry is -currently unwired, so EC KV lookup, request-signing key variables, and key -rotation are unavailable. The read-only `/_ts/admin/eids` diagnostic remains -available. Forwarded client-IP headers are sanitized, and Spin derives its +currently unwired, so EC KV lookup and request-signing key variables are +unavailable. Forwarded client-IP headers are sanitized, and Spin derives its runtime authority, scheme, and client-address headers in the innermost normalization layer. diff --git a/fastly.toml b/fastly.toml index 19ec5a3627..020b15ea28 100644 --- a/fastly.toml +++ b/fastly.toml @@ -45,11 +45,7 @@ build = """ key = "ts-2025-10-A" data = "NVnTYrw5xoyTJDOwoUWoPJO3A6UCCXOJJUzgGTxxx7k=" - [[local_server.secret_stores.api-keys]] - key = "api_key" - env = "FASTLY_KEY" - - # Local-only values for the three secret references in + # Local-only values for the two secret references in # trusted-server.example.toml. These are public development fixtures. # App-config references use logical `trusted_server_secrets`. Viceroy # exposes the store under that ID, matching the resource link a @@ -62,10 +58,6 @@ build = """ key = "ec_passphrase" data = "local-only-ec-passphrase-32-bytes" - [[local_server.secret_stores.trusted_server_secrets]] - key = "handler_password" - data = "local-only-handler-password" - [[local_server.secret_stores.trusted_server_secrets]] key = "tinybird_auction_append_token" data = "test-tinybird-auction-append-token" diff --git a/scripts/config-by-host-local-test.sh b/scripts/config-by-host-local-test.sh index 2dcfa33edc..b81ccd5534 100755 --- a/scripts/config-by-host-local-test.sh +++ b/scripts/config-by-host-local-test.sh @@ -195,10 +195,6 @@ data = "fictional-local-publisher-proxy-secret-value" [[local_server.secret_stores.trusted_server_secrets]] key = "ec_passphrase" data = "fictional-local-ec-passphrase-secret-value" - -[[local_server.secret_stores.trusted_server_secrets]] -key = "handler_password" -data = "fictional-local-handler-password-secret-value" SECRETSEOF "$TS" config init --app-config "$WORK/app.toml" >/dev/null diff --git a/scripts/smoke-axum.sh b/scripts/smoke-axum.sh index 11be22980a..1c0891ff51 100755 --- a/scripts/smoke-axum.sh +++ b/scripts/smoke-axum.sh @@ -63,11 +63,10 @@ run_case() { local port="$2" local config_value="$3" local policy_dir="$4" - local handler_value="$5" - local proxy_value="$6" - local ec_value="$7" - local expected_status="$8" - local expected_diagnostic="${9:-}" + local proxy_value="$5" + local ec_value="$6" + local expected_status="$7" + local expected_diagnostic="${8:-}" local log_path="$WORKSPACE/$case_name.log" local body_path="$WORKSPACE/$case_name.body" local headers_path="$WORKSPACE/$case_name.headers" @@ -75,13 +74,11 @@ run_case() { env -u "$SMOKE_CONFIG_ENV" -u "$SMOKE_POLICY_ENV" - -u "$SMOKE_HANDLER_ENV" -u "$SMOKE_PROXY_ENV" -u "$SMOKE_EC_ENV" ) [ -z "$config_value" ] || env_args+=("$SMOKE_CONFIG_ENV=$config_value") [ -z "$policy_dir" ] || env_args+=("$SMOKE_POLICY_ENV=$policy_dir") - [ -z "$handler_value" ] || env_args+=("$SMOKE_HANDLER_ENV=$handler_value") [ -z "$proxy_value" ] || env_args+=("$SMOKE_PROXY_ENV=$proxy_value") [ -z "$ec_value" ] || env_args+=("$SMOKE_EC_ENV=$ec_value") @@ -106,21 +103,18 @@ run_case() { } run_case missing-config "$BASE_PORT" "" "$SMOKE_POLICY_DIR" \ - "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 500 \ + "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 500 \ "env var '$SMOKE_CONFIG_ENV' not set" run_case missing-policy "$((BASE_PORT + 1))" "$ENVELOPE" "" \ - "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 500 \ + "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 500 \ "$SMOKE_POLICY_ENV is not set" -run_case missing-handler "$((BASE_PORT + 2))" "$ENVELOPE" "$SMOKE_POLICY_DIR" \ - "" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 500 \ - "failed to resolve secret reference at \`handlers[0].password\`" -run_case missing-proxy "$((BASE_PORT + 3))" "$ENVELOPE" "$SMOKE_POLICY_DIR" \ - "$SMOKE_HANDLER_VALUE" "" "$SMOKE_EC_VALUE" 500 \ +run_case missing-proxy "$((BASE_PORT + 2))" "$ENVELOPE" "$SMOKE_POLICY_DIR" \ + "" "$SMOKE_EC_VALUE" 500 \ "failed to resolve secret reference at \`publisher.proxy_secret\`" -run_case missing-ec "$((BASE_PORT + 4))" "$ENVELOPE" "$SMOKE_POLICY_DIR" \ - "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "" 500 \ +run_case missing-ec "$((BASE_PORT + 3))" "$ENVELOPE" "$SMOKE_POLICY_DIR" \ + "$SMOKE_PROXY_VALUE" "" 500 \ "failed to resolve secret reference at \`ec.hmac.passphrase\`" -run_case positive "$((BASE_PORT + 5))" "$ENVELOPE" "$SMOKE_POLICY_DIR" \ - "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 200 +run_case positive "$((BASE_PORT + 4))" "$ENVELOPE" "$SMOKE_POLICY_DIR" \ + "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 200 echo "Axum first-success smoke passed" diff --git a/scripts/smoke-cloudflare.sh b/scripts/smoke-cloudflare.sh index dca54406fe..413b49103c 100755 --- a/scripts/smoke-cloudflare.sh +++ b/scripts/smoke-cloudflare.sh @@ -69,14 +69,10 @@ write_runtime_config() { # The secrets go at the end, under the manifest's last table, [vars]. awk \ -v missing_key="$missing_key" \ - -v handler="$SMOKE_HANDLER_VALUE" \ -v proxy="$SMOKE_PROXY_VALUE" \ -v ec="$SMOKE_EC_VALUE" ' { print } END { - if (missing_key != "handler_password") { - print "handler_password = \"" handler "\"" - } if (missing_key != "publisher_proxy_secret") { print "publisher_proxy_secret = \"" proxy "\"" } @@ -154,10 +150,9 @@ ENVELOPE=$( ) [ -n "$ENVELOPE" ] || smoke_die "Wrangler KV read-back returned an empty envelope" -run_case missing-handler "$((BASE_PORT + 1))" handler_password 500 'env.handler_password' -run_case missing-proxy "$((BASE_PORT + 2))" publisher_proxy_secret 500 \ +run_case missing-proxy "$((BASE_PORT + 1))" publisher_proxy_secret 500 \ 'env.publisher_proxy_secret' -run_case missing-ec "$((BASE_PORT + 3))" ec_passphrase 500 'env.ec_passphrase' -run_case positive "$((BASE_PORT + 4))" "" 200 "" +run_case missing-ec "$((BASE_PORT + 2))" ec_passphrase 500 'env.ec_passphrase' +run_case positive "$((BASE_PORT + 3))" "" 200 "" echo "Cloudflare first-success smoke passed" diff --git a/scripts/smoke-common.sh b/scripts/smoke-common.sh index e240927f0e..e44f5d8aa2 100644 --- a/scripts/smoke-common.sh +++ b/scripts/smoke-common.sh @@ -212,9 +212,7 @@ smoke_initialize_config() { } SMOKE_CONFIG_ENV=TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG -SMOKE_HANDLER_ENV=TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD SMOKE_PROXY_ENV=TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET SMOKE_EC_ENV=TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE -SMOKE_HANDLER_VALUE=smoke-admin-password-32-bytes-ok SMOKE_PROXY_VALUE=smoke-publisher-proxy-secret-32-bytes SMOKE_EC_VALUE=smoke-edge-cookie-passphrase-32-bytes diff --git a/scripts/smoke-fastly.sh b/scripts/smoke-fastly.sh index d3346d43a5..a4c466d74b 100755 --- a/scripts/smoke-fastly.sh +++ b/scripts/smoke-fastly.sh @@ -152,10 +152,6 @@ run_case missing-config "$BASE_PORT" 500 \ ) cat >>"$FASTLY_MANIFEST" <"$WORKSPACE/edgezero.toml" -SPIN_HANDLER_VAR=v_trusted_x5fserver_x5fsecrets_v_handler_x5fpassword SPIN_PROXY_VAR=v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret SPIN_EC_VAR=v_trusted_x5fserver_x5fsecrets_v_ec_x5fpassphrase @@ -76,9 +75,6 @@ run_case() { --truncate-logs ) smoke_assert_process_alive "$ORIGIN_PID" "stub origin" "$WORKSPACE/origin.log" - if [ "$missing_variable" != "$SPIN_HANDLER_VAR" ]; then - spin_args+=(--variable "$SPIN_HANDLER_VAR=$SMOKE_HANDLER_VALUE") - fi if [ "$missing_variable" != "$SPIN_PROXY_VAR" ]; then spin_args+=(--variable "$SPIN_PROXY_VAR=$SMOKE_PROXY_VALUE") fi @@ -125,9 +121,8 @@ EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=default \ --no-diff CONFIG_PUSHED=true -run_case missing-handler "$((BASE_PORT + 1))" "$SPIN_HANDLER_VAR" 500 -run_case missing-proxy "$((BASE_PORT + 2))" "$SPIN_PROXY_VAR" 500 -run_case missing-ec "$((BASE_PORT + 3))" "$SPIN_EC_VAR" 500 -run_case positive "$((BASE_PORT + 4))" "" 200 +run_case missing-proxy "$((BASE_PORT + 1))" "$SPIN_PROXY_VAR" 500 +run_case missing-ec "$((BASE_PORT + 2))" "$SPIN_EC_VAR" 500 +run_case positive "$((BASE_PORT + 3))" "" 200 echo "Spin first-success smoke passed" diff --git a/scripts/template-cache-local-test.sh b/scripts/template-cache-local-test.sh index c56d41b20a..48d1e2bf8f 100755 --- a/scripts/template-cache-local-test.sh +++ b/scripts/template-cache-local-test.sh @@ -10,26 +10,18 @@ # Usage: # ./scripts/template-cache-local-test.sh # esi mode (shared template + edge assembly) # ./scripts/template-cache-local-test.sh inline # today's shipped behaviour, as a control -# ./scripts/template-cache-local-test.sh purge # store -> hit -> purge -> miss, end to end set -euo pipefail MODE="${1:-esi}" case "$MODE" in - inline | esi | purge) ;; + inline | esi) ;; *) - echo "Unknown mode '$MODE'. Use one of: inline, esi, purge." >&2 + echo "Unknown mode '$MODE'. Use one of: inline, esi." >&2 exit 1 ;; esac -# `purge` exercises the same shared-template configuration as `esi`, then invalidates it. -# Everything upstream of the purge assertions is identical, so the config generator and -# every esi assertion keep running unchanged. -CONFIG_MODE="$MODE" -if [ "$MODE" = "purge" ]; then - CONFIG_MODE="esi" -fi REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORK="$(mktemp -d)" ORIGIN_PORT="${ORIGIN_PORT:-9099}" @@ -244,7 +236,7 @@ ORIGIN_PID=$! sleep 1 info "Generating stub config (mode: $MODE)" -python3 - "$REPO_ROOT/trusted-server.example.toml" "$WORK/app.toml" "$CONFIG_MODE" \ +python3 - "$REPO_ROOT/trusted-server.example.toml" "$WORK/app.toml" "$MODE" \ "$ORIGIN_PORT" "$BID_PORT" <<'PYEOF' import sys @@ -452,10 +444,6 @@ data = "fictional-local-publisher-proxy-secret-value" [[local_server.secret_stores.trusted_server_secrets]] key = "ec_passphrase" data = "fictional-local-ec-passphrase-secret-value" - -[[local_server.secret_stores.trusted_server_secrets]] -key = "handler_password" -data = "fictional-local-handler-password-secret-value" ''') PYEOF @@ -575,7 +563,7 @@ check_post_reaches_origin() { "$(( $(grep -cF "origin: received POST /article" "$WORK/origin.log" || true) - before ))" "1" } -if [ "$CONFIG_MODE" = "inline" ]; then +if [ "$MODE" = "inline" ]; then check "inline fetches the origin every time" "$FETCHES" "2" check "inline writes no shared template" \ "$(grep -c 'template_cache stored' "$WORK/viceroy.log" || true)" "0" @@ -727,7 +715,7 @@ NODEEOF check_post_reaches_origin fi -if [ "$CONFIG_MODE" = "esi" ]; then +if [ "$MODE" = "esi" ]; then info "Where the marker actually lives" echo " The cached template (the shared copy — has a hole where bids go):" grep -oE "template_cache stored [0-9]+ bytes \(seam marker present: [a-z]+\)" \ @@ -817,7 +805,7 @@ COMPLETE=$(echo "$B_LINE" | sed -n 's/.*complete=\([0-9]*\)ms.*/\1/p') if ! [[ "$FIRST_BODY" =~ ^[0-9]+$ && "$COMPLETE" =~ ^[0-9]+$ ]]; then bad "socket probe did not return numeric body timings: '$B_LINE'" else - if [ "$CONFIG_MODE" = "inline" ]; then + if [ "$MODE" = "inline" ]; then check "inline delivers the article before the auction resolves" \ "$(awk -v f="$FIRST_BODY" -v c="$COMPLETE" 'BEGIN { print (f < c / 3) ? "yes" : "no" }')" \ "yes" @@ -832,7 +820,7 @@ else printf ' first body byte %sms, complete %sms\n\n' "$FIRST_BODY" "$COMPLETE" fi -if [ "$CONFIG_MODE" != "inline" ]; then +if [ "$MODE" != "inline" ]; then # Guards a regression where assembly rewrote a reader's accepted gzip origin request # to identity, making the origin send ~674KB where it would have sent ~100KB. The # cache still stores identity; that does not require changing what this reader accepts. @@ -840,91 +828,8 @@ if [ "$CONFIG_MODE" != "inline" ]; then "$(grep -c 'served PLAINTEXT' "$WORK/origin.log" || true)" "0" fi -if [ "$MODE" = "purge" ]; then - info "Purge invalidates the shared template" - - # The config's `password = "handler_password"` is a secret-store *reference*; the basic - # auth value is the seeded secret itself. Read it back from the generated manifest so - # this cannot drift from the seeding block above. - ADMIN_PASSWORD=$(awk -F'"' '/^key = "handler_password"/ { found = 1; next } \ - found && /^data = / { print $2; exit }' "$WORK/fastly.toml") - if [ -z "$ADMIN_PASSWORD" ]; then - bad "could not read the seeded admin password from the generated manifest" - ADMIN_PASSWORD="unreadable" - fi - - # Viceroy 0.17 implements purge_surrogate_key against the same in-process cache it - # serves reads from, so store -> hit -> purge -> miss is genuinely end to end here. No - # Fastly service is involved, which is what makes this the strongest check available - # for the purge path. - - fetch_article_state() { - local headers - headers=$(curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -D- -o /dev/null \ - -H "Host: ts.example.com" \ - -H "Accept-Encoding: gzip" \ - -H "sec-fetch-dest: document" -H "sec-fetch-mode: navigate" \ - "http://127.0.0.1:$TS_PORT/article") - echo "$headers" > "$WORK/purge-probe.headers" - template_cache_state "$WORK/purge-probe.headers" - } - - purge() { - curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -o "$WORK/purge.out" -w '%{http_code}' \ - -X POST \ - -u "admin:$ADMIN_PASSWORD" \ - -H "Host: ts.example.com" \ - -H "Content-Type: application/json" \ - --data "$1" \ - "http://127.0.0.1:$TS_PORT/_ts/admin/cache/purge" - } - - # The suite above has already warmed the cache; confirm that before purging, or a - # "miss after purge" result would prove nothing. - check "the template is warm before the purge" "$(fetch_article_state)" "hit" - - check "purge-all is accepted" "$(purge '{"scope":"all"}')" "200" - check "purge-all reports success" \ - "$(grep -c '\"purged\":true' "$WORK/purge.out" || true)" "1" - - check "the next request misses after a purge, and refills" \ - "$(fetch_article_state)" "miss-stored" - check "the cache refills after the purge" "$(fetch_article_state)" "hit" - - info "Purge guards" - - check "an unauthenticated purge is refused" \ - "$(curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -o /dev/null -w '%{http_code}' \ - -X POST -H "Host: ts.example.com" -H "Content-Type: application/json" \ - --data '{"scope":"all"}' \ - "http://127.0.0.1:$TS_PORT/_ts/admin/cache/purge")" "401" - - # The guard the route claims every method for: an unclaimed method would fall through - # to the publisher with the Authorization header still attached. - check "a GET is answered locally, not forwarded to the origin" \ - "$(curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -o /dev/null -w '%{http_code}' \ - -u "admin:$ADMIN_PASSWORD" -H "Host: ts.example.com" \ - "http://127.0.0.1:$TS_PORT/_ts/admin/cache/purge")" "405" - - check "a form-postable content type is refused" \ - "$(curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -o /dev/null -w '%{http_code}' \ - -X POST -u "admin:$ADMIN_PASSWORD" -H "Host: ts.example.com" \ - -H "Content-Type: text/plain" --data '{"scope":"all"}' \ - "http://127.0.0.1:$TS_PORT/_ts/admin/cache/purge")" "415" - - check "scope all carrying a url is refused rather than flushing" \ - "$(purge '{"scope":"all","url":"http://ts.example.com/article"}')" "400" - - # Purging one reader-facing URL, which is the scope a CMS webhook uses. - check "the template is warm before the url purge" "$(fetch_article_state)" "hit" - check "purge-url is accepted" \ - "$(purge '{"scope":"url","url":"http://ts.example.com/article"}')" "200" - check "the next request misses after a url purge, and refills" \ - "$(fetch_article_state)" "miss-stored" -fi - info "Cookie variant isolation and session bypass (mode: $MODE)" -if python3 - "$TS_PORT" "$CONFIG_MODE" "$WORK/origin.log" "$REQUEST_TIMEOUT_SECONDS" <<'PYEOF' +if python3 - "$TS_PORT" "$MODE" "$WORK/origin.log" "$REQUEST_TIMEOUT_SECONDS" <<'PYEOF' import gzip import sys import urllib.request diff --git a/trusted-server.example.toml b/trusted-server.example.toml index cfcd7fc0f5..870dd33230 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -6,15 +6,14 @@ # `trusted-server.toml`. Copy it (`ts config init`), fill in the required # values, and push it (`ts config push`) as an EdgeZero app-config blob. # -# Only two sections are REQUIRED for the server to start and pass validation: -# 1. [[handlers]] covering /_ts/admin (admin authentication) -# 2. [publisher] (domain + origin) +# Only one section is REQUIRED for the server to start and pass validation, +# which is [publisher] (domain + origin). # # Edge Cookie identity is optional and stays off until an [ec] module is # selected, so no identity secret is needed to start. See the Edge Cookie # section below. # -# Everything below those is OPTIONAL. Most optional blocks are commented out, so +# Everything below it is OPTIONAL. Most optional blocks are commented out, so # uncomment and edit one to switch it on. All example hosts use `example.com`, # so replace them with your real endpoints in your private config, not in this # template. @@ -43,28 +42,6 @@ # ============================================================================= -# ----------------------------------------------------------------------------- -# REQUIRED - Admin authentication -# ----------------------------------------------------------------------------- -# HTTP Basic-auth handler(s). At least one handler whose `path` regex covers -# the /_ts/admin endpoints is mandatory; startup fails without it. Each handler -# needs a non-placeholder username/password (deploy validation rejects the -# sample password below). -[[handlers]] -# Regex matched against the request path. This one guards the admin surface. -path = "^/_ts/admin" -username = "admin" -password = "handler_password" - -# You can add more handlers to basic-auth-protect other path prefixes. The -# sample password below is a known placeholder that deploy validation rejects, -# so it forces a real secret before push: -# [[handlers]] -# path = "^/secure" -# username = "user" -# password = "replace-with-admin-password" - - # ----------------------------------------------------------------------------- # REQUIRED - Publisher / origin # ----------------------------------------------------------------------------- @@ -207,17 +184,11 @@ pull_sync_concurrency = 3 # [response_headers] # X-Robots-Tag = "noindex" -# Sign outbound OpenRTB/API requests. These are platform management-API store -# IDs used for key-rotation WRITES; the runtime reads keys from fixed store names -# (`jwks_store` / `signing_keys`), not these. To keep signing OFF, leave this -# whole block commented out. If you uncomment it - even with `enabled = false` - -# both store IDs are required and must be REAL values: the key rotate/deactivate -# admin routes read them regardless of `enabled`, so deploy validation rejects -# the placeholders below (replace them before pushing). +# Sign outbound OpenRTB/API requests. The runtime reads its keys from fixed +# store names (`jwks_store` / `signing_keys`), and `ts keys` writes them. To keep +# signing OFF, leave this whole block commented out. # [request_signing] # enabled = false -# config_store_id = "" -# secret_store_id = "" # First-party HTML/CSS rewriting controls. # [rewrite]