From e95ca11970c2e6974d14f66db5a696dbed88d9cb Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 12:22:19 +0100 Subject: [PATCH 01/25] ts keys rotates and retires the signing keys through the host `ts keys rotate` and `ts keys deactivate` do from an operator's machine what `POST /_ts/admin/keys/rotate` and `POST /_ts/admin/keys/deactivate` do from inside the running service. They drive the same rotation code in core, `KeyRotationManager`, over two store implementations that write through the `fastly` CLI with the operator's own token from `FASTLY_API_TOKEN`. The token reaches `fastly` in its environment and a private key reaches it on standard input, so neither is in an argument list another process can read. Every value is joined to its flag with `=`, so a key id beginning with `-` is never read as a flag. A key id is checked before anything is asked of `fastly`. A new key takes the rule the rotate route applies, `kid_is_creatable`. A key being retired takes the looser rule the deactivate route applies, exposed here as `kid_is_well_formed`, so that a key created under an earlier rule can still be removed. Neither command takes `current-kid` or `active-kids` for a key. Named as a key, either entry would be written over by a rotation or deleted with the key. Two things differ from the stores the service reads, and the command's stores allow for both. - A read is a call to the platform and can fail for a reason other than a missing entry. The rotation library takes any failed read for an entry that does not exist, which would write a key list without the keys already active, or let the current key leave the active set. So the stores tell a `404` from any other failure and write nothing once a read has failed. - The service's management client takes a `404` on a delete for success, which is how a delete tried again after a partial failure finishes. The stores do the same, after asking for the store, because the API answers `404` for a missing store too. `fastly_cli` holds the runner, so the next command that administers a service through the host uses the same one. Its report of a failed `fastly` names the command by its words and says what `fastly` printed, including when the process stopped before it read its input. The command comes before the removal of the two routes, so that no commit leaves an operator without a way to rotate a key. --- CHANGELOG.md | 1 + .../trusted-server-cli/src/commands/keys.rs | 918 ++++++++++++++++++ crates/trusted-server-cli/src/commands/mod.rs | 1 + crates/trusted-server-cli/src/fastly_cli.rs | 149 +++ crates/trusted-server-cli/src/lib.rs | 2 + crates/trusted-server-cli/src/run.rs | 31 + .../src/request_signing/endpoints.rs | 25 + docs/guide/cli.md | 41 + 8 files changed, 1168 insertions(+) create mode 100644 crates/trusted-server-cli/src/commands/keys.rs create mode 100644 crates/trusted-server-cli/src/fastly_cli.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e32d4189..c92cf8fbb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `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. - Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. - One Fastly service can serve several publishers. A `__KEY` selector 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. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. A selector without the placeholder reads one blob as before. `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(...)`. 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..3cde1ecadd --- /dev/null +++ b/crates/trusted-server-cli/src/commands/keys.rs @@ -0,0 +1,918 @@ +//! `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::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. +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) { + // The first failure is kept. One is enough to stop every + // write, so a later one has nothing to add. + 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()) + .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) => { + // Looser than the rule for a new key, so that a key made under an + // earlier rule can still be retired. + 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('=')) + } + + fn not_found(what: &str) -> String { + format!("`fastly` failed: ERROR: the Fastly API returned 404 Not Found: {what}") + } + + 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") => { + let name = flag(args, "--name").unwrap_or_default(); + self.secrets + .lock() + .expect("should lock") + .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")); + } + + #[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..f29d7cf432 100644 --- a/crates/trusted-server-cli/src/commands/mod.rs +++ b/crates/trusted-server-cli/src/commands/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod audit; 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 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..3cdaad8563 --- /dev/null +++ b/crates/trusted-server-cli/src/fastly_cli.rs @@ -0,0 +1,149 @@ +//! 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}"))?; + // The pipe closes when this is done, which is how `fastly` learns the + // value has ended. + 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. +#[cfg(test)] +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 789b87c13f..b5cec461bb 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -40,6 +40,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. @@ -155,6 +158,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; @@ -199,6 +205,31 @@ mod tests { Args::try_parse_from(args).expect("should parse args") } + #[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-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index d35e51a151..9385dec785 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -279,6 +279,17 @@ pub fn kid_is_creatable(kid: &str) -> bool { validate_kid(kid).is_ok() } +/// Returns whether `kid` has the shape of a stored key id, which +/// [`validate_kid_format`] checks. +/// +/// 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() +} + /// Rotates the current active kid by generating and saving a new one. /// /// # Response contract @@ -1186,6 +1197,20 @@ mod tests { assert!(req.delete); } + #[test] + 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] fn test_handle_trusted_server_discovery() { let settings = crate::test_support::tests::create_test_settings(); diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 4f4d7e4aef..29fe937363 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -43,6 +43,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] ` | @@ -277,6 +280,44 @@ 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. `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). + ## Audit a public page `ts audit` loads a public page in a fresh headless Chrome/Chromium session, From 95f4346e95da07d368ad8d907e94679f386ae8c6 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 12:42:05 +0100 Subject: [PATCH 02/25] ts cache purge asks the host to purge The command called `POST /_ts/admin/cache/purge` on the publisher's domain with a shared password. It now runs `fastly service purge --key` with the operator's own token from `FASTLY_API_TOKEN`, so the credential that can empty the cache stays with the platform's access control and off the domain every reader reaches. A token with the `purge_select` scope is enough, which allows a purge by surrogate key and does not allow a purge of everything. `fastly service purge` is the Fastly CLI's name for the command since its version 14.0.0, where `fastly purge` became a deprecated alias. The repository pins 15.1.0. The surrogate key is derived in the command by `reader_url_surrogate_key`, the function the service tags a cached page with, and `--all` names `ts-template`, the key every cached object carries. Both scopes purge what they purged before. Neither runs `fastly service purge --all`, which would also flush objects the service cached without one of those keys. `--service `, `--username` and `TRUSTED_SERVER_ADMIN_PASSWORD` go and `--service-id ` arrives. A service id that is not letters and digits is refused, because it becomes an argument of the `fastly` process. The tests that drove the command against a fixture service are replaced by tests against a fake `fastly`. The command changes before the route is removed, for the reason `ts keys` comes first. The guide's command table gains the two `ts cache` rows it lacked. --- CHANGELOG.md | 1 + .../src/commands/cache/mod.rs | 60 ++- .../src/commands/cache/purge.rs | 410 +++++++++--------- crates/trusted-server-cli/src/commands/mod.rs | 7 +- .../src/commands/origin/mod.rs | 2 +- crates/trusted-server-cli/src/fastly_cli.rs | 1 - crates/trusted-server-cli/src/run.rs | 34 +- crates/trusted-server-cli/src/url_guard.rs | 4 +- .../trusted-server-cli/tests/cache_purge.rs | 276 ------------ docs/guide/cli.md | 28 ++ docs/guide/configuration.md | 8 +- 11 files changed, 305 insertions(+), 526 deletions(-) delete mode 100644 crates/trusted-server-cli/tests/cache_purge.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c92cf8fbb2..dc85ba4ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **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, and no longer on every page of a deployment that selects the module. 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 hook 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. diff --git a/crates/trusted-server-cli/src/commands/cache/mod.rs b/crates/trusted-server-cli/src/commands/cache/mod.rs index 889852e10e..3ee3027404 100644 --- a/crates/trusted-server-cli/src/commands/cache/mod.rs +++ b/crates/trusted-server-cli/src/commands/cache/mod.rs @@ -1,63 +1,59 @@ //! `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 +/// # Why this asks the platform rather than the service /// -/// 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. +/// A purge is administration, and the service answers readers on the publisher's own +/// domain. Asking the platform leaves the credential that can empty the cache with the +/// platform's access control, which ties a token to the user who made it, can limit it +/// to purging by key and can revoke it, and keeps that credential off the domain every +/// reader reaches. /// -/// 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..7a774b4b02 100644 --- a/crates/trusted-server-cli/src/commands/cache/purge.rs +++ b/crates/trusted-server-cli/src/commands/cache/purge.rs @@ -1,268 +1,268 @@ -//! 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. /// -/// `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()), +/// # Errors +/// +/// 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. Anything else is refused here, + // because the id becomes an argument of the `fastly` process and a value starting + // with `-` would be read as one of its flags. + 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(), + }), + // Parsed, not merely non-empty. The key function hashes whatever it is given, so a + // path or a scheme-less host would be purged under a key nothing was ever stored + // with, and the command would report success having invalidated nothing. (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), + }) } + // Defaulting to `--all` would make a bare `ts cache purge` flush production. (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, and never `fastly service purge --all`, which would + // also flush every object this service cached without one of the template cache's tags. + runner + .run( + &args(&[ + "service", + "purge", + "--key", + &target.surrogate_key, + "--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(), } } + fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter() + .position(|arg| arg == name) + .and_then(|at| args.get(at + 1)) + .map(String::as_str) + } + + 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(); + + let error = + purge(&fake, &purge_args(false, Some(page))).expect_err("should refuse the page"); + + assert!( + error.contains("--page"), + "should name the flag for {page:?}: {error}" + ); + assert!(fake.calls().is_empty(), "nothing should be asked of fastly"); + } } #[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 fake = FakeFastly::default(); - #[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" - ); - } + 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 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", - ] { - assert!( - purge_endpoint(service).is_err(), - "should refuse remote plaintext transport: {service}" - ); + 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 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 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/mod.rs b/crates/trusted-server-cli/src/commands/mod.rs index f29d7cf432..2cdc395b0a 100644 --- a/crates/trusted-server-cli/src/commands/mod.rs +++ b/crates/trusted-server-cli/src/commands/mod.rs @@ -1,12 +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 index 3cdaad8563..55a5f7872e 100644 --- a/crates/trusted-server-cli/src/fastly_cli.rs +++ b/crates/trusted-server-cli/src/fastly_cli.rs @@ -99,7 +99,6 @@ fn command_words(args: &[String]) -> String { } /// The arguments of one `fastly` invocation, owned. -#[cfg(test)] pub(crate) fn args(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_owned()).collect() } diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index b5cec461bb..f0427b0291 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -182,7 +182,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(); @@ -205,6 +205,38 @@ 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(&[ 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/docs/guide/cli.md b/docs/guide/cli.md index 29fe937363..f03f62713b 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 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> ` | @@ -318,6 +320,32 @@ A command that cannot read the config store writes nothing. `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/configuration.md b/docs/guide/configuration.md index a2da78c4c7..0e6632d8a4 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2876,12 +2876,12 @@ 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 + object existed. The command asks Fastly to purge, through the `fastly` CLI with the + token in `FASTLY_API_TOKEN`. 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 @@ -3035,7 +3035,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 From 492f172df6cc4012d3abaddbb608a45be09bfb7a Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 12:59:53 +0100 Subject: [PATCH 03/25] The Edge Cookie lookups under /_ts/admin are removed `GET /_ts/admin/ec` and `GET /_ts/admin/ec/{id}` read any reader's identity graph record, and `GET /_ts/admin/eids` decoded the caller's EID cookies, on the publisher's own domain behind one shared password. `GET /_ts/data` shows a reader what is held against that reader's own Edge Cookie and asks for no password, so the operator lookups go. What goes with them: `ec/admin.rs`, `KvIdentityGraph::lookup_raw`, the decoder's diagnostic view of an EID cookie, the two probes that established handler coverage for the lookup by id, the adapters' routes and their not-supported stand-ins, and the tests and guide sections for all of it. What stays is the check that keeps administration paths away from the origin. It moves to `closed_paths.rs` as `closed_path_response`, with the matcher unchanged: every path beneath `/_ts/admin`, and the `/admin/keys` alias beside it, as sent and after each of up to four percent-decoding rounds. A request there that no route claims is answered `404` by the server, so a caller's `Authorization` header and body are never forwarded. The three removed paths are answered that way for every method, where a method other than GET was answered `405`. --- CHANGELOG.md | 6 +- crates/trusted-server-adapter-axum/src/app.rs | 41 +- .../tests/routes.rs | 136 +- .../src/app.rs | 32 +- .../tests/routes.rs | 111 +- .../trusted-server-adapter-fastly/src/app.rs | 241 +-- crates/trusted-server-adapter-spin/src/app.rs | 42 +- .../tests/routes.rs | 108 +- crates/trusted-server-core/src/attestation.rs | 3 - crates/trusted-server-core/src/auth.rs | 2 +- .../trusted-server-core/src/closed_paths.rs | 264 +++ crates/trusted-server-core/src/ec/admin.rs | 1698 ----------------- .../trusted-server-core/src/ec/generation.rs | 2 +- crates/trusted-server-core/src/ec/kv.rs | 25 +- crates/trusted-server-core/src/ec/mod.rs | 13 +- crates/trusted-server-core/src/ec/module.rs | 10 +- .../trusted-server-core/src/ec/prebid_eids.rs | 58 +- .../src/integrations/registry.rs | 4 +- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/settings.rs | 196 +- docs/guide/api-reference.md | 95 +- docs/guide/axum-dev.md | 3 +- docs/guide/cloudflare.md | 6 +- docs/guide/configuration.md | 9 +- docs/guide/onboarding.md | 2 +- docs/guide/spin.md | 7 +- 26 files changed, 337 insertions(+), 2778 deletions(-) create mode 100644 crates/trusted-server-core/src/closed_paths.rs delete mode 100644 crates/trusted-server-core/src/ec/admin.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dc85ba4ad0..bcfeb41811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ 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. +- **Breaking:** 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. @@ -52,6 +52,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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:** `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. 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 - `/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. diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 99b4e8353a..be7472c2d7 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -15,12 +15,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}; @@ -364,7 +361,7 @@ async fn dispatch_fallback( services: &RuntimeServices, req: Request, ) -> Result> { - if let Some(response) = deny_admin_diagnostic_fallback(&req) { + if let Some(response) = closed_path_response(&req) { return Ok(response); } @@ -458,8 +455,6 @@ enum NamedRouteHandler { 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, @@ -487,7 +482,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 22] { +fn named_routes() -> [NamedRoute; 19] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -549,26 +544,6 @@ fn named_routes() -> [NamedRoute; 22] { 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 @@ -686,16 +661,6 @@ fn named_route_handler( ); 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 diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 6b49bcb3f9..f572869088 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -129,9 +129,6 @@ fn all_explicit_routes_are_registered() { ("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"), @@ -364,141 +361,12 @@ async fn admin_route_without_credentials_returns_401() { } #[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(), diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 7de0f9e5c6..a80698cc96 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -14,6 +14,7 @@ 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(any(test, target_arch = "wasm32"))] use trusted_server_core::config_payload::CONFIG_BLOB_KEY; #[cfg(target_arch = "wasm32")] @@ -21,12 +22,7 @@ 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}; @@ -523,10 +519,6 @@ fn cache_purge_not_supported() -> Response { 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. /// @@ -692,7 +684,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) { @@ -824,26 +816,6 @@ fn build_router(state: &Arc) -> RouterService { .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 { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index ce95980e88..ce03e868df 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -271,9 +271,6 @@ fn all_explicit_routes_are_registered() { ("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 @@ -335,116 +332,12 @@ async fn authenticated_admin_routes_return_501() { } #[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(), diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 64871f8e45..08835e9dd1 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -22,9 +22,6 @@ //! | 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`] | @@ -57,7 +54,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. @@ -112,11 +109,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}; @@ -801,34 +796,6 @@ async fn execute_named( 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. The permissions are resolved for this request as a page's // would be, then shown, and the Edge Cookie lifecycle never runs, so // nothing is written for the reader. @@ -903,9 +870,6 @@ async fn run_named_route( } 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") } @@ -1062,7 +1026,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; } @@ -1370,8 +1334,6 @@ enum NamedRouteHandler { 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). @@ -1443,25 +1405,6 @@ const NAMED_ROUTES: &[NamedRoute] = &[ 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 @@ -1769,7 +1712,6 @@ mod tests { build_per_request_services, build_state_from_settings, handle_publisher_request, publisher_response_into_streaming_response, startup_error_router, }; - use base64::Engine as _; use bytes::Bytes; use edgezero_core::app::Hooks as _; use edgezero_core::body::Body; @@ -2343,44 +2285,6 @@ mod tests { ); } - #[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 @@ -2481,51 +2385,13 @@ mod tests { } #[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(), @@ -3318,103 +3184,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 diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index ec222620fa..5074e57818 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -18,15 +18,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}; @@ -381,7 +377,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 22] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 19] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -392,9 +388,6 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 22] { (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), @@ -629,10 +622,6 @@ fn admin_key_management_not_supported() -> Response { response } -fn admin_ec_lookup_not_supported() -> Response { - core_admin_ec_lookup_not_supported() -} - // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -867,23 +856,6 @@ fn build_router(state: &Arc) -> RouterService { 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| { @@ -1024,7 +996,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) { @@ -1157,14 +1129,6 @@ fn build_router(state: &Arc) -> RouterService { // 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) diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 088e0ce57d..4a532227b2 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -120,116 +120,12 @@ async fn authenticated_admin_routes_return_501() { } #[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(), diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs index 8e54969b21..5ff1f52639 100644 --- a/crates/trusted-server-core/src/attestation.rs +++ b/crates/trusted-server-core/src/attestation.rs @@ -67,9 +67,6 @@ pub const RESERVED_PATHS: &[&str] = &[ "/_ts/admin/keys/rotate", "/_ts/admin/keys/deactivate", "/_ts/admin/cache/purge", - "/_ts/admin/ec", - "/_ts/admin/ec/{id}", - "/_ts/admin/eids", "/_ts/api/v1/batch-sync", "/_ts/api/v1/identify", "/_ts/api/v1/ec/resolve", diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index 9bbd40b69a..e9b33b914e 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -204,7 +204,7 @@ mod tests { // `^/_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`). + // upstream (see `closed_paths::closed_path_response`). let settings = create_test_settings(); for path in ["/_ts/admin%2Fec", "/_ts/admin%2fec"] { 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..1f78cc310e --- /dev/null +++ b/crates/trusted-server-core/src/closed_paths.rs @@ -0,0 +1,264 @@ +//! Paths this server answers itself and never forwards to the publisher's origin. +//! +//! A request to the server's administration prefix, `/_ts/admin`, or to the +//! `/admin/keys` addresses beside it, can carry an operator's `Authorization` +//! header and an administration payload. Forwarding one would hand both to the +//! origin, so every adapter asks [`closed_path_response`] before its publisher +//! fallback, and a request that no route claims 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. +/// +/// Mirrors the documented `^/_ts/admin` basic-auth handler regex, so every +/// path that handler authenticates is also closed at the fallback boundary. +/// It is matched as a bare prefix, with no `/` after it, which also covers a +/// percent-encoded separator such as `/_ts/admin%2Fec`. The auth handler +/// matches that spelling and a check for a literal slash would not. +const ADMIN_NAMESPACE_PREFIX: &str = "/_ts/admin"; + +/// The key administration alias outside `/_ts`. +/// +/// No auth handler matches it, so 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; + } + + // Normal publisher paths carry no escape sequence, so the decode loop and + // its allocation are 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, 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() { + // `/_ts/admin%2Fec` matches the documented `^/_ts/admin` basic-auth + // handler, so it is authenticated, and a check for a literal slash + // would miss it. 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/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs deleted file mode 100644 index 3279fb9787..0000000000 --- a/crates/trusted-server-core/src/ec/admin.rs +++ /dev/null @@ -1,1698 +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 so the preview reports exactly what an eligible 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 695be1c635..b227c5a2d9 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 882bd17a63..7ef3ccb5b9 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; @@ -859,7 +856,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. /// @@ -1494,7 +1491,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; @@ -2498,9 +2495,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 f81aca3643..b54f4af439 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 c4e95bc4e6..45b86003c5 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), @@ -152,28 +120,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( @@ -205,7 +157,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); @@ -221,14 +173,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/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 86f10cc8e2..c7758b0d39 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -428,7 +428,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, @@ -512,7 +512,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 647e4a8889..ca0c3a8f61 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -37,6 +37,7 @@ pub mod auth; pub mod build_info; 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/settings.rs b/crates/trusted-server-core/src/settings.rs index 29608338b9..2afbd7d415 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4313,50 +4313,12 @@ impl Settings { /// 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 @@ -4371,16 +4333,12 @@ impl Settings { ) -> 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; - } + let mut covered = false; + for handler in &self.handlers { + if handler.matches_path(path)? { + covered = true; + break; } - covered &= probe_covered; } if !covered { uncovered.push(path); @@ -9651,9 +9609,6 @@ source_domain = "partner.example.com" 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" @@ -9689,78 +9644,22 @@ source_domain = "partner.example.com" .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", - ], + vec!["/_ts/admin/keys/deactivate", "/_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. + // ahead of the admin matcher governs the paths it matches, and none of + // them need be a listed admin endpoint. The placeholder check therefore + // cannot be limited to handlers inferred to cover one. 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$" + r#"path = "^/_ts/admin/reports/[a-z0-9]{6}$" username = "admin" password = "change-me-admin-password" @@ -9804,81 +9703,6 @@ source_domain = "partner.example.com" ); } - #[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!( diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 1bc09ca88a..e4d1559c59 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,9 +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 | — | — | — | @@ -1090,92 +1087,13 @@ curl -X POST https://edge.example.com/_ts/admin/keys/deactivate \ --- -## Admin Diagnostic Endpoints +## The Administration Prefix -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 whole `/_ts/admin` prefix is closed to the publisher fallback. A request beneath it that no route claims, whether unknown, malformed or percent-encoded (`/_ts/admin%2Fec`), is answered locally with `404` and `Cache-Control: no-store` 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 closed the same way. -The diagnostic handlers have no CORS grant or in-process rate limiter. Apply an -edge policy if operator access also needs request-rate enforcement. +Configure a handler that covers the entire `/_ts/admin` namespace, because startup rejects configurations that do not protect every admin route. Missing or invalid credentials receive the shared plaintext `401 Unauthorized` Basic-auth challenge. -The examples below use fictional IDs and values only. - -### GET /\_ts/admin/ec - -### GET /\_ts/admin/ec/`{id}` - -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. - -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. - -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. +What is held against a reader's own Edge Cookie is shown to that reader at [`GET /_ts/data`](#get-ts-data). ### Legacy /admin/keys/rotate and /admin/keys/deactivate aliases @@ -1500,9 +1418,6 @@ curl -u 'admin:' https://edge.example.com/_ts/admin/key - `/_ts/admin/keys/rotate` - `/_ts/admin/keys/deactivate` -- `/_ts/admin/ec` -- `/_ts/admin/ec/{id}` -- `/_ts/admin/eids` - Any paths matching configured `handlers` patterns The EC partner APIs use their own Bearer-token contract. Signed first-party diff --git a/docs/guide/axum-dev.md b/docs/guide/axum-dev.md index a98acbb065..788f0b7f94 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -47,8 +47,7 @@ config, local-push output, bodies, headers, and logs. ## 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 +Key rotation is registered but returns a not-supported response because the adapter has no request-time KV implementation. It sanitizes forwarded client-IP headers but does not resolve `[trusted_client_ip]`; see the [adapter matrix](./api-reference#adapter-and-startup-support). diff --git a/docs/guide/cloudflare.md b/docs/guide/cloudflare.md index 73448d8cf1..b717e14f02 100644 --- a/docs/guide/cloudflare.md +++ b/docs/guide/cloudflare.md @@ -57,9 +57,9 @@ only after all four bindings are present. A successful `ts config push` alone does not configure this runtime: the Worker currently does not open `TRUSTED_SERVER_KV` during startup. Request-time store registries are also 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. +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.md b/docs/guide/configuration.md index 0e6632d8a4..48451a0760 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1174,13 +1174,8 @@ path = "^/api/v[0-9]+/private" # /api/v1/private, /api/v2/private ::: 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). +Startup fails when no handler covers an admin route. Use a prefix-level +matcher such as `^/_ts/admin`. 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 diff --git a/docs/guide/onboarding.md b/docs/guide/onboarding.md index e8f07693ea..f676592acb 100644 --- a/docs/guide/onboarding.md +++ b/docs/guide/onboarding.md @@ -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/spin.md b/docs/guide/spin.md index a0e2f8fd31..fbfa047a14 100644 --- a/docs/guide/spin.md +++ b/docs/guide/spin.md @@ -52,10 +52,9 @@ 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 -runtime authority, scheme, and client-address headers in the innermost -normalization layer. +rotation are unavailable. Forwarded client-IP headers are sanitized, and Spin +derives its runtime authority, scheme, and client-address headers in the +innermost normalization layer. Spin is not installed in the integration CI environment. The recurring manual smoke therefore needs a named owner, tested commit, exact tool versions, and an From 86c9344efa019831d2c8af53f978e238908d5917 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 13:08:43 +0100 Subject: [PATCH 04/25] Key rotation leaves the publisher's domain `POST /_ts/admin/keys/rotate` and `POST /_ts/admin/keys/deactivate` let anyone holding one shared password replace the keys the service signs with, over the domain every reader reaches. `ts keys rotate` and `ts keys deactivate` do the same work from an operator's machine with the operator's own token, so the two routes and their handlers go. Only Fastly served them. Axum, Cloudflare and Spin registered them to answer `501`. The `/admin/keys/rotate` and `/admin/keys/deactivate` routes go with them. They existed to refuse those paths, so that a call to one was not forwarded to the origin with its credentials. `closed_path_response` already answers every path beneath `/admin/keys` and `/_ts/admin` before the publisher fallback, on each adapter, so the four paths are still answered `404` here for every fallback method. Each adapter's `key_administration_paths_are_answered_here_and_never_proxied` holds that with the credentials a caller would send. `kid_is_creatable` and the two validators behind it stay in `request_signing/endpoints.rs`, because `ts keys` refuses a key id with them before it writes. The tests of the password challenge ask at `/_ts/admin/anything`, since the path they asked at is not a route any more, and the two probe tests take `/first-party/proxy` as their named route for the same reason. The key rotation guide's diagram had `current-kid` written before `active-kids`. The code writes `active-kids` first, and the diagram now agrees with the text beside it. `[request_signing]` still requires `config_store_id` and `secret_store_id`, which only the removed handlers read. They go in the next commit. --- CHANGELOG.md | 1 + crates/trusted-server-adapter-axum/src/app.rs | 69 +- .../tests/routes.rs | 136 +--- .../tests/seam_probe.rs | 13 +- .../src/app.rs | 60 -- .../tests/routes.rs | 151 +--- .../trusted-server-adapter-fastly/src/app.rs | 151 +--- .../src/app/seam_probe_tests.rs | 11 +- crates/trusted-server-adapter-spin/src/app.rs | 59 +- .../tests/routes.rs | 128 +--- crates/trusted-server-core/src/attestation.rs | 2 - .../src/request_signing/endpoints.rs | 695 +----------------- crates/trusted-server-core/src/settings.rs | 18 +- .../tests/parity.rs | 154 +--- docs/guide/api-reference.md | 124 +--- docs/guide/architecture.md | 13 +- docs/guide/axum-dev.md | 5 +- docs/guide/cloudflare.md | 7 +- docs/guide/error-reference.md | 17 +- docs/guide/getting-started.md | 2 +- docs/guide/key-rotation.md | 286 ++----- docs/guide/spin.md | 8 +- 22 files changed, 228 insertions(+), 1882 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcfeb41811..95686e4038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- **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. 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 diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index be7472c2d7..34c0dc49f4 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -266,23 +266,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 // --------------------------------------------------------------------------- @@ -453,11 +436,7 @@ enum NamedRouteHandler { Permissions, Config, Data, - AdminNotSupported, CachePurgeNotSupported, - /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never - /// reach the publisher fallback (which would leak admin credentials). - LegacyAdminDenied, Auction, PageBids, FirstPartyProxy, @@ -482,7 +461,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 19] { +fn named_routes() -> [NamedRoute; 15] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -523,19 +502,6 @@ fn named_routes() -> [NamedRoute; 19] { 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. @@ -544,22 +510,6 @@ fn named_routes() -> [NamedRoute; 19] { primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::CachePurgeNotSupported, }, - // 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], @@ -645,23 +595,6 @@ fn named_route_handler( ); 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::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()` diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index f572869088..ea0678893f 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -127,10 +127,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"), - ("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 @@ -155,29 +151,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) // --------------------------------------------------------------------------- @@ -342,7 +315,7 @@ async fn admin_route_without_credentials_returns_401() { let mut svc = make_service(); let req = Request::builder() .method("POST") - .uri("/_ts/admin/keys/rotate") + .uri("/_ts/admin/anything") .header("content-type", "application/json") .body(AxumBody::from("{}")) .expect("should build request"); @@ -419,15 +392,18 @@ async fn an_authenticated_request_to_a_closed_path_is_answered_here() { } #[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", + "/admin/keys/rotate", + "/admin/keys/deactivate", + ] { for method in LEGACY_ADMIN_DENY_METHODS { let mut svc = make_service(); let req = Request::builder() @@ -436,7 +412,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 @@ -447,7 +423,7 @@ 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" ); } } @@ -458,7 +434,7 @@ 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") + .uri("/_ts/admin/anything") .header("content-type", "application/json") .body(AxumBody::from("{}")) .expect("should build request"); @@ -497,7 +473,7 @@ async fn admin_route_with_wrong_credentials_returns_401() { let mut svc = make_service(); let req = Request::builder() .method("POST") - .uri("/_ts/admin/keys/rotate") + .uri("/_ts/admin/anything") .header("content-type", "application/json") .header("authorization", format!("Basic {creds}")) .body(AxumBody::from("{}")) @@ -683,84 +659,6 @@ async fn auction_endpoint_does_not_require_auth() { ); } -#[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 // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 7b4de0c8e9..efbbe7bf41 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -273,16 +273,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"), diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index a80698cc96..db68c3f579 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -491,20 +491,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\ @@ -519,23 +505,6 @@ fn cache_purge_not_supported() -> Response { response } -/// 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 // --------------------------------------------------------------------------- @@ -800,22 +769,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()) - }) .post( "/auction", make_handler(Arc::clone(&state), |s, services, req| async move { @@ -941,19 +894,6 @@ fn build_router(state: &Arc) -> RouterService { ); } - 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()); - } - // The attestation pages answer at the address the settings choose, so // they are registered here rather than with the fixed routes. Reads reach // the handler, and other methods reach the publisher's origin unless the diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index ce03e868df..e4a9070ecb 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -141,13 +141,13 @@ 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 + // challenge on a protected path (/_ts/admin/anything). 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") + .uri("/_ts/admin/anything") .header("content-type", "application/json") .body(edgezero_core::body::Body::from("{}")) .expect("should build request"); @@ -166,15 +166,18 @@ async fn auth_middleware_runs_in_chain_for_protected_routes() { } #[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", + "/admin/keys/rotate", + "/admin/keys/deactivate", + ] { for method in LEGACY_ADMIN_DENY_METHODS { let router = test_router(); let req = request_builder() @@ -183,14 +186,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" ); } } @@ -269,8 +272,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"), ("POST", "/auction"), // SPA re-auction endpoint, plus its deprecated `/__ts/` alias. Both // paths are spelled out as literals rather than referencing @@ -293,44 +294,12 @@ 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 // --------------------------------------------------------------------------- -#[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 an_authenticated_request_to_a_closed_path_is_answered_here() { let ec_id = format!("{}.abc123", "a".repeat(64)); @@ -388,7 +357,7 @@ async fn admin_route_without_credentials_returns_401() { let router = test_router(); let req = request_builder() .method("POST") - .uri("/_ts/admin/keys/rotate") + .uri("/_ts/admin/anything") .header("content-type", "application/json") .body(edgezero_core::body::Body::from("{}")) .expect("should build request"); @@ -405,7 +374,7 @@ 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") + .uri("/_ts/admin/anything") .header("content-type", "application/json") .body(edgezero_core::body::Body::from("{}")) .expect("should build request"); @@ -438,7 +407,7 @@ async fn admin_route_with_wrong_credentials_returns_401() { let router = test_router(); let req = request_builder() .method("POST") - .uri("/_ts/admin/keys/rotate") + .uri("/_ts/admin/anything") .header("content-type", "application/json") .header("authorization", format!("Basic {creds}")) .body(edgezero_core::body::Body::from("{}")) @@ -581,88 +550,6 @@ async fn auction_endpoint_does_not_require_auth() { ); } -// --------------------------------------------------------------------------- -// 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 diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 08835e9dd1..1595cc1598 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -20,8 +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`] | //! | POST | `/_ts/api/v1/batch-sync` | [`handle_batch_sync`] | //! | GET | `/_ts/api/v1/identify` | [`handle_identify`] | //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | @@ -146,8 +144,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::{ @@ -868,12 +865,9 @@ 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::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") @@ -1274,23 +1268,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 // --------------------------------------------------------------------------- @@ -1332,12 +1309,7 @@ fn startup_error_router(e: &Report) -> RouterService { enum NamedRouteHandler { TrustedServerDiscovery, VerifySignature, - RotateKey, - DeactivateKey, 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, @@ -1362,9 +1334,6 @@ struct NamedRoute { /// 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, @@ -1386,16 +1355,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. @@ -1405,22 +1364,6 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: LEGACY_ADMIN_DENY_METHODS, handler: NamedRouteHandler::AdminCachePurge, }, - // 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], @@ -2169,7 +2112,7 @@ mod tests { // (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 req = empty_request(Method::POST, "/_ts/admin/anything"); let response = route(&router, req); @@ -2188,69 +2131,6 @@ mod tests { ); } - #[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 @@ -2328,15 +2208,13 @@ 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]] @@ -2364,21 +2242,26 @@ 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", + "/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" ); } } 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 3c8a9cd7da..dc93e41b16 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 @@ -222,15 +222,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-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 5074e57818..ca33886ec7 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -377,7 +377,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 19] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 15] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -386,11 +386,7 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 19] { (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/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]), @@ -608,20 +604,6 @@ fn cache_purge_not_supported() -> Response { 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 -} - // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -641,23 +623,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 // --------------------------------------------------------------------------- @@ -849,10 +814,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()) }; @@ -1089,8 +1050,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 @@ -1118,17 +1077,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) .post("/auction", auction_handler) .get(PAGE_BIDS_PATH, page_bids_handler.clone()) .route(PAGE_BIDS_PATH, Method::OPTIONS, page_bids_options_handler) @@ -1157,11 +1105,6 @@ fn build_router(state: &Arc) -> RouterService { ); } - 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 diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 4a532227b2..85dc19efed 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -93,32 +93,6 @@ fn edgezero_manifest_loads_and_resolves_spin_stores() { // AuthMiddleware are wired so they 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 an_authenticated_request_to_a_closed_path_is_answered_here() { let ec_id = format!("{}.abc123", "a".repeat(64)); @@ -342,46 +316,18 @@ 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", + "/admin/keys/rotate", + "/admin/keys/deactivate", + ] { for method in ["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] { let router = test_router(); let req = request_builder() @@ -390,18 +336,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" ); } } @@ -744,7 +690,7 @@ async fn admin_route_without_credentials_returns_401() { let router = test_router(); let req = request_builder() .method("POST") - .uri("/_ts/admin/keys/rotate") + .uri("/_ts/admin/anything") .header("content-type", "application/json") .body(edgezero_core::body::Body::from("{}")) .expect("should build request"); @@ -761,7 +707,7 @@ 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") + .uri("/_ts/admin/anything") .header("content-type", "application/json") .body(edgezero_core::body::Body::from("{}")) .expect("should build request"); @@ -794,7 +740,7 @@ async fn admin_route_with_wrong_credentials_returns_401() { let router = test_router(); let req = request_builder() .method("POST") - .uri("/_ts/admin/keys/rotate") + .uri("/_ts/admin/anything") .header("content-type", "application/json") .header("authorization", format!("Basic {creds}")) .body(edgezero_core::body::Body::from("{}")) @@ -937,44 +883,6 @@ async fn auction_endpoint_does_not_require_auth() { ); } -// --------------------------------------------------------------------------- -// 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 // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs index 5ff1f52639..b76f0be819 100644 --- a/crates/trusted-server-core/src/attestation.rs +++ b/crates/trusted-server-core/src/attestation.rs @@ -64,8 +64,6 @@ pub const RESERVED_PATHS: &[&str] = &[ DATA_PAGE_PATH, PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH, - "/_ts/admin/keys/rotate", - "/_ts/admin/keys/deactivate", "/_ts/admin/cache/purge", "/_ts/api/v1/batch-sync", "/_ts/api/v1/identify", diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index 9385dec785..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,12 +211,14 @@ 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() @@ -290,224 +235,6 @@ pub fn kid_is_well_formed(kid: &str) -> bool { validate_kid_format(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 `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)) - } - } -} - #[cfg(test)] mod tests { use bytes::Bytes; @@ -730,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(); @@ -1029,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"); @@ -1126,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(), @@ -1138,65 +548,20 @@ 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); - } - #[test] 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"] { diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 2afbd7d415..415ff6e2c4 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4313,11 +4313,7 @@ impl Settings { /// 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`. - pub(crate) const ADMIN_ENDPOINTS: &[&str] = &[ - "/_ts/admin/keys/rotate", - "/_ts/admin/keys/deactivate", - "/_ts/admin/cache/purge", - ]; + pub(crate) const ADMIN_ENDPOINTS: &[&str] = &["/_ts/admin/cache/purge"]; /// Returns admin endpoint paths that no configured handler covers. /// @@ -9606,11 +9602,7 @@ source_domain = "partner.example.com" .expect("should check admin coverage"); assert_eq!( uncovered, - vec![ - "/_ts/admin/keys/rotate", - "/_ts/admin/keys/deactivate", - "/_ts/admin/cache/purge", - ], + vec!["/_ts/admin/cache/purge"], "should report every admin endpoint as uncovered" ); } @@ -9632,7 +9624,7 @@ source_domain = "partner.example.com" let toml_str = settings_str_without_admin_handler() + r#" [[handlers]] - path = "^/_ts/admin/keys/rotate$" + path = "^/_ts/admin/reports$" username = "admin" password = "secret" "#; @@ -9644,8 +9636,8 @@ source_domain = "partner.example.com" .expect("should check admin coverage"); assert_eq!( uncovered, - vec!["/_ts/admin/keys/deactivate", "/_ts/admin/cache/purge"], - "should detect the admin endpoints not covered by the narrow handler" + vec!["/_ts/admin/cache/purge"], + "should detect the admin endpoint not covered by the narrow handler" ); } diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index ef37686928..a8007c1d20 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -29,8 +29,8 @@ use trusted_server_core::test_support::nextjs_auction; /// `get_settings()` rejects by design, so all routers are built through /// their `routes_with_settings` testing seams from this known-good config. /// 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. +/// `Settings::ADMIN_ENDPOINTS` and the default config, so the admin prefix +/// is auth-gated exactly as in production. fn test_settings() -> Settings { Settings::from_toml( r#" @@ -666,14 +666,12 @@ 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; +async fn admin_prefix_unauthenticated_parity() { + // Every adapter must return 401 for an unauthenticated request beneath + // the `/_ts/admin` prefix that production config auth-gates. + let (axum_status, axum_headers) = axum_post_headers("/_ts/admin/anything", "{}").await; + let (cf_status, cf_headers) = cf_post_headers("/_ts/admin/anything", "{}").await; + let (spin_status, spin_headers) = spin_post_headers("/_ts/admin/anything", "{}").await; assert_eq!( axum_status, 401, @@ -708,7 +706,7 @@ async fn admin_rotate_unauthenticated_parity() { .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" + "WWW-Authenticate header value must match across adapters beneath /_ts/admin" ); assert!( axum_www_auth.starts_with("Basic"), @@ -730,72 +728,10 @@ async fn admin_rotate_unauthenticated_parity() { } #[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() { +async fn spin_key_aliases_are_answered_here_and_never_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. + // `/_ts/admin/keys/*` paths. The aliases outside `/_ts` must therefore be + // answered here with 404 instead of reaching the publisher fallback. for alias in ["/admin/keys/rotate", "/admin/keys/deactivate"] { let (canonical_status, _) = spin_post_headers(&format!("/_ts{alias}"), "{}").await; assert_eq!( @@ -1020,64 +956,18 @@ 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() { +async fn key_aliases_are_answered_here_and_never_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. + // `/_ts/admin/keys/*` paths, so the `/admin/keys/*` aliases are not + // auth-gated. They must be answered here with 404 and never reach 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. + // Canonical paths challenge unauthenticated callers (401). The aliases are + // answered here with 404 and carry no auth challenge. A key handler at + // these ungated paths would not return 404, and the publisher fallback + // would not either, so 404 proves the request was answered here. for alias in ["/admin/keys/rotate", "/admin/keys/deactivate"] { let (canonical_status, _) = cf_post_headers(&format!("/_ts{alias}"), "{}").await; assert_eq!( diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index e4d1559c59..536cd439da 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -38,8 +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/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 | — | — | — | @@ -52,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 | @@ -981,109 +977,11 @@ 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). --- @@ -1095,13 +993,6 @@ Configure a handler that covers the entire `/_ts/admin` namespace, because start What is held against a reader's own Edge Cookie is shown to that reader at [`GET /_ts/data`](#get-ts-data). -### 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. - --- ## Operational and fallback endpoints @@ -1411,13 +1302,14 @@ Basic Authentication password under `admin_password`. **Usage:** ```bash -curl -u 'admin:' https://edge.example.com/_ts/admin/keys/rotate +curl -u 'admin:' -X POST \ + -H 'Content-Type: application/json' -d '{"scope":"all"}' \ + https://edge.example.com/_ts/admin/cache/purge ``` **Protected Endpoints:** -- `/_ts/admin/keys/rotate` -- `/_ts/admin/keys/deactivate` +- `/_ts/admin/cache/purge` - Any paths matching configured `handlers` patterns The EC partner APIs use their own Bearer-token contract. Signed first-party diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 0db1d0dd98..358164eb5b 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -53,13 +53,12 @@ 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` | +| Config/secret-store writes | Return an error (read-only via env vars) | +| 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 788f0b7f94..3437dab861 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -47,9 +47,8 @@ config, local-push output, bodies, headers, and logs. ## Runtime boundaries -Key rotation is registered but returns a not-supported response 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/cloudflare.md b/docs/guide/cloudflare.md index b717e14f02..0c7dc7a1bc 100644 --- a/docs/guide/cloudflare.md +++ b/docs/guide/cloudflare.md @@ -56,10 +56,9 @@ only after all four bindings are present. A successful `ts config push` alone does not configure this runtime: the Worker currently does not open `TRUSTED_SERVER_KV` during startup. Request-time -store registries are also unwired, so EC KV lookup and key rotation are 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. +store registries are also 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/error-reference.md b/docs/guide/error-reference.md index 3deea2080b..932841951f 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -419,12 +419,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. --- @@ -442,13 +438,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 diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index dda6414416..015587a960 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -130,7 +130,7 @@ 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. +> geo lookup, or config/secret-store writes. > See [Architecture](/guide/architecture) for the full list. ### Build the Project diff --git a/docs/guide/key-rotation.md b/docs/guide/key-rotation.md index cde347612b..d7fad7a84d 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 @@ -64,61 +67,22 @@ 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,10 +124,6 @@ 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 @@ -216,9 +170,9 @@ You should see a JWKS response with your public keys. │ ↓ │ │ 3. Store public JWK (Config Store) │ │ ↓ │ -│ 4. Update current-kid pointer │ +│ 4. Update active-kids list │ │ ↓ │ -│ 5. Update active-kids list │ +│ 5. Update current-kid pointer │ │ ↓ │ │ 6. Both keys now active │ │ │ @@ -244,79 +198,40 @@ You should see a JWKS response with your public keys. ## Rotating Keys -### Using the Rotation Endpoint - -**Endpoint**: `POST /_ts/admin/keys/rotate` - -#### Automatic Key ID (Recommended) - -Let Trusted Server generate a date-based key ID: - -```bash -curl -X POST https://your-domain/_ts/admin/keys/rotate \ - -H "Content-Type: application/json" \ - -d '{}' -``` - -**Response**: - -```json -{ - "success": true, - "message": "Key rotated successfully", - "new_kid": "ts-2024-02-15", - "previous_kid": "ts-2024-01-15", - "active_kids": ["ts-2024-01-15", "ts-2024-02-15"], - "jwk": { - "kty": "OKP", - "crv": "Ed25519", - "x": "new-public-key-base64url", - "kid": "ts-2024-02-15", - "alg": "EdDSA" - } -} -``` - -#### Custom Key ID - -Specify a custom key identifier: +Rotate with `ts keys rotate`, with a Fastly API token in `FASTLY_API_TOKEN`: ```bash -curl -X POST https://your-domain/_ts/admin/keys/rotate \ - -H "Content-Type: application/json" \ - -d '{"kid": "production-2024-q1"}' +ts keys rotate --config-store-id --secret-store-id ``` -**Response**: +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. -```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 management store IDs from `[request_signing]`. 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 +241,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 +275,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: - -```bash -curl -X POST https://your-domain/_ts/admin/keys/deactivate \ - -H "Content-Type: application/json" \ - -d '{ - "kid": "ts-2024-01-15", - "delete": false - }' -``` - -**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: +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": true - }' +ts keys deactivate --config-store-id \ + --secret-store-id --kid ts-2024-01-15 --delete ``` -**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 +349,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 +457,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 +499,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/spin.md b/docs/guide/spin.md index fbfa047a14..2299ca3cb2 100644 --- a/docs/guide/spin.md +++ b/docs/guide/spin.md @@ -51,10 +51,10 @@ 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. Forwarded client-IP headers are sanitized, and Spin -derives its runtime authority, scheme, and client-address headers in the -innermost normalization layer. +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. Spin is not installed in the integration CI environment. The recurring manual smoke therefore needs a named owner, tested commit, exact tool versions, and an From f0b4379c2194193f3c4e0bfce7949b446b5c5d51 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 13:13:37 +0100 Subject: [PATCH 05/25] The signing store ids leave the settings `[request_signing]` required `config_store_id` and `secret_store_id`, the platform ids of the two stores the keys live in. Only the removed rotate and deactivate handlers read them, to write those stores. The service finds its keys by the names the stores are linked under, `jwks_store` and `signing_keys`. With the handlers gone, the two ids were settings an operator had to supply with real values for nothing to read. They go, with the placeholder and padding checks on them in startup and deploy validation, and the auction plan's rule that enabled signing needs both. `ts keys` takes the ids on its command line. A configuration that still carries either key is refused with directions, through the refusal the removed tables use, when it is validated and when the service starts. Serializing the settings writes `enabled` alone, so a configuration the service writes is one it reads back. Fixtures whose `[request_signing]` section held only the two ids now say `enabled = false`, which is what an absent `enabled` meant. --- CHANGELOG.md | 1 + .../trusted-server-adapter-fastly/src/app.rs | 6 - .../trusted-server-adapter-fastly/src/lib.rs | 2 - .../src/middleware.rs | 2 - .../src/auction/orchestrator.rs | 6 +- .../trusted-server-core/src/auction/plan.rs | 53 +----- crates/trusted-server-core/src/config.rs | 92 ---------- .../src/inspect/config/tests.rs | 9 +- .../src/integrations/js_asset_proxy.rs | 3 +- crates/trusted-server-core/src/settings.rs | 159 +++++++++++------- .../trusted-server-core/src/test_support.rs | 3 +- .../configs/trusted-server.integration.toml | 2 - docs/guide/configuration.md | 40 +++-- docs/guide/error-reference.md | 4 +- docs/guide/key-rotation.md | 15 +- docs/guide/request-signing.md | 6 +- trusted-server.example.toml | 12 +- 17 files changed, 139 insertions(+), 276 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95686e4038..9e10dec740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- **Breaking:** `[request_signing]` does not take `config_store_id` or `secret_store_id`. Only the removed key rotation routes 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. 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`. diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 1595cc1598..1fc6ddafd2 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1815,8 +1815,6 @@ mod tests { [request_signing] enabled = false - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" [auction] enabled = true @@ -3151,8 +3149,6 @@ mod tests { [request_signing] enabled = false - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" [proxy] @@ -3594,8 +3590,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/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index e485d90f01..027f710a6c 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -1133,8 +1133,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/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 0299c7ef17..b7b6ffd218 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -351,8 +351,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-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 37c28e9374..77a0266749 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/config.rs b/crates/trusted-server-core/src/config.rs index 73e3fcb1ad..1ef726c048 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -453,14 +453,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(()); @@ -1269,90 +1261,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/inspect/config/tests.rs b/crates/trusted-server-core/src/inspect/config/tests.rs index 08b43bf59d..6ce0a66c66 100644 --- a/crates/trusted-server-core/src/inspect/config/tests.rs +++ b/crates/trusted-server-core/src/inspect/config/tests.rs @@ -8,7 +8,7 @@ use crate::integrations::{IntegrationBuilder, ModuleSecretSetting}; use crate::platform::{PlatformError, PlatformSecretStore, 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; /// Every value this store hands back starts with this, so a view can be @@ -359,8 +359,6 @@ fn settings_with_every_sensitive_field() -> (Settings, Vec<(&'static str, &'stat ("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| { @@ -381,11 +379,6 @@ fn settings_with_every_sensitive_field() -> (Settings, Vec<(&'static str, &'stat 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) } 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 2cd5a87592..8d1d377236 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -1082,8 +1082,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/settings.rs b/crates/trusted-server-core/src/settings.rs index 415ff6e2c4..960cce0e51 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1578,37 +1578,31 @@ impl Handler { 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). + /// Request signing switched on or off. #[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. - #[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() + } } } @@ -3547,6 +3541,15 @@ refused_table! { [.]" } +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 \ @@ -4211,22 +4214,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(()); @@ -5745,6 +5732,69 @@ module = \"none\"", } } + #[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()) @@ -7760,26 +7810,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 = ""; @@ -9585,8 +9615,7 @@ source_domain = "partner.example.com" assume_single_jurisdiction = true [request_signing] - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" + enabled = false "# .to_string() } diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 14d0372cd3..cbe2280094 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -40,8 +40,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() } 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 a7136f6dc7..9e67e6df51 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 @@ -37,8 +37,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/docs/guide/configuration.md b/docs/guide/configuration.md index 48451a0760..1f7a955299 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -236,7 +236,7 @@ fail and the service will return its startup-error response. | `[permission-signal]` | several modules | Which permission signals are acted on, in order | | `[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 | @@ -265,8 +265,6 @@ passphrase = "ec_passphrase" [request_signing] enabled = true -config_store_id = "01GXXX" -secret_store_id = "01GYYY" [auction.prebid] client_side_bidders = ["example-browser-bidder"] @@ -683,14 +681,14 @@ with `[]` for every element of a list and `[N]` for one, such as 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 | +| `handlers[].path` and `handlers[].username` | 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 @@ -1033,31 +1031,31 @@ Custom headers may be overwritten by application logic. Standard headers (`Conte ## 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 diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index 932841951f..6420e44bbb 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -499,13 +499,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/key-rotation.md b/docs/guide/key-rotation.md index d7fad7a84d..7a3214890f 100644 --- a/docs/guide/key-rotation.md +++ b/docs/guide/key-rotation.md @@ -61,7 +61,7 @@ The running service only reads these stores, so it needs no Fastly API token of # 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 ``` @@ -128,17 +128,18 @@ For local testing, configure stores in `fastly.toml`: ### 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 = "", ""); // Rotate with automatic kid 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/trusted-server.example.toml b/trusted-server.example.toml index db43e917cc..8d3fe7ef3c 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -208,17 +208,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] From b4a22e9ebe3ce295e69a218754b76ca995fa9369 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 13:14:54 +0100 Subject: [PATCH 06/25] The service holds no token for its own platform's API `management_api.rs` was a client for the Fastly management API. The deployed service read an API token from a secret store named `api-keys` and used it to write its own config store and secret store, which is how the key rotation routes stored a new key. Nothing calls it now that keys are rotated with `ts keys`, so the client goes, with the `api-keys` entries in `fastly.toml` and the Viceroy template. The Fastly platform's store writes, `put`, `delete` and `create`, refuse with a message saying the service does not write its stores. They remain on the traits because `ts keys` implements them over the `fastly` CLI. `BackendConfig::from_url` and its two variants built the backend the client reached the API through. Nothing else called them, so they go as well, and the one test that parsed a URL through them states the parsed parts directly. The adapter's `base64` and `url` dependencies go with their last users. A deployment that linked an `api-keys` secret store can unlink it and revoke the token it held. --- CHANGELOG.md | 1 + Cargo.lock | 2 - .../trusted-server-adapter-fastly/Cargo.toml | 2 - .../src/backend.rs | 104 +---- .../trusted-server-adapter-fastly/src/lib.rs | 1 - .../src/management_api.rs | 416 ------------------ .../src/platform.rs | 55 +-- .../src/request_signing/rotation.rs | 3 +- .../fixtures/configs/viceroy-template.toml | 4 - docs/guide/architecture.md | 11 +- docs/guide/getting-started.md | 4 +- fastly.toml | 4 - 12 files changed, 33 insertions(+), 574 deletions(-) delete mode 100644 crates/trusted-server-adapter-fastly/src/management_api.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e10dec740..4c6cd17e63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- 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 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. 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`. diff --git a/Cargo.lock b/Cargo.lock index a3d0c64152..501c08afca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6607,7 +6607,6 @@ name = "trusted-server-adapter-fastly" version = "0.1.0" dependencies = [ "async-trait", - "base64", "bytes", "chrono", "derive_more", @@ -6633,7 +6632,6 @@ dependencies = [ "trusted-server-permission-signal-mtm", "trusted-server-permission-signal-tcf", "trusted-server-permission-signal-us-privacy", - "url", "urlencoding", ] 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/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 027f710a6c..dbfb5ebb8e 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; 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/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 95828637c0..66a5c3b797 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -24,17 +24,9 @@ use trusted_server_core::settings::TrustedClientIpConfig; /// 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; impl PlatformConfigStore for FastlyPlatformConfigStore { @@ -57,14 +49,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")) } } @@ -74,15 +66,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 { @@ -118,19 +104,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-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-integration-tests/fixtures/configs/viceroy-template.toml b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml index 374e95e426..032ae3c184 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -108,10 +108,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.ts_secrets]] key = "integration_admin_password" data = "integration-admin-password-32-bytes-ok" diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 358164eb5b..f5910a88b0 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -53,12 +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) | -| 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/getting-started.md b/docs/guide/getting-started.md index 015587a960..785f002d93 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -129,8 +129,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, or config/secret-store writes. +> **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/fastly.toml b/fastly.toml index 9486bb82d2..66f26e45e4 100644 --- a/fastly.toml +++ b/fastly.toml @@ -45,10 +45,6 @@ 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 # trusted-server.example.toml. These are public development fixtures. [[local_server.secret_stores.ts_secrets]] From 57fa6dc75c83a9d9b90656f8c2f4f205fb030dd3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 13:23:27 +0100 Subject: [PATCH 07/25] The cache purge route is removed `POST /_ts/admin/cache/purge` let anyone holding one shared password empty the service's cache over the publisher's own domain, with nothing limiting how often. `ts cache purge` asks the hosting platform to purge the same surrogate keys with the operator's own token, so the route goes, with its handler in core, the stand-ins that answered `501` on Axum, Cloudflare and Spin, the harness mode and workflow step that drove it, and the parity tests of it. `auth::authenticated_username`, which named the caller in the purge log, goes too. Nothing in the service purges by surrogate key or purges everything now, so `purge_url_surrogate_key` and `purge_all` leave the template cache interface and every implementation of it. `purge_url`, which core uses to drop an entry it finds invalid, stays. The harness's purge mode was the end to end check that purging a key empties the cache. Two Fastly tests keep that evidence where it can still be had without the route: purging `ts-template` through the platform clears a stored template, and purging one page's reader key clears that page and leaves another. No admin endpoint is left, so the rule that every one must be covered by a `[[handlers]]` entry goes: `ADMIN_ENDPOINTS`, the coverage check at startup and at deploy validation, and the test that held the list against the Fastly route table. A configuration with no handler starts. Handler passwords are still checked. A request to the removed path is still answered `404` by the server and never forwarded, as for any path beneath `/_ts/admin`. --- .github/workflows/test.yml | 3 - CHANGELOG.md | 1 + crates/trusted-server-adapter-axum/src/app.rs | 37 +- .../src/app.rs | 28 +- .../tests/routes.rs | 5 +- .../trusted-server-adapter-fastly/src/app.rs | 84 ---- .../src/template_cache.rs | 56 ++- crates/trusted-server-adapter-spin/src/app.rs | 38 +- .../tests/routes.rs | 5 +- crates/trusted-server-core/src/attestation.rs | 1 - crates/trusted-server-core/src/auth.rs | 13 - crates/trusted-server-core/src/cache_purge.rs | 373 ------------------ crates/trusted-server-core/src/config.rs | 1 - crates/trusted-server-core/src/lib.rs | 1 - .../src/platform/template_cache.rs | 35 -- crates/trusted-server-core/src/publisher.rs | 35 -- crates/trusted-server-core/src/settings.rs | 253 +----------- .../tests/parity.rs | 71 +--- docs/guide/api-reference.md | 9 +- docs/guide/configuration-rules.md | 3 +- docs/guide/configuration.md | 29 +- docs/guide/onboarding.md | 4 +- scripts/template-cache-local-test.sh | 107 +---- trusted-server.example.toml | 16 +- 24 files changed, 99 insertions(+), 1109 deletions(-) delete mode 100644 crates/trusted-server-core/src/cache_purge.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f218c36f8c..e6fa88f1b3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,9 +68,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 4c6cd17e63..a21c22ca3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. With it goes the rule that every admin endpoint must be covered by a `[[handlers]]` entry, since no admin endpoint is left, so a configuration with no handler starts. 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 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. diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 34c0dc49f4..3208e01a9c 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -436,7 +436,6 @@ enum NamedRouteHandler { Permissions, Config, Data, - CachePurgeNotSupported, Auction, PageBids, FirstPartyProxy, @@ -451,17 +450,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; 15] { +fn named_routes() -> [NamedRoute; 14] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -502,14 +491,6 @@ fn named_routes() -> [NamedRoute; 15] { primary_methods: &[Method::GET], handler: NamedRouteHandler::Data, }, - // 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, - }, NamedRoute { path: "/auction", primary_methods: &[Method::POST], @@ -579,22 +560,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::Auction => { // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index db68c3f579..b1c0283fda 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::attestation::{PlatformIdentity, handle_prefix, prefix_routes}; @@ -491,20 +491,6 @@ pub(crate) fn http_error(report: &Report) -> Response { 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 -} - // --------------------------------------------------------------------------- // Startup error fallback // --------------------------------------------------------------------------- @@ -882,18 +868,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(), - ); - } - // The attestation pages answer at the address the settings choose, so // they are registered here rather than with the fixed routes. Reads reach // the handler, and other methods reach the publisher's origin unless the diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index e4a9070ecb..e649305c5c 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -18,9 +18,8 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// The settings baked into the binary contain placeholder secrets that /// `get_settings()` rejects by design, which 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. +/// The handler regex is the production-shaped `^/_ts/admin`, matching the +/// default config, so the admin prefix is auth-gated exactly as in production. fn test_router() -> RouterService { let settings = Settings::from_toml( r#" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 1fc6ddafd2..a5d2d2ea30 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -779,20 +779,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); - } - // Read only. The permissions are resolved for this request as a page's // would be, then shown, and the Edge Cookie lifecycle never runs, so // nothing is written for the reader. @@ -865,9 +851,6 @@ async fn run_named_route( NamedRouteHandler::VerifySignature => { handle_verify_signature(&state.settings, services, req) } - NamedRouteHandler::AdminCachePurge => { - unreachable!("cache purge should be handled before EC setup") - } NamedRouteHandler::BatchSync => { // Dispatched by execute_named before EC state is built. unreachable!("batch-sync should be handled by run_batch_sync") @@ -1309,7 +1292,6 @@ fn startup_error_router(e: &Report) -> RouterService { enum NamedRouteHandler { TrustedServerDiscovery, VerifySignature, - AdminCachePurge, BatchSync, Identify, SetTester, @@ -1332,18 +1314,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. -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", @@ -1355,15 +1325,6 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::POST], handler: NamedRouteHandler::VerifySignature, }, - // 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, - }, NamedRoute { path: "/_ts/api/v1/batch-sync", primary_methods: &[Method::POST], @@ -2129,40 +2090,6 @@ mod tests { ); } - #[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 page_bids_serves_canonical_path_and_deprecated_alias() { // The SPA re-auction endpoint lives at the canonical single-underscore @@ -3339,17 +3266,6 @@ mod tests { .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)] 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/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index ca33886ec7..c261c2eb9b 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -367,17 +367,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]); 15] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 14] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), @@ -386,7 +376,6 @@ fn named_fallback_paths() -> [(&'static str, &'static [Method]); 15] { (CONFIG_PAGE_PATH, &[Method::GET]), (CONFIG_JSON_PATH, &[Method::GET]), (DATA_PAGE_PATH, &[Method::GET]), - ("/_ts/admin/cache/purge", LEGACY_ADMIN_DENY_METHODS), ("/auction", &[Method::POST]), (PAGE_BIDS_PATH, &[Method::GET, Method::OPTIONS]), (PAGE_BIDS_LEGACY_PATH, &[Method::GET, Method::OPTIONS]), @@ -590,20 +579,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 -} - // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -814,9 +789,6 @@ fn build_router(state: &Arc) -> RouterService { } }; - let cache_purge_unsupported_handler = - |_ctx: RequestContext| async { Ok::(cache_purge_not_supported()) }; - // /auction let s = Arc::clone(&state); let auction_handler = move |ctx: RequestContext| { @@ -1097,14 +1069,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, - ); - } - // 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 diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 85dc19efed..5037a3232b 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -17,9 +17,8 @@ use trusted_server_core::settings::Settings; /// The settings baked into the binary contain placeholder secrets that /// `get_settings()` rejects by design, which 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. +/// The handler regex is the production-shaped `^/_ts/admin`, matching the +/// default config, so the admin prefix is auth-gated exactly as in production. fn test_router() -> RouterService { let settings = Settings::from_toml( r#" diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs index b76f0be819..2a40da864f 100644 --- a/crates/trusted-server-core/src/attestation.rs +++ b/crates/trusted-server-core/src/attestation.rs @@ -64,7 +64,6 @@ pub const RESERVED_PATHS: &[&str] = &[ DATA_PAGE_PATH, PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH, - "/_ts/admin/cache/purge", "/_ts/api/v1/batch-sync", "/_ts/api/v1/identify", "/_ts/api/v1/ec/resolve", diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index e9b33b914e..f8e121941a 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -124,19 +124,6 @@ pub fn enforce_basic_auth( } } -/// 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()?; 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/config.rs b/crates/trusted-server-core/src/config.rs index 1ef726c048..457e517962 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -360,7 +360,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 diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index ca0c3a8f61..8386b2650e 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -36,7 +36,6 @@ pub mod auction_config_types; pub mod auth; pub mod build_info; pub mod cache_policy; -pub mod cache_purge; pub mod closed_paths; pub mod config; pub mod config_payload; diff --git a/crates/trusted-server-core/src/platform/template_cache.rs b/crates/trusted-server-core/src/platform/template_cache.rs index efa3f170f3..488b2f26bc 100644 --- a/crates/trusted-server-core/src/platform/template_cache.rs +++ b/crates/trusted-server-core/src/platform/template_cache.rs @@ -815,17 +815,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. @@ -870,17 +859,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)] @@ -1193,19 +1171,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 6a9a68c233..149f7e1938 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -10365,17 +10365,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 { @@ -10520,10 +10509,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 { @@ -10708,26 +10693,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. diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 960cce0e51..191537cb89 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4061,7 +4061,6 @@ impl Settings { settings .geo .validate_jurisdiction_acknowledgment(&settings.ec)?; - settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; // Log the policy's declared default once per settings load, so an @@ -4293,71 +4292,10 @@ impl Settings { 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`. - pub(crate) const ADMIN_ENDPOINTS: &[&str] = &["/_ts/admin/cache/purge"]; - - /// 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 = false; - for handler in &self.handlers { - if handler.matches_path(path)? { - covered = true; - break; - } - } - 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. + /// Applies to every handler. 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> { @@ -7870,7 +7808,7 @@ source_domain = "partner.example.com" ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR ); - // Admin handler at index 1 (required for admin endpoint coverage) + // The fixture's second handler, at index 1 let path_key_1 = format!( "{}{}HANDLERS{}1{}PATH", ENVIRONMENT_VARIABLE_PREFIX, @@ -9562,8 +9500,6 @@ source_domain = "partner.example.com" ); } - // --- admin endpoint coverage --- - #[test] fn test_publisher_rejects_cookie_domain_with_metacharacters() { for bad_domain in [ @@ -9590,92 +9526,11 @@ 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] - enabled = false - "# - .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/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/reports$" - 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/cache/purge"], - "should detect the admin endpoint not covered by the narrow handler" - ); - } - #[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 paths it matches, and none of - // them need be a listed admin endpoint. The placeholder check therefore - // cannot be limited to handlers inferred to cover one. + // ahead of a broader one governs the paths it matches, and its password + // is checked like any other. let toml_str = crate_test_settings_str().replace( r#"path = "^/_ts/admin" username = "admin" @@ -9724,32 +9579,6 @@ source_domain = "partner.example.com" ); } - #[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() @@ -9775,24 +9604,22 @@ source_domain = "partner.example.com" } #[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}" - ); + fn from_toml_accepts_a_configuration_with_no_handlers() { + let fixture = crate_test_settings_str(); + let start = fixture + .find("[[handlers]]") + .expect("the fixture should open with its handlers"); + let end = fixture + .find("[publisher]") + .expect("the fixture should have a publisher after them"); + let without_handlers = format!("{}{}", &fixture[..start], &fixture[end..]); + + let settings = Settings::from_toml(&without_handlers) + .expect("should load a configuration that gates no path"); + + assert!(settings.handlers.is_empty(), "should hold no handler"); } - /// 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#" @@ -10116,48 +9943,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-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index a8007c1d20..0703e6f359 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -28,9 +28,8 @@ use trusted_server_core::test_support::nextjs_auction; /// The settings baked into the binaries contain placeholder secrets that /// `get_settings()` rejects by design, so all routers are built through /// their `routes_with_settings` testing seams from this known-good config. -/// The handler regex is the production-shaped `^/_ts/admin`, matching -/// `Settings::ADMIN_ENDPOINTS` and the default config, so the admin prefix -/// is auth-gated exactly as in production. +/// The handler regex is the production-shaped `^/_ts/admin`, matching the +/// default config, so the admin prefix is auth-gated exactly as in production. fn test_settings() -> Settings { Settings::from_toml( r#" @@ -1019,72 +1018,6 @@ async fn key_aliases_are_answered_here_and_never_proxied() { } } -#[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" - ); - } - } -} - /// A known non-regulated location permits the fixture's server-side auction. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn adapter_buffers_nextjs_auction_output() { diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 536cd439da..446b18bff5 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -989,7 +989,7 @@ core against the signing stores. See [Key Rotation](./key-rotation.md). The whole `/_ts/admin` prefix is closed to the publisher fallback. A request beneath it that no route claims, whether unknown, malformed or percent-encoded (`/_ts/admin%2Fec`), is answered locally with `404` and `Cache-Control: no-store` 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 closed the same way. -Configure a handler that covers the entire `/_ts/admin` namespace, because startup rejects configurations that do not protect every admin route. Missing or invalid credentials receive the shared plaintext `401 Unauthorized` Basic-auth challenge. +No route is served beneath the prefix. Where a handler gates it, missing or invalid credentials receive the shared plaintext `401 Unauthorized` Basic-auth challenge before the path is looked at. What is held against a reader's own Edge Cookie is shown to that reader at [`GET /_ts/data`](#get-ts-data). @@ -1291,7 +1291,7 @@ Endpoints under protected paths require HTTP Basic Authentication: ```toml [[handlers]] -path = "^/_ts/admin" +path = "^/secure" username = "admin" password = "admin_password" ``` @@ -1302,14 +1302,11 @@ Basic Authentication password under `admin_password`. **Usage:** ```bash -curl -u 'admin:' -X POST \ - -H 'Content-Type: application/json' -d '{"scope":"all"}' \ - https://edge.example.com/_ts/admin/cache/purge +curl -u 'admin:' https://edge.example.com/secure/report ``` **Protected Endpoints:** -- `/_ts/admin/cache/purge` - Any paths matching configured `handlers` patterns The EC partner APIs use their own Bearer-token contract. Signed first-party diff --git a/docs/guide/configuration-rules.md b/docs/guide/configuration-rules.md index 3782859d85..89084661cb 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 diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 1f7a955299..1683a158fe 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1170,20 +1170,17 @@ 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. Use a prefix-level -matcher such as `^/_ts/admin`. +::: warning Passwords are validated at startup 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. +Startup fails when any handler 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 a broader pattern +governs the paths it matches. ::: @@ -2849,11 +2846,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. @@ -2874,12 +2866,11 @@ Rollback must preserve configuration compatibility: 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. The command asks Fastly to purge, through the `fastly` CLI with the - token in `FASTLY_API_TOKEN`. 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. + 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 diff --git a/docs/guide/onboarding.md b/docs/guide/onboarding.md index f676592acb..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 diff --git a/scripts/template-cache-local-test.sh b/scripts/template-cache-local-test.sh index 4a2e04f826..99561a8a6b 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 @@ -570,7 +562,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" @@ -722,7 +714,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]+\)" \ @@ -812,7 +804,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" @@ -827,7 +819,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. @@ -835,91 +827,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 8d3fe7ef3c..d7a57b0a29 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -6,9 +6,8 @@ # `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: +# 1. [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 @@ -44,14 +43,13 @@ # ----------------------------------------------------------------------------- -# REQUIRED - Admin authentication +# OPTIONAL - Password-protected paths # ----------------------------------------------------------------------------- -# 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). +# HTTP Basic-auth handler(s). Each handler gates the paths its `path` regex +# matches, and 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. +# Regex matched against the request path. path = "^/_ts/admin" username = "admin" password = "handler_password" From 37207625e6805ea7e5c0929fda36ba629b60b340 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 13:45:26 +0100 Subject: [PATCH 08/25] No request is asked for a password HTTP Basic authentication existed to gate the endpoints beneath `/_ts/admin`. None is left, so the gate goes: `auth.rs` in core, the `AuthMiddleware` each of the four adapters registered, the `Handler` type with its regex and its placeholder and weak password checks, and `handlers[*].password` as a declared secret. A configuration that still carries `[[handlers]]` is refused with directions when it is validated and when the service starts, through the refusal the removed tables use. Loading it and ignoring the list would leave an operator who gated a path of their own, a staging site for instance, believing it was still gated. An empty list is refused too, so is a handler given by an environment variable, and the settings are never written with one. The upgrade has an order to it. A build before this series refuses a configuration without a handler covering `/_ts/admin`, and this build refuses one with any handler, so no one configuration starts both. The new configuration and the new build go live together. The publisher path treats an `Authorization` header as bound for the origin in every case. It shared a cached page across readers when the edge had checked the header itself, and with no check at the edge that case is gone, so a request carrying the header always bypasses the shared template. The closed path check is unchanged and still answers `/_ts/admin` and `/admin/keys` with `404` before the publisher fallback, so a caller that sends a password to either never has it forwarded. Each adapter's tests of that send the credentials a caller would. What goes with the gate: - the tests of the challenge in the four adapters and the parity suite, and the tests that proved other routes were not challenged; - `[[handlers]]` in every test fixture, 52 entries; - the `handler_password` secret in `fastly.toml`, the Spin manifest, the Viceroy template, the integration fixtures, the three harnesses and the four smoke scripts, whose missing-secret cases cover the two secrets that remain; - the handlers section of the configuration guide, the Basic Authentication section of the API reference, the `Handler.password` row of the configuration guide's table of exceptional fields, and "unless an operator adds a handler" in the contracts of `/first-party/sign` and `/_ts/debug/ja4`. The configuration page's tests take their example list from `proxy.asset_routes`, having taken it from `handlers`. The page-bids alias comments named a `[[handlers]]` pattern covering `/_ts` as one reason a browser falls back to the alias. They name a gate in front of the service, which is the case that remains. --- CHANGELOG.md | 8 +- crates/auction/prebid-server/src/lib.rs | 5 - crates/auction/prebid/src/lib.rs | 5 - crates/tag/google-tag-manager/src/lib.rs | 10 - crates/testing/seam-probe/src/lib.rs | 10 - crates/trusted-server-adapter-axum/src/app.rs | 10 +- .../src/middleware.rs | 49 +- .../tests/routes.rs | 158 +---- .../tests/seam_probe.rs | 10 - .../src/app.rs | 23 +- .../src/middleware.rs | 49 +- .../tests/routes.rs | 166 +---- .../wrangler.ci.toml | 1 - .../trusted-server-adapter-fastly/src/app.rs | 408 +----------- .../src/app/seam_probe_tests.rs | 5 - .../trusted-server-adapter-fastly/src/lib.rs | 5 - .../src/middleware.rs | 90 +-- .../src/sandbox.rs | 5 - crates/trusted-server-adapter-spin/spin.toml | 5 +- crates/trusted-server-adapter-spin/src/app.rs | 16 +- .../src/middleware.rs | 52 +- .../tests/routes.rs | 147 +---- .../src/commands/audit/generate/mod.rs | 14 +- crates/trusted-server-core/src/auth.rs | 594 ------------------ .../trusted-server-core/src/closed_paths.rs | 32 +- crates/trusted-server-core/src/config.rs | 23 - .../trusted-server-core/src/config_payload.rs | 13 +- .../trusted-server-core/src/html_processor.rs | 5 - .../trusted-server-core/src/inspect/config.rs | 8 +- .../src/inspect/config/tests.rs | 79 +-- .../src/integrations/js_asset_proxy.rs | 10 - crates/trusted-server-core/src/lib.rs | 2 - crates/trusted-server-core/src/publisher.rs | 185 +----- .../src/response_privacy.rs | 5 - crates/trusted-server-core/src/settings.rs | 506 +++------------ .../trusted-server-core/src/test_support.rs | 19 - .../configs/trusted-server.integration.toml | 5 - .../fixtures/configs/viceroy-template.toml | 3 - .../src/bin/generate-viceroy-config.rs | 4 - .../tests/common/config.rs | 6 +- .../tests/environments/axum.rs | 4 - .../tests/parity.rs | 187 +----- .../lib/src/integrations/gpt/index.ts | 10 +- .../test/integrations/gpt/spa_hook.test.ts | 6 +- docs/guide/api-reference.md | 65 +- docs/guide/axum-dev.md | 1 - docs/guide/cloudflare.md | 11 +- docs/guide/configuration-rules.md | 25 +- docs/guide/configuration.md | 161 +---- docs/guide/fastly.md | 6 +- docs/guide/getting-started.md | 3 +- docs/guide/spin.md | 1 - fastly.toml | 6 +- scripts/attestation-local-test.sh | 4 - scripts/config-by-host-local-test.sh | 4 - scripts/smoke-axum.sh | 28 +- scripts/smoke-cloudflare.sh | 15 +- scripts/smoke-common.sh | 2 - scripts/smoke-fastly.sh | 13 +- scripts/smoke-spin.sh | 12 +- scripts/template-cache-local-test.sh | 4 - trusted-server.example.toml | 27 +- 62 files changed, 370 insertions(+), 2975 deletions(-) delete mode 100644 crates/trusted-server-core/src/auth.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a21c22ca3b..ed9c739c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,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:** 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. @@ -48,13 +47,14 @@ 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:** `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. With it goes the rule that every admin endpoint must be covered by a `[[handlers]]` entry, since no admin endpoint is left, so a configuration with no handler starts. A request to the removed path is answered `404` by the server itself and is never forwarded to the origin. +- **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 deploy reads a configuration key of its own (`ts deploy --staging`, `ts config push --staging`), which 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 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. @@ -63,7 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security - `/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 diff --git a/crates/auction/prebid-server/src/lib.rs b/crates/auction/prebid-server/src/lib.rs index fa89ef493b..35fd0b3e6d 100644 --- a/crates/auction/prebid-server/src/lib.rs +++ b/crates/auction/prebid-server/src/lib.rs @@ -3038,11 +3038,6 @@ mod tests { [auction] modules = ["prebid"] -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" diff --git a/crates/auction/prebid/src/lib.rs b/crates/auction/prebid/src/lib.rs index a44b995f73..ad2bcbe0ed 100644 --- a/crates/auction/prebid/src/lib.rs +++ b/crates/auction/prebid/src/lib.rs @@ -1724,11 +1724,6 @@ mod tests { [auction] modules = ["prebid"] -[[handlers]] -path = "^/_ts/admin" -username = "admin" -password = "admin-pass" - [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" diff --git a/crates/tag/google-tag-manager/src/lib.rs b/crates/tag/google-tag-manager/src/lib.rs index 67da461a2a..699ea54073 100644 --- a/crates/tag/google-tag-manager/src/lib.rs +++ b/crates/tag/google-tag-manager/src/lib.rs @@ -3052,11 +3052,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" @@ -3092,11 +3087,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 d00258f05d..5f8365bd6f 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -816,11 +816,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" @@ -1055,11 +1050,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 3208e01a9c..daca62627e 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -46,7 +46,7 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::RuntimeServices; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; +use crate::middleware::{FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_runtime_services}; // --------------------------------------------------------------------------- @@ -778,8 +778,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::( @@ -847,11 +846,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-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 6516d6f9d4..9690a7d04d 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; @@ -56,9 +55,8 @@ impl Middleware for SanitizeRequestMiddleware { /// is always emitted. Fastly-specific headers (`X-TS-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, } @@ -80,44 +78,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 // --------------------------------------------------------------------------- @@ -181,11 +141,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 ea0678893f..1dd9f5053d 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -21,11 +21,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" @@ -307,32 +302,9 @@ 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/anything") - .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 an_authenticated_request_to_a_closed_path_is_answered_here() { let ec_id = format!("{}.abc123", "a".repeat(64)); @@ -347,14 +319,13 @@ async fn an_authenticated_request_to_a_closed_path_is_answered_here() { "/_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(), @@ -429,91 +400,6 @@ async fn key_administration_paths_are_answered_here_and_never_proxied() { } } -#[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/anything") - .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/anything") - .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 [ @@ -589,8 +475,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" ); } } @@ -636,29 +522,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" - ); -} - // --------------------------------------------------------------------------- // First-party route smoke tests // --------------------------------------------------------------------------- @@ -783,11 +646,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 efbbe7bf41..a19a9b23b0 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" @@ -638,11 +633,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 b1c0283fda..6f5cf17981 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -47,7 +47,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; // --------------------------------------------------------------------------- @@ -742,7 +742,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 { @@ -912,11 +911,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" @@ -973,11 +967,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" @@ -1075,11 +1064,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" @@ -1105,11 +1089,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 4527fd4ab4..64c086a5ef 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 // --------------------------------------------------------------------------- @@ -197,11 +157,6 @@ mod tests { // by design. 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 e649305c5c..8f803609dd 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -18,16 +18,9 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// The settings baked into the binary contain placeholder secrets that /// `get_settings()` rejects by design, which 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 the -/// default config, so the admin prefix is 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" @@ -78,11 +71,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" @@ -113,8 +101,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)] @@ -137,33 +125,6 @@ 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 path (/_ts/admin/anything). Only AuthMiddleware - // short-circuits with this response — FinalizeResponseMiddleware alone would not. - let router = test_router(); - - let req = request_builder() - .method("POST") - .uri("/_ts/admin/anything") - .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 key_administration_paths_are_answered_here_and_never_proxied() { // A request to a key administration path carries an operator's @@ -296,7 +257,7 @@ fn all_explicit_routes_are_registered() { } // --------------------------------------------------------------------------- -// Basic-auth parity tests +// Closed paths and the inspection endpoints // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -313,14 +274,13 @@ async fn an_authenticated_request_to_a_closed_path_is_answered_here() { "/_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(), @@ -351,90 +311,6 @@ async fn an_authenticated_request_to_a_closed_path_is_answered_here() { } } -#[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/anything") - .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/anything") - .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/anything") - .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 [ @@ -494,8 +370,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" ); } } @@ -532,23 +408,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" - ); -} - #[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 @@ -579,11 +438,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" diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml index 9992db7123..c938045b8b 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -18,7 +18,6 @@ TRUSTED_SERVER_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/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index a5d2d2ea30..cc412dce00 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. //! @@ -65,11 +65,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 @@ -153,7 +148,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, UnavailableKvStore, @@ -701,8 +696,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. @@ -1511,13 +1506,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)); @@ -1604,17 +1597,12 @@ 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, EcContext, EdgeCacheHeader, EidSyncSource, - HandlerFuture, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, - RuntimeStoreConfig, TrustedServerApp, build_orchestrator_with_plan, - build_per_request_services, build_state_from_settings, handle_publisher_request, - publisher_response_into_streaming_response, startup_error_router, + AppState, EidSyncSource, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, + PAGE_BIDS_PATH, RuntimeStoreConfig, TrustedServerApp, build_per_request_services, + build_state_from_settings, startup_error_router, }; use bytes::Bytes; use edgezero_core::app::Hooks as _; @@ -1642,9 +1630,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; @@ -1746,16 +1732,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" @@ -2057,39 +2033,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/anything"); - - 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 page_bids_serves_canonical_path_and_deprecated_alias() { // The SPA re-auction endpoint lives at the canonical single-underscore @@ -2142,11 +2085,6 @@ mod tests { // 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" @@ -2207,14 +2145,13 @@ 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(), @@ -3054,11 +2991,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" @@ -3178,303 +3110,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(()) - } - } - - #[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()) - .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 @@ -3484,11 +3119,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" 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 dc93e41b16..4ac37fcdab 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" diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index dbfb5ebb8e..ff6e74fd8d 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -1110,11 +1110,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" diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index b7b6ffd218..3c1f8bd663 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_VERSION, @@ -35,9 +29,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 @@ -117,51 +110,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 // --------------------------------------------------------------------------- @@ -329,11 +277,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" @@ -819,10 +762,6 @@ mod tests { ); } - // --------------------------------------------------------------------------- - // AuthMiddleware::handle tests - // --------------------------------------------------------------------------- - #[test] fn finalize_handle_preserves_duplicate_set_cookie_headers() { // Regression guard: FinalizeResponseMiddleware must not drop duplicate @@ -853,23 +792,4 @@ mod tests { "FinalizeResponseMiddleware must not drop duplicate Set-Cookie headers" ); } - - #[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" - ); - } } diff --git a/crates/trusted-server-adapter-fastly/src/sandbox.rs b/crates/trusted-server-adapter-fastly/src/sandbox.rs index 9233d97b89..eda5eb4380 100644 --- a/crates/trusted-server-adapter-fastly/src/sandbox.rs +++ b/crates/trusted-server-adapter-fastly/src/sandbox.rs @@ -622,11 +622,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-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 c261c2eb9b..e69f37a1eb 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -53,7 +53,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"))] @@ -1030,12 +1030,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. @@ -1127,11 +1125,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" @@ -1257,11 +1250,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 38bb48cf21..9b73c4f227 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; @@ -224,11 +181,6 @@ mod tests { // by design. 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 5037a3232b..b6c5378c05 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -17,16 +17,9 @@ use trusted_server_core::settings::Settings; /// The settings baked into the binary contain placeholder secrets that /// `get_settings()` rejects by design, which 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 the -/// default config, so the admin prefix is 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" @@ -88,8 +81,8 @@ 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)] @@ -106,14 +99,13 @@ async fn an_authenticated_request_to_a_closed_path_is_answered_here() { "/_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(), @@ -189,14 +181,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() @@ -210,7 +197,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(), @@ -681,77 +668,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/anything") - .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/anything") - .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/anything") - .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 [ @@ -811,8 +730,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" ); } } @@ -849,39 +768,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" - ); -} - // --------------------------------------------------------------------------- // Edge Cookie module availability // --------------------------------------------------------------------------- @@ -890,11 +776,6 @@ async fn auction_endpoint_does_not_require_auth() { /// 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 858ca05eeb..5832b3bd8b 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\"", @@ -3943,10 +3939,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\"", @@ -4389,10 +4381,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\"", @@ -4466,7 +4454,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-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs deleted file mode 100644 index f8e121941a..0000000000 --- a/crates/trusted-server-core/src/auth.rs +++ /dev/null @@ -1,594 +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())) - } -} - -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 `closed_paths::closed_path_response`). - 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/closed_paths.rs b/crates/trusted-server-core/src/closed_paths.rs index 1f78cc310e..2c935ba533 100644 --- a/crates/trusted-server-core/src/closed_paths.rs +++ b/crates/trusted-server-core/src/closed_paths.rs @@ -1,10 +1,11 @@ //! Paths this server answers itself and never forwards to the publisher's origin. //! -//! A request to the server's administration prefix, `/_ts/admin`, or to the -//! `/admin/keys` addresses beside it, can carry an operator's `Authorization` -//! header and an administration payload. Forwarding one would hand both to the -//! origin, so every adapter asks [`closed_path_response`] before its publisher -//! fallback, and a request that no route claims is answered `404` here. +//! 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; @@ -13,18 +14,16 @@ use http::{Request, Response, StatusCode, header}; /// The server's administration prefix. /// -/// Mirrors the documented `^/_ts/admin` basic-auth handler regex, so every -/// path that handler authenticates is also closed at the fallback boundary. -/// It is matched as a bare prefix, with no `/` after it, which also covers a -/// percent-encoded separator such as `/_ts/admin%2Fec`. The auth handler -/// matches that spelling and a check for a literal slash would not. +/// 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`. /// -/// No auth handler matches it, so only the alias itself and its separator -/// descendants are closed. A bare prefix here would also close unrelated -/// publisher paths such as `/admin/keystore`. +/// 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. @@ -174,10 +173,9 @@ mod tests { #[test] fn an_encoded_separator_does_not_open_a_closed_path() { - // `/_ts/admin%2Fec` matches the documented `^/_ts/admin` basic-auth - // handler, so it is authenticated, and a check for a literal slash - // would miss it. Reaching the publisher fallback would forward the - // caller's `Authorization` header and body to the origin. + // 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", diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 457e517962..b4741cf148 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -262,14 +262,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"), @@ -407,7 +399,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(|_| ())?; @@ -498,13 +489,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", @@ -788,8 +772,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"); @@ -952,7 +934,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), @@ -1224,10 +1205,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"); diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index d3ad281cf1..0fc7a317da 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -460,8 +460,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" ); } @@ -980,7 +979,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"); @@ -996,9 +997,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/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index b7e5d6ff6d..8442971433 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -967,11 +967,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 4753ae672e..1b2333fd68 100644 --- a/crates/trusted-server-core/src/inspect/config.rs +++ b/crates/trusted-server-core/src/inspect/config.rs @@ -50,7 +50,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 `[inspect]` section, which says what the configuration page shows. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -98,7 +99,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(); @@ -127,7 +129,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 6ce0a66c66..33c8f5a1ec 100644 --- a/crates/trusted-server-core/src/inspect/config/tests.rs +++ b/crates/trusted-server-core/src/inspect/config/tests.rs @@ -6,7 +6,6 @@ use super::*; use crate::config_payload::settings_from_config_blob_with; use crate::integrations::{IntegrationBuilder, ModuleSecretSetting}; use crate::platform::{PlatformError, PlatformSecretStore, StoreId, StoreName}; -use crate::redacted::Redacted; use crate::secret_resolution::ResolvedSecrets; use crate::settings::ProxyAssetRoute; use crate::test_support::tests::crate_test_settings_str; @@ -106,7 +105,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; @@ -143,7 +142,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()), @@ -152,22 +151,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] @@ -217,8 +209,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", @@ -356,8 +346,6 @@ 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"), ("auction.creative_store", "creative-store-canary"), ]; @@ -376,8 +364,6 @@ 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.auction.creative_store = value("auction.creative_store"); (settings, expected) @@ -397,7 +383,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 ); @@ -419,7 +405,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] @@ -581,11 +570,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); @@ -595,10 +603,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")); } @@ -623,16 +631,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"]"#, @@ -655,9 +663,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\"]", @@ -671,7 +680,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 8d1d377236..2872a114e6 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -1059,16 +1059,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" diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 8386b2650e..75e55e8e41 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 @@ -33,7 +32,6 @@ pub(crate) mod asset_image_optimizer; pub mod attestation; pub mod auction; pub mod auction_config_types; -pub mod auth; pub mod build_info; pub mod cache_policy; pub mod closed_paths; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 149f7e1938..c9ac107eff 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4499,7 +4499,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. @@ -4812,18 +4812,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 @@ -6376,9 +6368,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 @@ -7060,10 +7051,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 @@ -7205,8 +7194,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. @@ -7220,8 +7209,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!( @@ -12256,37 +12245,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")); @@ -12298,15 +12257,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!( @@ -12315,115 +12265,12 @@ mod tests { .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" + "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, - "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!( - response - .headers() - .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" - ); - 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/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 3859849129..8a767dfabd 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -222,11 +222,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 191537cb89..cae6358fab 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1506,73 +1506,6 @@ 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 { @@ -2574,14 +2507,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 { @@ -3541,6 +3466,16 @@ 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 \ @@ -3848,9 +3783,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, @@ -4061,7 +4001,6 @@ impl Settings { settings .geo .validate_jurisdiction_acknowledgment(&settings.ec)?; - settings.validate_admin_handler_passwords()?; // Log the policy's declared default once per settings load, so an // operator can see which permissions an unmatched request is granted @@ -4100,10 +4039,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 @@ -4199,11 +4134,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()); } @@ -4267,52 +4197,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/") - } - - /// Rejects placeholder and well-known weak handler passwords. - /// - /// Applies to every handler. 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 { @@ -4705,14 +4589,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>, @@ -5126,7 +5002,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. @@ -5134,8 +5010,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 = @@ -5152,13 +5026,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(), @@ -5224,15 +5091,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", @@ -5670,6 +5535,77 @@ 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"] { @@ -7422,18 +7358,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"); @@ -7641,36 +7565,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) } @@ -7776,83 +7670,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 - ); - // The fixture's second handler, at index 1 - 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(); @@ -7997,8 +7814,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!( @@ -8019,9 +7838,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:?}" + ); }); }, ); @@ -8261,11 +8085,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" @@ -8297,11 +8116,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" @@ -9526,108 +9340,9 @@ source_domain = "partner.example.com" ); } - #[test] - fn from_toml_rejects_placeholder_password_on_shadowing_admin_handler() { - // Handler selection is first-match-wins, so a narrow handler placed - // ahead of a broader one governs the paths it matches, and its password - // is checked like any other. - let toml_str = crate_test_settings_str().replace( - r#"path = "^/_ts/admin" - username = "admin" - password = "admin-pass""#, - r#"path = "^/_ts/admin/reports/[a-z0-9]{6}$" - 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_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_accepts_a_configuration_with_no_handlers() { - let fixture = crate_test_settings_str(); - let start = fixture - .find("[[handlers]]") - .expect("the fixture should open with its handlers"); - let end = fixture - .find("[publisher]") - .expect("the fixture should have a publisher after them"); - let without_handlers = format!("{}{}", &fixture[..start], &fixture[end..]); - - let settings = Settings::from_toml(&without_handlers) - .expect("should load a configuration that gates no path"); - - assert!(settings.handlers.is_empty(), "should hold no handler"); - } - #[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" @@ -9713,11 +9428,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" @@ -9755,11 +9465,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" @@ -9803,11 +9508,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" diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index cbe2280094..74546c6c12 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -7,16 +7,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" @@ -176,10 +166,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\"", @@ -259,11 +245,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" 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 9e67e6df51..e49c170a2a 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" 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 032ae3c184..8967f9bfdc 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -108,9 +108,6 @@ key = "ts-2025-10-A" data = "NVnTYrw5xoyTJDOwoUWoPJO3A6UCCXOJJUzgGTxxx7k=" - [[local_server.secret_stores.ts_secrets]] - key = "integration_admin_password" - data = "integration-admin-password-32-bytes-ok" [[local_server.secret_stores.ts_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 4388a47891..f5bb7e80f5 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 @@ -207,10 +207,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 171d8f0ab9..1a69cf3018 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -110,11 +110,7 @@ mod tests { .as_array() .expect("fastly.toml should define ts_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 6e1388cea3..0afdfbacc3 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -14,10 +14,6 @@ const AXUM_DEFAULT_PORT: u16 = 8787; /// 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 0703e6f359..65b8f9fabd 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. @@ -28,16 +28,9 @@ use trusted_server_core::test_support::nextjs_auction; /// The settings baked into the binaries contain placeholder secrets that /// `get_settings()` rejects by design, so all routers are built through /// their `routes_with_settings` testing seams from this known-good config. -/// The handler regex is the production-shaped `^/_ts/admin`, matching the -/// default config, so the admin prefix is 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" @@ -664,95 +657,6 @@ async fn verify_signature_route_parity() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn admin_prefix_unauthenticated_parity() { - // Every adapter must return 401 for an unauthenticated request beneath - // the `/_ts/admin` prefix that production config auth-gates. - let (axum_status, axum_headers) = axum_post_headers("/_ts/admin/anything", "{}").await; - let (cf_status, cf_headers) = cf_post_headers("/_ts/admin/anything", "{}").await; - let (spin_status, spin_headers) = spin_post_headers("/_ts/admin/anything", "{}").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 beneath /_ts/admin" - ); - 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 spin_key_aliases_are_answered_here_and_never_proxied() { - // The production handler regex `^/_ts/admin` only matches the canonical - // `/_ts/admin/keys/*` paths. The aliases outside `/_ts` must therefore be - // answered here with 404 instead of reaching 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)] = &[ @@ -812,14 +716,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, @@ -890,7 +791,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)" @@ -955,65 +855,40 @@ async fn unknown_route_returns_same_status_parity() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn key_aliases_are_answered_here_and_never_proxied() { - // The production handler regex `^/_ts/admin` only matches the canonical - // `/_ts/admin/keys/*` paths, so the `/admin/keys/*` aliases are not - // auth-gated. They must be answered here with 404 and never reach the - // publisher fallback, which forwards the request (including any - // `Authorization` header and key-management body) to the origin and leaks - // admin credentials. - // - // Canonical paths challenge unauthenticated callers (401). The aliases are - // answered here with 404 and carry no auth challenge. A key handler at - // these ungated paths would not return 404, and the publisher fallback +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 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" - ); - + 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" - ); + 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 446b18bff5..fd31cd51e5 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -78,9 +78,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. @@ -117,15 +116,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", @@ -759,7 +751,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 @@ -987,9 +979,9 @@ core against the signing stores. See [Key Rotation](./key-rotation.md). ## The Administration Prefix -The whole `/_ts/admin` prefix is closed to the publisher fallback. A request beneath it that no route claims, whether unknown, malformed or percent-encoded (`/_ts/admin%2Fec`), is answered locally with `404` and `Cache-Control: no-store` 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 closed the same way. +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. -No route is served beneath the prefix. Where a handler gates it, missing or invalid credentials receive the shared plaintext `401 Unauthorized` Basic-auth challenge before the path is looked at. +No request is asked for a password. What is held against a reader's own Edge Cookie is shown to that reader at [`GET /_ts/data`](#get-ts-data). @@ -1011,8 +1003,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 @@ -1167,8 +1159,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 | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | @@ -1283,36 +1275,15 @@ No EC ID response header is emitted. EC identity is maintained with the `ts-ec` ## Authentication and policy boundaries -### Basic Authentication +### Authentication -Endpoints under protected paths require HTTP Basic Authentication: - -**Configuration:** - -```toml -[[handlers]] -path = "^/secure" -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/secure/report -``` - -**Protected Endpoints:** - -- Any paths matching configured `handlers` patterns - -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/axum-dev.md b/docs/guide/axum-dev.md index 3437dab861..55a241e441 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` diff --git a/docs/guide/cloudflare.md b/docs/guide/cloudflare.md index 0c7dc7a1bc..d9ca66a543 100644 --- a/docs/guide/cloudflare.md +++ b/docs/guide/cloudflare.md @@ -32,8 +32,8 @@ value of `app_config` in a JSON object; that outer JSON is assigned to the `TRUSTED_SERVER_CONFIG` Worker variable. This double encoding is required by the current startup reader. -The generated local Wrangler files define `handler_password`, -`publisher_proxy_secret`, and `ec_passphrase` separately. The smoke launches +The generated local Wrangler files define `publisher_proxy_secret` and +`ec_passphrase` separately. The smoke launches `wrangler dev` once without the config binding and once for each omitted secret. Each case requires a 500 response and verifies the exact missing binding against Wrangler's runtime binding inventory. The positive publisher @@ -49,10 +49,9 @@ the checked-in manifests. Local `[vars]` are only for the isolated smoke. For a deployed Worker, obtain the validated envelope from the target KV namespace, construct the same nested `TRUSTED_SERVER_CONFIG` JSON, and configure it in the deployment environment. -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 all four bindings are present. +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 all three bindings are present. A successful `ts config push` alone does not configure this runtime: the Worker currently does not open `TRUSTED_SERVER_KV` during startup. Request-time diff --git a/docs/guide/configuration-rules.md b/docs/guide/configuration-rules.md index 89084661cb..781a21f470 100644 --- a/docs/guide/configuration-rules.md +++ b/docs/guide/configuration-rules.md @@ -158,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 @@ -194,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 1683a158fe..7b32121ee6 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -68,7 +68,7 @@ ts config push --adapter fastly ### 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 @@ -76,7 +76,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 @@ -119,7 +118,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 | @@ -230,7 +228,6 @@ 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 | @@ -677,7 +674,8 @@ 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. @@ -686,7 +684,6 @@ A masked value shows as `XXXX`, and the page lists every masked path. | 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` and `auction.creative_store` | By default | Yes | | Anything `hide` names | When named | It is the publisher's own choice | @@ -1094,142 +1091,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 Passwords are validated at startup - -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 fails when any handler 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 a broader 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. @@ -3392,12 +3253,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 @@ -3421,8 +3276,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 @@ -3517,12 +3372,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/fastly.md b/docs/guide/fastly.md index a4675c126a..2e100cddc7 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -384,10 +384,10 @@ Run the repository smoke from a clean shell: The script creates an isolated application config, applies its publisher-origin overrides, and runs strict validation. It then executes `ts config push ---adapter fastly --local`, adds all three required entries to +--adapter fastly --local`, adds both required entries to `[local_server.secret_stores.ts_secrets]`, and starts `fastly compute serve` -through Viceroy. The required keys are `handler_password`, -`publisher_proxy_secret`, and `ec_passphrase`. +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 785f002d93..a9fc8b19ec 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -90,11 +90,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)" # Build and start the dev server in the same shell. cargo run -p trusted-server-adapter-axum diff --git a/docs/guide/spin.md b/docs/guide/spin.md index 2299ca3cb2..8b84421d5c 100644 --- a/docs/guide/spin.md +++ b/docs/guide/spin.md @@ -33,7 +33,6 @@ a store the runtime never reads. Local push writes the temporary 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` diff --git a/fastly.toml b/fastly.toml index 66f26e45e4..b43df79492 100644 --- a/fastly.toml +++ b/fastly.toml @@ -45,7 +45,7 @@ build = """ key = "ts-2025-10-A" data = "NVnTYrw5xoyTJDOwoUWoPJO3A6UCCXOJJUzgGTxxx7k=" - # 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. [[local_server.secret_stores.ts_secrets]] key = "publisher_proxy_secret" @@ -55,10 +55,6 @@ build = """ key = "ec_passphrase" data = "local-only-ec-passphrase-32-bytes" - [[local_server.secret_stores.ts_secrets]] - key = "handler_password" - data = "local-only-handler-password" - [[local_server.secret_stores.ts_secrets]] key = "tinybird_auction_append_token" data = "test-tinybird-auction-append-token" diff --git a/scripts/attestation-local-test.sh b/scripts/attestation-local-test.sh index 10b73117eb..f10550dc8b 100755 --- a/scripts/attestation-local-test.sh +++ b/scripts/attestation-local-test.sh @@ -140,10 +140,6 @@ data = "fictional-local-proxy-secret-for-this-harness" [[local_server.secret_stores.ts_secrets]] key = "ec_passphrase" data = "fictional-local-ec-passphrase-secret-value" - -[[local_server.secret_stores.ts_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/config-by-host-local-test.sh b/scripts/config-by-host-local-test.sh index dcdbe870ac..86aef1662b 100755 --- a/scripts/config-by-host-local-test.sh +++ b/scripts/config-by-host-local-test.sh @@ -193,10 +193,6 @@ data = "fictional-local-publisher-proxy-secret-value" [[local_server.secret_stores.ts_secrets]] key = "ec_passphrase" data = "fictional-local-ec-passphrase-secret-value" - -[[local_server.secret_stores.ts_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 3632dc3ff7..42da64b7eb 100755 --- a/scripts/smoke-axum.sh +++ b/scripts/smoke-axum.sh @@ -58,23 +58,20 @@ run_case() { local case_name="$1" local port="$2" local config_value="$3" - local handler_value="$4" - local proxy_value="$5" - local ec_value="$6" - local expected_status="$7" - local expected_diagnostic="${8:-}" + local proxy_value="$4" + local ec_value="$5" + local expected_status="$6" + local expected_diagnostic="${7:-}" local log_path="$WORKSPACE/$case_name.log" local body_path="$WORKSPACE/$case_name.body" local headers_path="$WORKSPACE/$case_name.headers" local env_args=( env -u "$SMOKE_CONFIG_ENV" - -u "$SMOKE_HANDLER_ENV" -u "$SMOKE_PROXY_ENV" -u "$SMOKE_EC_ENV" ) [ -z "$config_value" ] || env_args+=("$SMOKE_CONFIG_ENV=$config_value") - [ -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") @@ -99,18 +96,15 @@ run_case() { } run_case missing-config "$BASE_PORT" "" \ - "$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-handler "$((BASE_PORT + 1))" "$ENVELOPE" \ - "" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 500 \ - "failed to resolve secret reference at \`handlers[0].password\`" -run_case missing-proxy "$((BASE_PORT + 2))" "$ENVELOPE" \ - "$SMOKE_HANDLER_VALUE" "" "$SMOKE_EC_VALUE" 500 \ +run_case missing-proxy "$((BASE_PORT + 1))" "$ENVELOPE" \ + "" "$SMOKE_EC_VALUE" 500 \ "failed to resolve secret reference at \`publisher.proxy_secret\`" -run_case missing-ec "$((BASE_PORT + 3))" "$ENVELOPE" \ - "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "" 500 \ +run_case missing-ec "$((BASE_PORT + 2))" "$ENVELOPE" \ + "$SMOKE_PROXY_VALUE" "" 500 \ "failed to resolve secret reference at \`ec.passphrase\`" -run_case positive "$((BASE_PORT + 4))" "$ENVELOPE" \ - "$SMOKE_HANDLER_VALUE" "$SMOKE_PROXY_VALUE" "$SMOKE_EC_VALUE" 200 +run_case positive "$((BASE_PORT + 3))" "$ENVELOPE" \ + "$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 161823217f..3097d93ec2 100755 --- a/scripts/smoke-cloudflare.sh +++ b/scripts/smoke-cloudflare.sh @@ -90,7 +90,6 @@ write_runtime_config() { fi CLOUDFLARE_SMOKE_CONFIG_LINE="$config_line" awk \ -v missing_key="$missing_key" \ - -v handler="$SMOKE_HANDLER_VALUE" \ -v proxy="$SMOKE_PROXY_VALUE" \ -v ec="$SMOKE_EC_VALUE" ' BEGIN { @@ -109,9 +108,6 @@ write_runtime_config() { print "Cloudflare smoke template must contain one config placeholder" > "/dev/stderr" exit 1 } - if (missing_key != "handler_password") { - print "handler_password = \"" handler "\"" - } if (missing_key != "publisher_proxy_secret") { print "publisher_proxy_secret = \"" proxy "\"" } @@ -160,7 +156,7 @@ run_case() { smoke_die "negative case unexpectedly exposed $missing_binding" fi if [ "$missing_binding" = "env.TRUSTED_SERVER_CONFIG" ]; then - grep --fixed-strings --quiet 'env.handler_password' "$log_path" || + grep --fixed-strings --quiet 'env.publisher_proxy_secret' "$log_path" || smoke_die "missing-config case did not retain the secret control binding" else grep --fixed-strings --quiet 'env.TRUSTED_SERVER_CONFIG' "$log_path" || @@ -178,15 +174,12 @@ run_case() { run_case missing-config "$BASE_PORT" "" "" 500 \ 'env.TRUSTED_SERVER_CONFIG' \ 'Cloudflare TRUSTED_SERVER_CONFIG is required' -run_case missing-handler "$((BASE_PORT + 1))" "$CONFIG_JSON" handler_password 500 \ - 'env.handler_password' \ - "failed to resolve secret reference at \`handlers[0].password\` from secret store \`trusted_server_secrets\`" -run_case missing-proxy "$((BASE_PORT + 2))" "$CONFIG_JSON" publisher_proxy_secret 500 \ +run_case missing-proxy "$((BASE_PORT + 1))" "$CONFIG_JSON" publisher_proxy_secret 500 \ 'env.publisher_proxy_secret' \ "failed to resolve secret reference at \`publisher.proxy_secret\` from secret store \`trusted_server_secrets\`" -run_case missing-ec "$((BASE_PORT + 3))" "$CONFIG_JSON" ec_passphrase 500 \ +run_case missing-ec "$((BASE_PORT + 2))" "$CONFIG_JSON" ec_passphrase 500 \ 'env.ec_passphrase' \ "failed to resolve secret reference at \`ec.passphrase\` from secret store \`trusted_server_secrets\`" -run_case positive "$((BASE_PORT + 4))" "$CONFIG_JSON" "" 200 "" "" +run_case positive "$((BASE_PORT + 3))" "$CONFIG_JSON" "" 200 "" "" echo "Cloudflare first-success smoke passed" diff --git a/scripts/smoke-common.sh b/scripts/smoke-common.sh index 9335c42ddd..e262b36cc8 100644 --- a/scripts/smoke-common.sh +++ b/scripts/smoke-common.sh @@ -172,9 +172,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 c2e8b01b99..ded5dd5631 100755 --- a/scripts/smoke-fastly.sh +++ b/scripts/smoke-fastly.sh @@ -131,10 +131,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 @@ -127,12 +123,10 @@ EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=default \ --no-diff CONFIG_PUSHED=true -run_case missing-handler "$((BASE_PORT + 1))" "$SPIN_HANDLER_VAR" 503 \ - "resolved secret at \`handlers[0].password\` must not be empty" -run_case missing-proxy "$((BASE_PORT + 2))" "$SPIN_PROXY_VAR" 503 \ +run_case missing-proxy "$((BASE_PORT + 1))" "$SPIN_PROXY_VAR" 503 \ "resolved secret at \`publisher.proxy_secret\` must not be empty" -run_case missing-ec "$((BASE_PORT + 3))" "$SPIN_EC_VAR" 503 \ +run_case missing-ec "$((BASE_PORT + 2))" "$SPIN_EC_VAR" 503 \ "resolved secret at \`ec.passphrase\` must not be empty" -run_case positive "$((BASE_PORT + 4))" "" 200 +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 99561a8a6b..eab13804ef 100755 --- a/scripts/template-cache-local-test.sh +++ b/scripts/template-cache-local-test.sh @@ -439,10 +439,6 @@ data = "fictional-local-publisher-proxy-secret-value" [[local_server.secret_stores.ts_secrets]] key = "ec_passphrase" data = "fictional-local-ec-passphrase-secret-value" - -[[local_server.secret_stores.ts_secrets]] -key = "handler_password" -data = "fictional-local-handler-password-secret-value" ''') PYEOF diff --git a/trusted-server.example.toml b/trusted-server.example.toml index d7a57b0a29..f0a6727307 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -6,14 +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 one section is REQUIRED for the server to start and pass validation: -# 1. [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. @@ -42,27 +42,6 @@ # ============================================================================= -# ----------------------------------------------------------------------------- -# OPTIONAL - Password-protected paths -# ----------------------------------------------------------------------------- -# HTTP Basic-auth handler(s). Each handler gates the paths its `path` regex -# matches, and needs a non-placeholder username/password (deploy validation -# rejects the sample password below). -[[handlers]] -# Regex matched against the request path. -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 # ----------------------------------------------------------------------------- From a5a8704da4cdbc6aafe23a9c673c527130175ff2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 13:46:11 +0100 Subject: [PATCH 09/25] Bot protection is asked about the publisher's admin paths The DataDome module skipped its protection API for every path beneath `/admin/` and `/_ts/admin/`, listing them with the server's own routes. Nothing of the server's is served beneath either. `/_ts/admin` and `/admin/keys` are answered by the closed path check before any request filter runs, and everything else beneath `/admin/` is the publisher's own site, which the list left out of protection. `only_the_servers_own_paths_are_internal` holds the list both ways. --- CHANGELOG.md | 1 + .../bot-protection/datadome/src/protection.rs | 23 +++++++++++++++++-- docs/guide/integrations/datadome.md | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9c739c00..ed71978787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ 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, and no longer on every page of a deployment that selects the module. 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 hook 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`. diff --git a/crates/bot-protection/datadome/src/protection.rs b/crates/bot-protection/datadome/src/protection.rs index eb6a0a8936..cd78c139b6 100644 --- a/crates/bot-protection/datadome/src/protection.rs +++ b/crates/bot-protection/datadome/src/protection.rs @@ -600,8 +600,6 @@ fn is_internal_path(path: &str) -> 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/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. From 91bb208a5c071d21f76a7bafabec4fcd8a9d34a8 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 15:20:33 +0100 Subject: [PATCH 10/25] ts cache purge joins every value to its flag The purge command passes --key and --service-id joined with =, as ts keys does, with a test that no value stands alone. The guide no longer says a Basic credential is validated and the ts cache row names both caches. A duplicate percent check goes from closed_paths.rs, and comments that argued for alternatives not taken are cut or shortened. --- .../src/commands/cache/mod.rs | 9 +--- .../src/commands/cache/purge.rs | 43 ++++++++++++------- .../trusted-server-cli/src/commands/keys.rs | 7 ++- crates/trusted-server-cli/src/fastly_cli.rs | 4 +- .../trusted-server-core/src/closed_paths.rs | 6 --- docs/guide/cli.md | 2 +- docs/guide/configuration.md | 8 ---- 7 files changed, 34 insertions(+), 45 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/cache/mod.rs b/crates/trusted-server-cli/src/commands/cache/mod.rs index 3ee3027404..79bdb2b8ff 100644 --- a/crates/trusted-server-cli/src/commands/cache/mod.rs +++ b/crates/trusted-server-cli/src/commands/cache/mod.rs @@ -16,13 +16,8 @@ pub(crate) enum CacheCommand { /// Arguments for `ts cache purge`. /// -/// # Why this asks the platform rather than the service -/// -/// A purge is administration, and the service answers readers on the publisher's own -/// domain. Asking the platform leaves the credential that can empty the cache with the -/// platform's access control, which ties a token to the user who made it, can limit it -/// to purging by key and can revoke it, and keeps that credential off the domain every -/// reader reaches. +/// The platform is asked, so the credential that can empty the cache stays off the +/// publisher's domain. /// /// 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. diff --git a/crates/trusted-server-cli/src/commands/cache/purge.rs b/crates/trusted-server-cli/src/commands/cache/purge.rs index 7a774b4b02..ec41dbf2a9 100644 --- a/crates/trusted-server-cli/src/commands/cache/purge.rs +++ b/crates/trusted-server-cli/src/commands/cache/purge.rs @@ -22,9 +22,7 @@ pub(crate) struct PurgeTarget { /// 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. Anything else is refused here, - // because the id becomes an argument of the `fastly` process and a value starting - // with `-` would be read as one of its flags. + // 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"); } @@ -33,9 +31,7 @@ pub(crate) fn purge_target(purge: &PurgeArgs) -> CliResult { scope: "all", surrogate_key: TEMPLATE_CACHE_PURGE_ALL_SURROGATE_KEY.to_owned(), }), - // Parsed, not merely non-empty. The key function hashes whatever it is given, so a - // path or a scheme-less host would be purged under a key nothing was ever stored - // with, and the command would report success having invalidated nothing. + // A page that is not an absolute URL names a key nothing was stored with. (false, Some(page)) => { if !url::Url::parse(page).is_ok_and(|parsed| { matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some() @@ -47,7 +43,7 @@ pub(crate) fn purge_target(purge: &PurgeArgs) -> CliResult { surrogate_key: reader_url_surrogate_key(page), }) } - // Defaulting to `--all` would make a bare `ts cache purge` flush production. + // A bare `ts cache purge` purges nothing. (false, None) => cli_error("specify --all or --page "), } } @@ -65,17 +61,14 @@ pub(crate) fn run_purge( ) -> CliResult<()> { let target = purge_target(purge)?; - // By surrogate key for both scopes, and never `fastly service purge --all`, which would - // also flush every object this service cached without one of the template cache's tags. + // By surrogate key for both scopes, never `fastly service purge --all`. runner .run( &args(&[ "service", "purge", - "--key", - &target.surrogate_key, - "--service-id", - &purge.service_id, + &format!("--key={}", target.surrogate_key), + &format!("--service-id={}", purge.service_id), "--non-interactive", ]), None, @@ -130,11 +123,10 @@ mod tests { } } + /// The value joined to `name` with `=`. fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { args.iter() - .position(|arg| arg == name) - .and_then(|at| args.get(at + 1)) - .map(String::as_str) + .find_map(|arg| arg.strip_prefix(name)?.strip_prefix('=')) } fn purge(fake: &FakeFastly, purge: &PurgeArgs) -> CliResult { @@ -225,6 +217,25 @@ mod tests { } } + #[test] + 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!( + arg.starts_with("--"), + "a value standing alone can be read as a flag: {:?}", + fake.calls() + ); + } + } + #[test] fn neither_scope_is_an_error_rather_than_a_default() { let fake = FakeFastly::default(); diff --git a/crates/trusted-server-cli/src/commands/keys.rs b/crates/trusted-server-cli/src/commands/keys.rs index 3cde1ecadd..0da57125eb 100644 --- a/crates/trusted-server-cli/src/commands/keys.rs +++ b/crates/trusted-server-cli/src/commands/keys.rs @@ -27,6 +27,9 @@ 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. +/// +/// Only the test fake is known to produce this wording. No recorded run of the +/// `fastly` CLI has, and the live check saw `401` alone. 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 @@ -185,8 +188,6 @@ impl PlatformConfigStore for FastlyConfigStore { .and_then(|output| item_value(&output, key)) .map_err(|message| { if !message.contains(API_NOT_FOUND) { - // The first failure is kept. One is enough to stop every - // write, so a later one has nothing to add. let _ = self.platform.failed_read.set(message.clone()); } Report::new(PlatformError::ConfigStore).attach(message) @@ -362,8 +363,6 @@ pub(crate) fn run_with( .to_string()) } KeysCommand::Deactivate(deactivate) => { - // Looser than the rule for a new key, so that a key made under an - // earlier rule can still be retired. if !kid_is_well_formed(&deactivate.kid) { return Err(format!( "kid `{}` must hold only letters, digits, `-`, `_`, `.` and `:`, and be 1 \ diff --git a/crates/trusted-server-cli/src/fastly_cli.rs b/crates/trusted-server-cli/src/fastly_cli.rs index 55a5f7872e..bd40b7a5ec 100644 --- a/crates/trusted-server-cli/src/fastly_cli.rs +++ b/crates/trusted-server-cli/src/fastly_cli.rs @@ -60,8 +60,6 @@ impl FastlyRunner for FastlyProcess { .stderr(Stdio::piped()) .spawn() .map_err(|error| format!("could not run `fastly`: {error}"))?; - // The pipe closes when this is done, which is how `fastly` learns the - // value has ended. let written = match stdin { Some(input) => match child.stdin.take() { Some(mut pipe) => pipe @@ -141,7 +139,7 @@ mod tests { "secret-store-entry create" ); assert_eq!( - command_words(&args(&["service", "purge", "--key", "ts-template"])), + command_words(&args(&["service", "purge", "--key=ts-template"])), "service purge" ); } diff --git a/crates/trusted-server-core/src/closed_paths.rs b/crates/trusted-server-core/src/closed_paths.rs index 2c935ba533..bac9b398c2 100644 --- a/crates/trusted-server-core/src/closed_paths.rs +++ b/crates/trusted-server-core/src/closed_paths.rs @@ -54,12 +54,6 @@ fn is_closed(path: &str) -> bool { return true; } - // Normal publisher paths carry no escape sequence, so the decode loop and - // its allocation are 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 { diff --git a/docs/guide/cli.md b/docs/guide/cli.md index f03f62713b..304819f17f 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -23,7 +23,7 @@ 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 cache commands | `ts cache ` | +| `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 ` | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 13c0422b97..3107ca9294 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2587,14 +2587,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, and schema From 4a36d737f081ad9b17e14d9788be36423ce99f74 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 10:16:53 +0100 Subject: [PATCH 11/25] Push a host's document to the live Fastly store `ts config push --adapter fastly --key ` without `--local` now writes the key itself to the live config store that EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME selects, or to the store named `trusted_server_config` when that is unset, because EdgeZero writes no Fastly key but the logical store ID. The store's id is found by name with `fastly config-store list --json`, and each entry is written with `fastly config-store-entry update --stdin --upsert`, a long document's chunks first and its pointer last, through the same runner `ts keys` uses, with the token taken from FASTLY_API_TOKEN. A failed write stops the push and its message leaves out what `fastly` printed, because that can quote the value. `--dry-run` lists the keys and asks `fastly` nothing. Until now a push of such a key without `--local` was refused, because the runner arrived only with the key commands of this branch. Tests: a_remote_push_writes_chunks_then_the_pointer runs the writer against a recording runner and checks every update goes to the store the selector names, chunk by chunk and then the pointer, with each value on standard input. Two more cover the store name without a selector and a listing where the name is absent or carried twice. The integration test that expected the refusal is replaced by one that runs a remote dry run with no `fastly` on the path and checks it names the live store and writes nothing locally. --- .../src/commands/config/keyed_push.rs | 242 ++++++++++++++++-- crates/trusted-server-cli/src/run.rs | 2 +- .../tests/config_push_by_key.rs | 19 +- 3 files changed, 233 insertions(+), 30 deletions(-) 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 8d08e48d29..01ef7f3a56 100644 --- a/crates/trusted-server-cli/src/commands/config/keyed_push.rs +++ b/crates/trusted-server-cli/src/commands/config/keyed_push.rs @@ -6,8 +6,11 @@ //! 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. use std::fs; use std::io::Write; @@ -16,6 +19,7 @@ use std::path::{Path, PathBuf}; 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 serde::Serialize; use sha2::{Digest as _, Sha256}; @@ -24,6 +28,7 @@ use trusted_server_core::config::TrustedServerAppConfig; use trusted_server_core::config_payload::DEFAULT_CONFIG_STORE_ID; use crate::app_config::resolve_app_config_path; +use crate::fastly_cli::{self, FastlyProcess, FastlyRunner}; /// The adapter whose keys this writer writes. const ADAPTER: &str = "fastly"; @@ -77,24 +82,19 @@ 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. +/// Pushes the blob `args` names under `key`, into the local server's store +/// with `--local` and into the live store otherwise. /// /// Every key is checked before anything is written, and a long blob's -/// chunks are written before the pointer to them. `--dry-run` lists the keys -/// and writes nothing. +/// chunks are written before the pointer to them. `--dry-run` lists the keys, +/// writes nothing and asks the `fastly` CLI nothing. /// /// # Errors /// -/// Returns a message when the push is not `--local`, the blob fails -/// validation, a key is refused, or the local manifest cannot be read or -/// written. +/// Returns a message when 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: &ConfigPushArgs, key: &str, out: &mut dyn Write) -> Result<(), String> { - if !args.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(&args.manifest) .map_err(|error| format!("failed to load {}: {error}", args.manifest.display()))?; let manifest = loader.manifest(); @@ -102,19 +102,31 @@ pub(crate) fn run(args: &ConfigPushArgs, key: &str, out: &mut dyn Write) -> Resu let envelope_json = serde_json::to_string(&envelope) .map_err(|error| format!("failed to serialize the envelope: {error}"))?; let entries = entries(key, &envelope_json, &envelope.sha256)?; - let fastly_toml = local_manifest(&args.manifest, manifest)?; + + let destination = if args.local { + let fastly_toml = local_manifest(&args.manifest, manifest)?; + if !args.dry_run { + write_local(&fastly_toml, &entries)?; + } + format!( + "[local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}.contents] in {}", + fastly_toml.display() + ) + } else { + let store = remote_store_name(&EnvConfig::from_env())?; + if !args.dry_run { + let runner = FastlyProcess::from_environment()?; + write_remote(&runner, &store, &entries)?; + } + format!("the Fastly config store `{store}`") + }; let verb = if args.dry_run { "would write" } else { "wrote" }; - if !args.dry_run { - write_local(&fastly_toml, &entries)?; - } for (entry_key, value) in &entries { 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(|error| format!("failed to write command output: {error}"))?; } @@ -246,6 +258,89 @@ fn sha256_hex(text: &str) -> String { format!("{:x}", Sha256::digest(text.as_bytes())) } +/// 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) +} + +/// Writes `entries` in order to the live config store named `store`, through +/// the `fastly` CLI, and returns the store's id. +/// +/// `fastly` writes an entry by store id, so the id is found by the store's +/// name first. 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. +/// +/// # Errors +/// +/// Returns a message when the stores cannot be listed, no store or more than +/// one carries the name, or a write fails. +fn write_remote( + runner: &dyn FastlyRunner, + store: &str, + entries: &[(String, String)], +) -> Result { + let listing = runner.run( + &fastly_cli::args(&["config-store", "list", "--json", "--non-interactive"]), + None, + )?; + let store_id = store_id_by_name(&listing, store)?; + 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(store_id) +} + +/// 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 { @@ -316,8 +411,111 @@ 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 store_id = write_remote(&fastly, &store, &written).expect("should write every entry"); + + assert_eq!( + store_id, "publisher-id", + "should write the store the selector names" + ); + 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 + ); + } + + #[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" + ); + } + #[test] fn a_long_envelope_is_split_at_character_boundaries_with_the_pointer_last() { // After one byte, two-byte characters straddle the chunk limit, so a diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 10009c0648..7e9df67701 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -72,7 +72,7 @@ enum ConfigCommand { /// Reclaim orphaned chunk entries leaked from prior oversized pushes. Gc(ConfigGcArgs), /// Push `trusted-server.toml` as a blob envelope through `EdgeZero`, or - /// under a Fastly `--key` of its own with `--local`. + /// under a Fastly `--key` of its own. Push(ConfigPushArgs), /// Validate `edgezero.toml` and the typed Trusted Server config. Validate(ConfigValidateArgs), 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 6c2da1b587..443b43e1ff 100644 --- a/crates/trusted-server-cli/tests/config_push_by_key.rs +++ b/crates/trusted-server-cli/tests/config_push_by_key.rs @@ -305,21 +305,26 @@ fn a_key_whose_chunk_keys_are_too_long_is_refused_before_writing() { } #[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"]); - 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" ); } From cab6975c8a8aabf8095048526570db496af0667c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 10:19:25 +0100 Subject: [PATCH 12/25] Say the host's document is pushed to the live store The guide, the CLI page and the changelog said a key other than trusted_server_config could be pushed only with `--local` and copied into a live store by hand. Now that `ts config push --key ` writes the live store the __NAME selector picks, they say so, and the guide's commands for a document per host drop `--local`. --- CHANGELOG.md | 2 +- docs/guide/cli.md | 5 ++++- docs/guide/configuration.md | 17 +++++++++-------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a720e71c3..e753e9fc0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,7 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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. - Attestation. With `[attestation]` in the settings a deployment serves evidence, in the sense of RFC 9334, of who operates it and which build it runs, as a page and as JSON at `[attestation] endpoint`, which defaults to `/_ts/attestation`. The evidence names the host the request arrived for, the publisher, the platform service and the build, with the time and the caller's nonce, and is signed with ECDSA P-256 under a configured context. The signing keys are a schedule compiled in from the build input `TRUSTED_SERVER_ATTESTATION_KEYS`, and a build given none answers `503` at the endpoint. A build also reports the commit, the build run, the tag it was made from and the build time it was given, from `TRUSTED_SERVER_COMMIT`, `TRUSTED_SERVER_BUILD_RUN`, `TRUSTED_SERVER_BUILD_TAG` and `SOURCE_DATE_EPOCH`. See `docs/guide/attestation.md`. -- 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. 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. +- 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. 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. - A builder registers its module from the compiled auction plan with `with_plan_registration` and checks its settings against the plan with `with_plan_validator`, so a module whose page support follows what the plan selects is not named in `trusted-server-core`. Prebid and APS register this way. A renderer descriptor carries its builder's statement of which payload key holds the identifier the renderer picks its bid by, made with `BidRenderer::picking_bid_by`, and the page's `hb_adid` is read through that statement. diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 99e228e447..aaa99862f4 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -276,7 +276,10 @@ 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`. +`--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`. Inspect and verify deployments with the deploy lifecycle commands. All three are Fastly-only — the axum, cloudflare, and spin adapters reject them: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 1c551e580f..463984cd2d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -3058,8 +3058,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. @@ -3083,8 +3082,8 @@ 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 --app-config publisher-a.toml -ts config push --adapter fastly --local --key www.publisher-b.example --app-config publisher-b.toml +ts config push --adapter fastly --key www.publisher-a.example --app-config publisher-a.toml +ts config push --adapter fastly --key www.publisher-b.example --app-config publisher-b.toml fastly config-store-entry update --upsert \ --store-id \ @@ -3092,10 +3091,12 @@ 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`, a long blob's -chunks before its pointer. The selector is read on every request, so the +`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. A long blob's chunks are +written before its pointer. The selector is read on every request, so the blobs come first. | A request for | Reads the blob at | Is answered | From 11bf7b76d050b3e949aec94ae4ab255d590941d5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:05:38 +0100 Subject: [PATCH 13/25] The ts keys tests answer as the fastly CLI does, and never replace a secret The test fake replaced a secret that already had the name. The API refuses to create a secret under a name the store holds and replaces one only when asked with --recreate or --recreate-allow, which the command never passes. The fake now refuses too, and a test checks that a rotation onto a secret a half finished rotation left behind fails, writes nothing to the config store and never asks to replace. The fake's 404 is now the text the fastly CLI prints, as 15.1.0 and 16.1.0 print it for a missing config store or entry and a missing secret store or secret. A test checks that both carry the wording the stores read a missing entry from, and another reads a value from the fields config-store-entry describe --json prints. The comment that said no run of the CLI had shown the wording now says which versions print it. --- .../trusted-server-cli/src/commands/keys.rs | 98 +++++++++++++++++-- docs/guide/cli.md | 8 +- 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/keys.rs b/crates/trusted-server-cli/src/commands/keys.rs index 0da57125eb..c8c2dae50f 100644 --- a/crates/trusted-server-cli/src/commands/keys.rs +++ b/crates/trusted-server-cli/src/commands/keys.rs @@ -26,10 +26,8 @@ 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. -/// -/// Only the test fake is known to produce this wording. No recorded run of the -/// `fastly` CLI has, and the live check saw `401` alone. +/// 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 @@ -484,8 +482,28 @@ mod tests { .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 { - format!("`fastly` failed: ERROR: the Fastly API returned 404 Not Found: {what}") + let recorded = if what.contains("secret") { + RECORDED_SECRET_404 + } else { + RECORDED_CONFIG_404 + }; + format!("`fastly` failed: {recorded}") } impl FastlyRunner for FakeFastly { @@ -531,11 +549,18 @@ mod tests { .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(); - self.secrets - .lock() - .expect("should lock") - .insert(name.to_owned(), stdin.unwrap_or_default().to_owned()); + 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") => { @@ -716,6 +741,61 @@ mod tests { 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 diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 304819f17f..b8c4827d34 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -316,9 +316,11 @@ 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. `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). +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 From df0fc18a071a9d06022169be6b8a595a8f5120de Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:05:54 +0100 Subject: [PATCH 14/25] An attestation endpoint beneath the closed administration prefix is refused Every adapter closes /_ts/admin before the publisher fallback, and the attestation endpoint is the one route the settings can place, so an endpoint there passed validation and was served beneath the prefix. validate_endpoint now refuses a page or JSON address the closed path matcher closes, as reserved_endpoint naming the address, with a test of /_ts/admin, /_ts/admin/attestation and /_ts/adminx/attestation. --- CHANGELOG.md | 2 +- crates/trusted-server-core/src/attestation.rs | 38 +++++++++++++++++-- .../trusted-server-core/src/closed_paths.rs | 2 +- docs/guide/attestation.md | 12 +++--- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e771f4304f..10a6aab4ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 deploy reads a configuration key of its own (`ts deploy --staging`, `ts config push --staging`), which 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:** 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`, where an `[attestation] endpoint` is refused. 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 deploy reads a configuration key of its own (`ts deploy --staging`, `ts config push --staging`), which 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 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. diff --git a/crates/trusted-server-core/src/attestation.rs b/crates/trusted-server-core/src/attestation.rs index 56bcaa4f8d..17ac777c24 100644 --- a/crates/trusted-server-core/src/attestation.rs +++ b/crates/trusted-server-core/src/attestation.rs @@ -672,14 +672,15 @@ fn escape_attribute(value: &str) -> String { /// case letters, digits, `_` and `-`, the first beginning with an underscore /// so the pages sit apart from the publisher's own, and without `.json`, /// which names the JSON form. Neither the page nor the JSON form may be one -/// of the [`RESERVED_PATHS`], and the prefix may not be one of the +/// of the [`RESERVED_PATHS`] or a path every adapter closes (see +/// [`crate::closed_paths`]), and the prefix may not be one of the /// [`ORIGIN_ASSET_PREFIXES`]. /// /// # Errors /// /// Returns `invalid_endpoint` for any other shape, `reserved_endpoint`, -/// naming the address, when the page or the JSON form is reserved, and -/// `origin_prefix`, naming the prefix, when the prefix is one a framework +/// naming the address, when the page or the JSON form is reserved or closed, +/// and `origin_prefix`, naming the prefix, when the prefix is one a framework /// serves its assets from. pub fn validate_endpoint(value: &str) -> Result<(), ValidationError> { let segment_ok = |segment: &str| { @@ -706,6 +707,14 @@ pub fn validate_endpoint(value: &str) -> Result<(), ValidationError> { error.message = Some(format!("{reserved} is answered by the deployment itself").into()); return Err(error); } + if let Some(closed) = paths_of(value) + .into_iter() + .find(|path| crate::closed_paths::is_closed(path)) + { + let mut error = ValidationError::new("reserved_endpoint"); + error.message = Some(format!("{closed} is closed on every adapter").into()); + return Err(error); + } let prefix = prefix_of(value); if ORIGIN_ASSET_PREFIXES.contains(&prefix) { let mut error = ValidationError::new("origin_prefix"); @@ -1331,6 +1340,29 @@ mod tests { } } + /// Every adapter closes the administration prefix before the publisher + /// fallback, so an endpoint beneath it would be the one route served there. + #[test] + fn an_endpoint_beneath_the_closed_administration_prefix_is_refused() { + for endpoint in [ + "/_ts/admin", + "/_ts/admin/attestation", + "/_ts/adminx/attestation", + ] { + let error = validate_endpoint(endpoint).expect_err("should refuse the endpoint"); + + assert_eq!( + error.code, "reserved_endpoint", + "{endpoint} should be refused" + ); + assert_eq!( + error.message.as_deref(), + Some(format!("{endpoint} is closed on every adapter").as_str()), + "{endpoint} should be named" + ); + } + } + #[test] fn the_section_loads_from_a_document_and_refuses_unknown_fields() { let base = crate_test_settings_str(); diff --git a/crates/trusted-server-core/src/closed_paths.rs b/crates/trusted-server-core/src/closed_paths.rs index bac9b398c2..3cf6750c18 100644 --- a/crates/trusted-server-core/src/closed_paths.rs +++ b/crates/trusted-server-core/src/closed_paths.rs @@ -49,7 +49,7 @@ pub fn closed_path_response(req: &Request) -> Option bool { +pub(crate) fn is_closed(path: &str) -> bool { if is_closed_as_written(path) { return true; } diff --git a/docs/guide/attestation.md b/docs/guide/attestation.md index 54ce27ce5b..9817ff7c9a 100644 --- a/docs/guide/attestation.md +++ b/docs/guide/attestation.md @@ -167,12 +167,12 @@ request and reads no store. ### `[attestation]` {#attestation-section} -| Field | Type | Rule | Meaning | -| ------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `endpoint` | String | An absolute path of two or more segments, 64 characters at most, of lower case letters, digits, `_` and `-`, the first segment starting with `_`, not under a prefix a framework serves its assets from, and not ending `.json`. Defaults to `/_ts/attestation`. | Where the page answers. The JSON is at the same path plus `.json`. | -| `operator` | String | 1 to 64 characters | Who the evidence says operates the deployment | -| `context` | String | 1 to 64 characters with no control characters | The text signed ahead of the payload. A verifier must use exactly the same text. | -| `verify_url` | String | An `https` address with a host | Where the page sends a reader to check the claim, and the envelope's `verify` field | +| Field | Type | Rule | Meaning | +| ------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | +| `endpoint` | String | An absolute path of two or more segments, 64 characters at most, of lower case letters, digits, `_` and `-`, the first segment starting with `_`, not under a prefix a framework serves its assets from, not beneath `/_ts/admin`, and not ending `.json`. Defaults to `/_ts/attestation`. | Where the page answers. The JSON is at the same path plus `.json`. | +| `operator` | String | 1 to 64 characters | Who the evidence says operates the deployment | +| `context` | String | 1 to 64 characters with no control characters | The text signed ahead of the payload. A verifier must use exactly the same text. | +| `verify_url` | String | An `https` address with a host | Where the page sends a reader to check the claim, and the envelope's `verify` field | The section refuses any other field, so a document naming a signing key is refused, because keys are compiled into the build and never configured. A From 23bde99bc1e36256fe484fad992dbcc8d977618f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:06:08 +0100 Subject: [PATCH 15/25] Axum answers a closed path before the modules' preparers run Fastly, Cloudflare and Spin check for a closed path first in their fallback, and Axum ran the preparers first and checked inside dispatch_fallback, so a preparer saw, and could answer, a request to a closed path. The check moves into fallback_handler, ahead of execute_handler. A test sends /_ts/admin/keys/rotate with the seam probe's counting header and expects 404 with no preparer run. --- crates/trusted-server-adapter-axum/src/app.rs | 16 +++++++------- .../tests/seam_probe.rs | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 8ac66c7297..1814a54fdf 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -334,8 +334,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. @@ -344,10 +345,6 @@ async fn dispatch_fallback( services: &RuntimeServices, req: Request, ) -> Result> { - if let Some(response) = closed_path_response(&req) { - return Ok(response); - } - let path = req.uri().path().to_string(); let method = req.method().clone(); @@ -415,7 +412,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, diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index a19a9b23b0..a9dc1e81a4 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -304,6 +304,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 // --------------------------------------------------------------------------- From 18aba165cfa50c0c7534440a8f24dce015f6f3be Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:06:09 +0100 Subject: [PATCH 16/25] Every adapter test sends the removed purge path, and Fastly's the double encoded paths C:/Program Files/Git/_ts/admin/cache/purge was in the core test alone. It joins the key administration paths each adapter sends with every publisher fallback method. The Fastly closed path test gains /admin%252Fkeys/rotate and /_ts%252Fadmin/ec, which the Axum, Cloudflare and Spin tests already send. --- crates/trusted-server-adapter-axum/tests/routes.rs | 1 + crates/trusted-server-adapter-cloudflare/tests/routes.rs | 1 + crates/trusted-server-adapter-fastly/src/app.rs | 5 +++++ crates/trusted-server-adapter-spin/tests/routes.rs | 1 + 4 files changed, 8 insertions(+) diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 61293c11f2..3d667d3587 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -372,6 +372,7 @@ async fn key_administration_paths_are_answered_here_and_never_proxied() { for path in [ "/_ts/admin/keys/rotate", "/_ts/admin/keys/deactivate", + "/_ts/admin/cache/purge", "/admin/keys/rotate", "/admin/keys/deactivate", ] { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 8f803609dd..235082b715 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -135,6 +135,7 @@ async fn key_administration_paths_are_answered_here_and_never_proxied() { for path in [ "/_ts/admin/keys/rotate", "/_ts/admin/keys/deactivate", + "/_ts/admin/cache/purge", "/admin/keys/rotate", "/admin/keys/deactivate", ] { diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 51e4ba4f31..c2870fa85e 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -2094,6 +2094,7 @@ mod tests { for path in [ "/_ts/admin/keys/rotate", "/_ts/admin/keys/deactivate", + "/_ts/admin/cache/purge", "/admin/keys/rotate", "/admin/keys/deactivate", ] { @@ -2142,6 +2143,10 @@ mod tests { "/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() diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index b6c5378c05..878fe4f5c0 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -311,6 +311,7 @@ async fn key_administration_paths_are_answered_here_and_never_proxied() { for path in [ "/_ts/admin/keys/rotate", "/_ts/admin/keys/deactivate", + "/_ts/admin/cache/purge", "/admin/keys/rotate", "/admin/keys/deactivate", ] { From e75e307de9a095da5791c99689aa8653d5170b16 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:06:10 +0100 Subject: [PATCH 17/25] Comments and the changelog no longer describe the removed purge endpoint and store settings The comments on reader_url_surrogate_key and the shared cache key named the purge endpoint, and the request signing module said the store ids come from the settings, which no longer take them. The changelog said only the removed routes read the two store ids, which the settings checks did too, and now says what GET /_ts/data does not cover. --- CHANGELOG.md | 4 ++-- crates/trusted-server-core/src/platform/template_cache.rs | 6 +++--- crates/trusted-server-core/src/publisher.rs | 2 +- crates/trusted-server-core/src/request_signing/mod.rs | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a6aab4ff..86c50c798f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,9 +58,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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`, where an `[attestation] endpoint` is refused. 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 deploy reads a configuration key of its own (`ts deploy --staging`, `ts config push --staging`), which 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 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:** `[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. 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`. +- **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 diff --git a/crates/trusted-server-core/src/platform/template_cache.rs b/crates/trusted-server-core/src/platform/template_cache.rs index e50bfe5032..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 /// diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index b9d8505f03..0bb81c1c7d 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4561,7 +4561,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} {}", 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; From ee544e27fa7629510d5f28abd714f06205e26f2b Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 19:38:28 +0100 Subject: [PATCH 18/25] The Cloudflare attestation route test carries no handlers list A configuration that still carries `[[handlers]]` is refused since HTTP Basic authentication was removed, so the settings of `attestation_routes_skip_the_request_preparers`, written with the list before that removal, could not be read and the test stopped at its first step. --- crates/trusted-server-adapter-cloudflare/tests/routes.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 001edfc3a6..28f09efd25 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -602,11 +602,6 @@ async fn attestation_routes_skip_the_request_preparers() { 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" From 1666c38e3853c366c1ee8214b10181b7f2d5af83 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 10:30:57 +0100 Subject: [PATCH 19/25] The development files and the Viceroy template name no opid_store --- .env.dev | 1 - .env.example | 1 - .../fixtures/configs/viceroy-template.toml | 4 ---- 3 files changed, 6 deletions(-) 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/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml index 933882d5f8..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" From e110aa025f0dbce97cb89f7a4fe099abf6d51db8 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 17 Oct 2026 10:30:58 +0100 Subject: [PATCH 20/25] A test checks that every store a development file names is declared --- .../tests/common/config.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index 3cb81809a9..46bb530ab7 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -126,6 +126,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 [ From a7f64a4028bb01021d43ad7ba0b655628c5eaeb5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:41 +0000 Subject: [PATCH 21/25] The policy fixtures and the endpoint proof carry no handlers list --- crates/trusted-server-adapter-axum/src/app.rs | 11 ----------- .../trusted-server-adapter-cloudflare/tests/routes.rs | 5 ----- crates/trusted-server-adapter-spin/tests/routes.rs | 5 ----- crates/trusted-server-core/src/test_support.rs | 5 ----- 4 files changed, 26 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index d4fde45dd2..afeb7e176f 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -917,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"; @@ -927,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" @@ -966,10 +959,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-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index 8701c33664..79877360d4 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -605,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-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 93d1f8b4a5..83c36a0319 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -680,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" diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 19fb83be0a..b674c41de4 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -688,11 +688,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" From 4eb37a05d6d72f603375623f5f7f746a1c1928c5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:42 +0000 Subject: [PATCH 22/25] The changelog says a staged version reads its key from the store it links --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d29fc501..72db7b881f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 deploy reads a configuration key of its own (`ts deploy --staging`, `ts config push --staging`), which 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:** 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. From b30bb38dddd829498359454fc431b23bd651f29a Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:43 +0000 Subject: [PATCH 23/25] ts keys gives its services a policy that grants nothing --- .../trusted-server-cli/src/commands/keys.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/trusted-server-cli/src/commands/keys.rs b/crates/trusted-server-cli/src/commands/keys.rs index c8c2dae50f..bdde95544a 100644 --- a/crates/trusted-server-cli/src/commands/keys.rs +++ b/crates/trusted-server-cli/src/commands/keys.rs @@ -15,6 +15,9 @@ use std::io::Write as _; use std::sync::{Arc, OnceLock}; use error_stack::Report; +use trusted_server_core::permissions::{ + PermissionPolicy, PolicyReference, load_policy, policy_sha256, +}; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformSecretStore, RuntimeServices, StoreId, StoreName, @@ -34,6 +37,15 @@ const API_NOT_FOUND: &str = "the Fastly API returned 404"; /// them would be written over, or would take the entry with it when deleted. const BOOKKEEPING_ENTRIES: [&str; 2] = ["current-kid", "active-kids"]; +/// The permission policy the commands' services carry, as every services +/// graph carries one. The commands read and write the signing stores and +/// decide no request's permissions, so the policy grants nothing. +const KEY_COMMANDS_POLICY: &str = + "groups:\n none:\n default: denied\nrules:\n group: none\n jurisdiction: non-regulated\n"; + +/// The name the commands' policy is loaded under. +const KEY_COMMANDS_POLICY_NAME: &str = "key-commands"; + /// Request signing key commands. #[derive(Debug, clap::Subcommand)] pub(crate) enum KeysCommand { @@ -295,9 +307,22 @@ fn services(runner: Arc, stores: &StoreArgs) -> RuntimeService .http_client(Arc::new(UnavailableHttpClient)) .geo(Arc::new(DisabledGeo)) .client_info(ClientInfo::default()) + .permission_policy(key_commands_policy()) .build() } +/// The policy that grants nothing, which the commands' services carry. +fn key_commands_policy() -> Arc { + load_policy( + PolicyReference { + policy: KEY_COMMANDS_POLICY_NAME.to_owned(), + sha256: policy_sha256(KEY_COMMANDS_POLICY), + }, + KEY_COMMANDS_POLICY, + ) + .expect("the key commands' policy should load") +} + /// 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) { @@ -628,6 +653,16 @@ mod tests { fake } + #[test] + fn the_key_commands_policy_grants_nothing() { + let policy = key_commands_policy(); + + assert!( + policy.maps().baseline(None, None).permissions().is_empty(), + "commands that decide no request should carry a policy that grants nothing" + ); + } + #[test] fn rotate_stores_a_new_key_and_makes_it_current() { let fake = Arc::new(FakeFastly::with_config(&[ From c16643a04fcc90add707069f73951a233652aae0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:44 +0000 Subject: [PATCH 24/25] A live keyed push reads its policy entries back and waits before the blob --- CHANGELOG.md | 2 +- .../src/commands/config/keyed_push.rs | 301 ++++++++++++++++-- .../tests/config_push_by_key.rs | 5 +- docs/guide/cli.md | 2 +- docs/guide/configuration.md | 5 +- 5 files changed, 280 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72db7b881f..689d9ed1a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,7 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `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. 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. +- 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 ` Result<(), let stored = local_entries(&fastly_toml)?; let (kept, missing): (Vec<_>, Vec<_>) = policies .iter() - .partition(|policy| holds_policy(&stored, &policy.key, &policy.sha256)); + .partition(|policy| holds_policy(&StoredEntries(&stored), &policy.key, &policy.sha256)); let written: Vec<(String, String)> = missing .iter() .flat_map(|policy| policy.entries.iter().cloned()) @@ -156,17 +164,22 @@ pub(crate) fn run(args: &PushArgs, key: &str, out: &mut dyn Write) -> Result<(), (kept, written, destination) } else { let store = remote_store_name(&EnvConfig::from_env())?; - let written: Vec<(String, String)> = policies - .iter() - .flat_map(|policy| policy.entries.iter().cloned()) - .chain(blob) - .collect(); - if !push.dry_run { + 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()?; - write_remote(&runner, &store, &written)?; + let (kept, written) = + push_remote(&runner, &store, &policies, blob, &std::thread::sleep, out)?; + (kept, written, destination) } - let destination = format!("the Fastly config store `{store}`"); - (Vec::new(), written, destination) }; let verb = if push.dry_run { "would write" } else { "wrote" }; @@ -266,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) } @@ -417,28 +430,76 @@ fn remote_store_name(env: &EnvConfig) -> Result { env.store_name_checked("config", DEFAULT_CONFIG_STORE_ID) } -/// Writes `entries` in order to the live config store named `store`, through -/// the `fastly` CLI, and returns the store's 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` writes an entry by store id, so the id is found by the store's -/// name first. 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. +/// `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 write_remote( +fn push_remote<'p>( runner: &dyn FastlyRunner, store: &str, - entries: &[(String, String)], -) -> Result { + 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(), @@ -458,7 +519,55 @@ fn write_remote( ) })?; } - Ok(store_id) + 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 @@ -622,11 +731,24 @@ mod tests { calls: Mutex::default(), }; - let store_id = write_remote(&fastly, &store, &written).expect("should write every entry"); + let waited = Mutex::new(Vec::new()); + let mut out = Vec::new(); - assert_eq!( - store_id, "publisher-id", - "should write the store the selector names" + 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"); @@ -669,6 +791,123 @@ mod tests { ); } + /// 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"}]}"#; @@ -715,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" ); } @@ -738,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/tests/config_push_by_key.rs b/crates/trusted-server-cli/tests/config_push_by_key.rs index f245848f6c..c6013f3402 100644 --- a/crates/trusted-server-cli/tests/config_push_by_key.rs +++ b/crates/trusted-server-cli/tests/config_push_by_key.rs @@ -486,7 +486,10 @@ fn a_remote_dry_run_names_the_live_store_and_writes_nothing() { // 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"]); + let output = push( + &project, + &["--key", HOST, "--dry-run", "--permissions", POLICY_FILE], + ); assert_success(&output); let stdout = String::from_utf8_lossy(&output.stdout); diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 721384f3e8..3ce991e5ef 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -302,7 +302,7 @@ environment. It is mutually exclusive with `--key`. On Fastly, `ts` pushes a `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 +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 diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 4442f3cee3..628d9131b4 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -3201,7 +3201,10 @@ 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`. From 784640fa485298d8cc35fefc0671d129c6864034 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:29:11 +0000 Subject: [PATCH 25/25] The key commands carry core's policy that grants nothing ts keys built a six line YAML policy into the ts binary and loaded it at run time, because every services graph carries a policy and the key commands decide no request. The policy now comes from PermissionPolicy::granting_nothing() in core, built from the types rather than parsed from text, so no policy document is compiled into any binary. It denies every permission at the top of the tree, which every place resolves to, names the non-regulated jurisdiction, and carries the digest of empty text, since it has no file. The constructor is about the size of the YAML and the loading function it replaces. The services builder still requires a policy, and the test that the policy grants nothing moves to core, where it also checks that a signal granting everything sets nothing. --- .../trusted-server-cli/src/commands/keys.rs | 39 ++---------------- crates/trusted-server-core/src/permissions.rs | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/keys.rs b/crates/trusted-server-cli/src/commands/keys.rs index bdde95544a..9c3dc465aa 100644 --- a/crates/trusted-server-cli/src/commands/keys.rs +++ b/crates/trusted-server-cli/src/commands/keys.rs @@ -15,9 +15,7 @@ use std::io::Write as _; use std::sync::{Arc, OnceLock}; use error_stack::Report; -use trusted_server_core::permissions::{ - PermissionPolicy, PolicyReference, load_policy, policy_sha256, -}; +use trusted_server_core::permissions::PermissionPolicy; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformSecretStore, RuntimeServices, StoreId, StoreName, @@ -37,15 +35,6 @@ const API_NOT_FOUND: &str = "the Fastly API returned 404"; /// them would be written over, or would take the entry with it when deleted. const BOOKKEEPING_ENTRIES: [&str; 2] = ["current-kid", "active-kids"]; -/// The permission policy the commands' services carry, as every services -/// graph carries one. The commands read and write the signing stores and -/// decide no request's permissions, so the policy grants nothing. -const KEY_COMMANDS_POLICY: &str = - "groups:\n none:\n default: denied\nrules:\n group: none\n jurisdiction: non-regulated\n"; - -/// The name the commands' policy is loaded under. -const KEY_COMMANDS_POLICY_NAME: &str = "key-commands"; - /// Request signing key commands. #[derive(Debug, clap::Subcommand)] pub(crate) enum KeysCommand { @@ -307,22 +296,12 @@ fn services(runner: Arc, stores: &StoreArgs) -> RuntimeService .http_client(Arc::new(UnavailableHttpClient)) .geo(Arc::new(DisabledGeo)) .client_info(ClientInfo::default()) - .permission_policy(key_commands_policy()) + // 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() } -/// The policy that grants nothing, which the commands' services carry. -fn key_commands_policy() -> Arc { - load_policy( - PolicyReference { - policy: KEY_COMMANDS_POLICY_NAME.to_owned(), - sha256: policy_sha256(KEY_COMMANDS_POLICY), - }, - KEY_COMMANDS_POLICY, - ) - .expect("the key commands' policy should load") -} - /// 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) { @@ -653,16 +632,6 @@ mod tests { fake } - #[test] - fn the_key_commands_policy_grants_nothing() { - let policy = key_commands_policy(); - - assert!( - policy.maps().baseline(None, None).permissions().is_empty(), - "commands that decide no request should carry a policy that grants nothing" - ); - } - #[test] fn rotate_stores_a_new_key_and_makes_it_current() { let fake = Arc::new(FakeFastly::with_config(&[ diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index d0b7fc6ba6..0bb412252f 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 @@ -2806,4 +2828,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("")); + } }