diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index cd409d53a4..8ed805c155 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -21,6 +21,11 @@ use trusted_server_core::ec::admin::{ 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}; +use trusted_server_core::inspect::permissions::{ + PERMISSIONS_JSON_PATH, PERMISSIONS_PAGE_PATH, handle_permissions, +}; use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput, }; @@ -465,6 +470,9 @@ fn fallback_handler( enum NamedRouteHandler { TrustedServerDiscovery, VerifySignature, + Permissions, + Config, + Data, AdminNotSupported, CachePurgeNotSupported, AdminEcNotSupported, @@ -496,7 +504,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 17] { +fn named_routes() -> [NamedRoute; 22] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -508,6 +516,35 @@ fn named_routes() -> [NamedRoute; 17] { primary_methods: &[Method::POST], handler: NamedRouteHandler::VerifySignature, }, + // What the deployment decided for the asking request, shown to anyone. + NamedRoute { + path: PERMISSIONS_PAGE_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Permissions, + }, + NamedRoute { + path: PERMISSIONS_JSON_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Permissions, + }, + // The settings the deployment is running, masked, shown to anyone. + NamedRoute { + path: CONFIG_PAGE_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Config, + }, + NamedRoute { + path: CONFIG_JSON_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Config, + }, + // What is held against the request's own Edge Cookie. This adapter + // keeps no identity graph, so the page says nothing is held. + NamedRoute { + path: DATA_PAGE_PATH, + 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. @@ -629,6 +666,11 @@ fn named_route_handler( NamedRouteHandler::VerifySignature => { handle_verify_signature(&state.settings, &services, req) } + NamedRouteHandler::Permissions => { + handle_permissions(&state.settings, &services, &req).await + } + 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 diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index acd83dff35..043b4d3d12 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -126,6 +126,11 @@ fn all_explicit_routes_are_registered() { let expected: &[(&str, &str)] = &[ ("GET", "/.well-known/trusted-server.json"), ("POST", "/verify-signature"), + ("GET", "/_ts/permissions"), + ("GET", "/_ts/permissions.json"), + ("GET", "/_ts/config"), + ("GET", "/_ts/config.json"), + ("GET", "/_ts/data"), ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), ("GET", "/_ts/admin/ec"), @@ -669,6 +674,128 @@ async fn discovery_endpoint_does_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 [ + ("/_ts/permissions.json", "application/json"), + ("/_ts/permissions", "text/html; charset=utf-8"), + ] { + let mut svc = make_service(); + let req = Request::builder() + .method("GET") + .uri(path) + .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, + "{path} should answer with no credential" + ); + assert_eq!( + resp.headers()["content-type"], + content_type, + "{path} should answer in its own form" + ); + assert_eq!( + resp.headers()["cache-control"], + "no-store", + "{path} is one request's own answer" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_endpoint_answers_anyone_and_masks_secrets() { + for (path, content_type) in [ + ("/_ts/config.json", "application/json"), + ("/_ts/config", "text/html; charset=utf-8"), + ] { + let mut svc = make_service(); + let req = Request::builder() + .method("GET") + .uri(path) + .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, + "{path} should answer with no credential" + ); + assert_eq!( + resp.headers()["content-type"], + content_type, + "{path} should answer in its own form" + ); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("should read the body"); + let text = String::from_utf8_lossy(&body); + assert!( + text.contains("XXXX"), + "{path} should mask what it does not show" + ); + assert!( + !text.contains("admin-pass"), + "{path} should not carry the admin password" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn data_endpoint_answers_a_browser_opening_the_page_and_nothing_else() { + for (opened_as_a_page, expected) in [(false, 403), (true, 200)] { + let mut svc = make_service(); + let mut builder = Request::builder().method("GET").uri("/_ts/data"); + if opened_as_a_page { + builder = builder + .header("sec-fetch-mode", "navigate") + .header("sec-fetch-dest", "document"); + } + let req = builder + .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(), + expected, + "opened as a page: {opened_as_a_page}" + ); + assert_eq!( + resp.headers()["cache-control"], + "no-store, private", + "should never be stored" + ); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("should read the body"); + assert_eq!( + String::from_utf8_lossy(&body).contains("keeps no record"), + opened_as_a_page, + "should say nothing is held to a browser opening the page, and to nothing else" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auction_endpoint_does_not_require_auth() { let mut svc = make_service(); diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 290e212ca1..dfee0ec5b6 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -25,6 +25,9 @@ use trusted_server_core::ec::admin::{ 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}; +use trusted_server_core::inspect::permissions::{PERMISSIONS_PATHS, handle_permissions}; use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput, }; @@ -886,6 +889,32 @@ fn build_router(state: &Arc) -> RouterService { router = router.route(path, Method::OPTIONS, page_bids_preflight.clone()); } + // What the deployment decided for the asking request, shown to anyone + // as a page and as data. Read only, so nothing is written for the + // reader. + let permissions = make_handler(Arc::clone(&state), |s, services, req| async move { + handle_permissions(&s.settings, &services, &req).await + }); + for path in PERMISSIONS_PATHS { + router = router.route(path, Method::GET, permissions.clone()); + } + + // The settings the deployment is running, masked, shown to anyone as + // a page and as data. + let config = make_handler(Arc::clone(&state), |s, _services, req| async move { + Ok(handle_config(&s.settings, &req)) + }); + for path in CONFIG_PATHS { + router = router.route(path, Method::GET, config.clone()); + } + + // What is held against the request's own Edge Cookie. This adapter + // keeps no identity graph, so the page says nothing is held. + let data = make_handler(Arc::clone(&state), |_s, _services, req| async move { + handle_data(None, None, &req) + }); + 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()) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index a90bbbf940..bd40340da0 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -269,6 +269,11 @@ fn all_explicit_routes_are_registered() { let expected: &[(&str, &str)] = &[ ("GET", "/.well-known/trusted-server.json"), ("POST", "/verify-signature"), + ("GET", "/_ts/permissions"), + ("GET", "/_ts/permissions.json"), + ("GET", "/_ts/config"), + ("GET", "/_ts/config.json"), + ("GET", "/_ts/data"), ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), ("GET", "/_ts/admin/ec"), @@ -574,6 +579,103 @@ async fn discovery_endpoint_does_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 [ + ("/_ts/permissions.json", "application/json"), + ("/_ts/permissions", "text/html; charset=utf-8"), + ] { + let req = request_builder() + .method("GET") + .uri(path) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + assert_eq!( + resp.status().as_u16(), + 200, + "{path} should answer with no credential" + ); + assert_eq!( + resp.headers()["content-type"], + content_type, + "{path} should answer in its own form" + ); + assert_eq!( + resp.headers()["cache-control"], + "no-store", + "{path} is one request's own answer" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_endpoint_answers_anyone_and_masks_secrets() { + for (path, content_type) in [ + ("/_ts/config.json", "application/json"), + ("/_ts/config", "text/html; charset=utf-8"), + ] { + let req = request_builder() + .method("GET") + .uri(path) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + assert_eq!( + resp.status().as_u16(), + 200, + "{path} should answer with no credential" + ); + assert_eq!( + resp.headers()["content-type"], + content_type, + "{path} should answer in its own form" + ); + let body = resp.into_body().into_bytes().unwrap_or_default(); + let text = String::from_utf8_lossy(&body); + assert!( + text.contains("XXXX"), + "{path} should mask what it does not show" + ); + assert!( + !text.contains("admin-pass"), + "{path} should not carry the admin password" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn data_endpoint_answers_a_browser_opening_the_page_and_nothing_else() { + for (opened_as_a_page, expected) in [(false, 403), (true, 200)] { + let mut builder = request_builder().method("GET").uri("/_ts/data"); + if opened_as_a_page { + builder = builder + .header("sec-fetch-mode", "navigate") + .header("sec-fetch-dest", "document"); + } + let req = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + assert_eq!( + resp.status().as_u16(), + expected, + "opened as a page: {opened_as_a_page}" + ); + assert_eq!( + resp.headers()["cache-control"], + "no-store, private", + "should never be stored" + ); + let body = resp.into_body().into_bytes().unwrap_or_default(); + assert_eq!( + String::from_utf8_lossy(&body).contains("keeps no record"), + opened_as_a_page, + "should say nothing is held to a browser opening the page, and to nothing else" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auction_endpoint_does_not_require_auth() { let router = test_router(); diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index f4b250dbf2..ae1d5728ee 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -29,6 +29,9 @@ //! | GET | `/_ts/api/v1/identify` | [`handle_identify`] | //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | //! | GET | `/_ts/clear-tester` | [`handle_clear_tester`] | +//! | GET | `/_ts/permissions` and `/_ts/permissions.json` | [`handle_permissions`] | +//! | GET | `/_ts/config` and `/_ts/config.json` | [`handle_config`] | +//! | GET | `/_ts/data` | [`handle_data`] | //! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] | //! | POST | `/_ts/api/v1/ec/resolve` | [`handle_ec_resolve`] | //! | POST | `/auction` | [`handle_auction`] | @@ -121,6 +124,11 @@ use trusted_server_core::ec::resolve::handle_ec_resolve; use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; +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}; +use trusted_server_core::inspect::permissions::{ + PERMISSIONS_JSON_PATH, PERMISSIONS_PAGE_PATH, handle_permissions, +}; use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput, RequestFilterEffects, RequestFilterRegistryInput, RequestFilterRegistryOutcome, @@ -783,6 +791,11 @@ async fn execute_named( ctx: RequestContext, handler: NamedRouteHandler, ) -> Result { + // Read only, answered before the Edge Cookie lifecycle so nothing is written. + if matches!(handler, NamedRouteHandler::Config) { + return Ok(handle_config(&state.settings, &ctx.into_request())); + } + let services = build_per_request_services(&state, &ctx); let mut req = ctx.into_request(); @@ -836,6 +849,23 @@ async fn execute_named( return Ok(response); } + // Read only, answered before the Edge Cookie lifecycle so nothing is written. + if matches!(handler, NamedRouteHandler::Permissions) { + let response = handle_permissions(&state.settings, &services, &req) + .await + .unwrap_or_else(|error| http_error(&error)); + return Ok(response); + } + + // Read only, answered before the Edge Cookie lifecycle so nothing is written. + if matches!(handler, NamedRouteHandler::Data) { + let kv = crate::maybe_identity_graph(&state.settings); + let response = request_module(&state.settings.ec, &services) + .and_then(|module| handle_data(kv.as_ref(), module.as_deref(), &req)) + .unwrap_or_else(|error| http_error(&error)); + return Ok(response); + } + if let Err(report) = state.registry.prepare_request(&state.settings, &mut req) { return Ok(http_error(&report)); } @@ -917,6 +947,9 @@ async fn run_named_route( } NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), + NamedRouteHandler::Permissions | NamedRouteHandler::Config | NamedRouteHandler::Data => { + unreachable!("the inspection endpoints are answered before EC setup") + } NamedRouteHandler::EcResolve => { // The resolve endpoint persists the identity-graph row before // creating, so it takes the same bot-gated graph as generation: an @@ -1357,6 +1390,9 @@ enum NamedRouteHandler { Identify, SetTester, ClearTester, + Permissions, + Config, + Data, EcResolve, Auction, PageBids, @@ -1472,6 +1508,35 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::GET], handler: NamedRouteHandler::ClearTester, }, + // What the deployment decided for the asking request, shown to anyone. + NamedRoute { + path: PERMISSIONS_PAGE_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Permissions, + }, + NamedRoute { + path: PERMISSIONS_JSON_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Permissions, + }, + // The settings the deployment is running, masked, shown to anyone. + NamedRoute { + path: CONFIG_PAGE_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Config, + }, + NamedRoute { + path: CONFIG_JSON_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Config, + }, + // What is held against the request's own Edge Cookie, shown to a browser + // opening the address as a page. + NamedRoute { + path: DATA_PAGE_PATH, + primary_methods: &[Method::GET], + handler: NamedRouteHandler::Data, + }, NamedRoute { path: "/_ts/api/v1/ec/resolve", primary_methods: &[Method::POST], @@ -2575,6 +2640,123 @@ mod tests { ); } + #[test] + fn dispatch_permissions_answers_as_data_and_as_a_page_and_writes_nothing() { + let router = test_router(); + + let data = route(&router, empty_request(Method::GET, "/_ts/permissions.json")); + assert_eq!(data.status(), StatusCode::OK, "the data form should answer"); + assert_eq!(data.headers()[header::CONTENT_TYPE], "application/json"); + assert_eq!(data.headers()[header::CACHE_CONTROL], "no-store"); + assert!( + data.headers().get(header::SET_COOKIE).is_none(), + "should write nothing for the reader" + ); + assert!( + data.extensions().get::().is_none(), + "should not run the Edge Cookie lifecycle for a read" + ); + let payload: serde_json::Value = + serde_json::from_slice(&data.into_body().into_bytes().unwrap_or_default()) + .expect("the data form should be JSON"); + for key in ["set", "awaiting", "signals", "tdls", "modules"] { + assert!( + payload.get(key).is_some(), + "the resolution should carry `{key}`" + ); + } + + let page = route(&router, empty_request(Method::GET, "/_ts/permissions")); + assert_eq!(page.status(), StatusCode::OK, "the page should answer"); + assert_eq!( + page.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + } + + #[test] + fn dispatch_config_answers_the_masked_settings_as_data_and_as_a_page() { + let router = test_router(); + + let data = route(&router, empty_request(Method::GET, "/_ts/config.json")); + assert_eq!(data.status(), StatusCode::OK, "the data form should answer"); + assert_eq!(data.headers()[header::CONTENT_TYPE], "application/json"); + assert_eq!(data.headers()[header::CACHE_CONTROL], "no-store"); + assert!( + data.extensions().get::().is_none(), + "should not run the Edge Cookie lifecycle for a read" + ); + let body = data.into_body().into_bytes().unwrap_or_default(); + let payload: serde_json::Value = + serde_json::from_slice(&body).expect("the data form should be JSON"); + assert_eq!( + payload["settings"]["publisher"]["domain"], "test-publisher.com", + "should show an ordinary setting" + ); + assert_eq!( + payload["settings"]["publisher"]["proxy_secret"], "XXXX", + "should mask a secret" + ); + assert!( + !String::from_utf8_lossy(&body).contains("unit-test-proxy-secret"), + "should not carry the secret anywhere" + ); + + let page = route(&router, empty_request(Method::GET, "/_ts/config")); + assert_eq!(page.status(), StatusCode::OK, "the page should answer"); + assert_eq!( + page.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + } + + #[test] + fn dispatch_data_answers_a_browser_opening_the_page_and_nothing_else() { + let router = test_router(); + + let refused = route(&router, empty_request(Method::GET, "/_ts/data")); + assert_eq!( + refused.status(), + StatusCode::FORBIDDEN, + "should refuse a request that is not a browser opening the page" + ); + assert!( + refused + .extensions() + .get::() + .is_none(), + "should not run the Edge Cookie lifecycle for a refusal" + ); + + let mut opened = empty_request(Method::GET, "/_ts/data"); + opened + .headers_mut() + .insert("sec-fetch-mode", HeaderValue::from_static("navigate")); + opened + .headers_mut() + .insert("sec-fetch-dest", HeaderValue::from_static("document")); + let page = route(&router, opened); + assert_eq!(page.status(), StatusCode::OK, "the page should answer"); + assert_eq!( + page.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + assert_eq!(page.headers()[header::CACHE_CONTROL], "no-store, private"); + assert!( + page.headers().get(header::SET_COOKIE).is_none(), + "should write nothing for the reader" + ); + assert!( + page.extensions().get::().is_none(), + "should not run the Edge Cookie lifecycle for a read" + ); + let body = page.into_body().into_bytes().unwrap_or_default(); + assert!( + String::from_utf8_lossy(&body).contains("keeps no record"), + "should say a deployment with no identity graph keeps no record" + ); + } + #[test] fn dispatch_clear_tester_is_disabled_by_default() { let router = test_router(); diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 2206ea1a15..f5d07e438b 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -439,6 +439,40 @@ mod tests { response } + #[test] + fn the_data_page_keeps_its_sandbox_under_an_operator_content_security_policy() { + let settings = settings_with_response_headers(vec![ + ("Content-Security-Policy", "default-src 'self'"), + ("X-Frame-Options", "SAMEORIGIN"), + ]); + let request = request_builder() + .method(Method::GET) + .uri("https://publisher.example/_ts/data") + .body(Body::empty()) + .expect("should build request"); + let mut response = trusted_server_core::inspect::data::handle_data(None, None, &request) + .expect("should answer"); + + apply_finalize_headers(&settings, None, &mut response); + + let policy = response + .headers() + .get("content-security-policy") + .and_then(|v| v.to_str().ok()) + .expect("should carry a policy"); + assert!( + policy.starts_with("sandbox;"), + "an operator policy must not remove the sandbox: {policy}" + ); + assert_eq!( + response + .headers() + .get("x-frame-options") + .and_then(|v| v.to_str().ok()), + Some("DENY") + ); + } + #[test] fn apply_finalize_headers_downgrades_public_set_cookie_response() { // A per-user cookie response that arrives shared-cacheable (origin-public diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 6e704e8dc9..6d4a8b06d9 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -28,6 +28,11 @@ 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}; +use trusted_server_core::inspect::data::{DATA_PAGE_PATH, handle_data}; +use trusted_server_core::inspect::permissions::{ + PERMISSIONS_JSON_PATH, PERMISSIONS_PAGE_PATH, handle_permissions, +}; use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistry, ProxyDispatchInput, }; @@ -392,10 +397,15 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 17] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 22] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), + (PERMISSIONS_PAGE_PATH, &[Method::GET]), + (PERMISSIONS_JSON_PATH, &[Method::GET]), + (CONFIG_PAGE_PATH, &[Method::GET]), + (CONFIG_JSON_PATH, &[Method::GET]), + (DATA_PAGE_PATH, &[Method::GET]), ("/_ts/admin/keys/rotate", &[Method::POST]), ("/_ts/admin/keys/deactivate", &[Method::POST]), ("/_ts/admin/ec", &[Method::GET]), @@ -825,6 +835,39 @@ fn build_router(state: &Arc) -> RouterService { } }; + // /_ts/permissions and /_ts/permissions.json, what the deployment + // decided for the asking request, shown to anyone. Read only, so + // nothing is written for the reader. + let s = Arc::clone(&state); + let permissions_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let services = s.services_for_request(&ctx); + let req = ctx.into_request(); + Ok::( + handle_permissions(&s.settings, &services, &req) + .await + .unwrap_or_else(|e| http_error(&e)), + ) + } + }; + + // /_ts/config and /_ts/config.json, the settings the deployment is + // running, masked, shown to anyone. + let s = Arc::clone(&state); + let config_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { Ok::(handle_config(&s.settings, &ctx.into_request())) } + }; + + // /_ts/data, what is held against the request's own Edge Cookie. This + // adapter keeps no identity graph, so the page says nothing is held. + let data_handler = move |ctx: RequestContext| async move { + Ok::( + handle_data(None, None, &ctx.into_request()).unwrap_or_else(|e| http_error(&e)), + ) + }; + // /verify-signature let s = Arc::clone(&state); let verify_handler = move |ctx: RequestContext| { @@ -1118,6 +1161,11 @@ fn build_router(state: &Arc) -> RouterService { }) .get("/.well-known/trusted-server.json", discovery_handler) .post("/verify-signature", verify_handler) + .get(PERMISSIONS_PAGE_PATH, permissions_handler.clone()) + .get(PERMISSIONS_JSON_PATH, permissions_handler) + .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. diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 23d2e5b049..41715343ab 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -914,6 +914,103 @@ async fn admin_route_with_wrong_credentials_returns_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 [ + ("/_ts/permissions.json", "application/json"), + ("/_ts/permissions", "text/html; charset=utf-8"), + ] { + let req = request_builder() + .method("GET") + .uri(path) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + assert_eq!( + resp.status().as_u16(), + 200, + "{path} should answer with no credential" + ); + assert_eq!( + resp.headers()["content-type"], + content_type, + "{path} should answer in its own form" + ); + assert_eq!( + resp.headers()["cache-control"], + "no-store", + "{path} is one request's own answer" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_endpoint_answers_anyone_and_masks_secrets() { + for (path, content_type) in [ + ("/_ts/config.json", "application/json"), + ("/_ts/config", "text/html; charset=utf-8"), + ] { + let req = request_builder() + .method("GET") + .uri(path) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + assert_eq!( + resp.status().as_u16(), + 200, + "{path} should answer with no credential" + ); + assert_eq!( + resp.headers()["content-type"], + content_type, + "{path} should answer in its own form" + ); + let body = resp.into_body().into_bytes().unwrap_or_default(); + let text = String::from_utf8_lossy(&body); + assert!( + text.contains("XXXX"), + "{path} should mask what it does not show" + ); + assert!( + !text.contains("admin-pass"), + "{path} should not carry the admin password" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn data_endpoint_answers_a_browser_opening_the_page_and_nothing_else() { + for (opened_as_a_page, expected) in [(false, 403), (true, 200)] { + let mut builder = request_builder().method("GET").uri("/_ts/data"); + if opened_as_a_page { + builder = builder + .header("sec-fetch-mode", "navigate") + .header("sec-fetch-dest", "document"); + } + let req = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + assert_eq!( + resp.status().as_u16(), + expected, + "opened as a page: {opened_as_a_page}" + ); + assert_eq!( + resp.headers()["cache-control"], + "no-store, private", + "should never be stored" + ); + let body = resp.into_body().into_bytes().unwrap_or_default(); + assert_eq!( + String::from_utf8_lossy(&body).contains("keeps no record"), + opened_as_a_page, + "should say nothing is held to a browser opening the page, and to nothing else" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn discovery_endpoint_does_not_require_auth() { let router = test_router(); diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index 0bb93bb084..9a7f5ab019 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -97,7 +97,10 @@ pub struct AuctionConfig { pub timeout_ms: u32, /// KV store name for creative storage (deprecated: creatives are now delivered inline) - #[serde(default = "default_creative_store")] + #[serde( + default = "default_creative_store", + serialize_with = "crate::redacted::sensitive" + )] pub creative_store: String, /// Keys allowed in the auction request context map. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index a80051eab9..8dfbaaf794 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -370,6 +370,7 @@ pub fn validate_settings_for_deploy_with( validate_integration_blocks(settings, &plan, extra_integrations)?; PartnerRegistry::validate_config_for_deploy(&settings.ec.partners)?; settings.ec.validate_resolve_allowed_origins()?; + crate::inspect::config::validate_patterns_for_deploy(settings, extra_integrations)?; Ok(()) } diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 2661230248..13b9654335 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -108,14 +108,17 @@ pub fn settings_from_config_blob_with( let mut data = envelope.into_data(); remove_inactive_secret_references(&mut data); crate::module_secrets::clear_unused(&mut data, &builders); - resolve_secret_references_with::( + let resolved = resolve_secret_references_with::( &mut data, secret_store, default_secret_store_name, crate::module_secrets::secret_fields(&builders), )?; let mut settings = Settings::from_json_value(data)?; + // The configuration view masks every leaf a secret was written into. + settings.set_resolved_secrets(resolved); crate::config::validate_settings_for_runtime_with(&settings, extra_integrations)?; + crate::inspect::config::validate_patterns(&settings)?; let policy = load_domain_policy(&mut settings.permissions, policy_origin)?; log_permission_baseline(&policy); settings.attach_permission_policy(policy); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 583bf836ed..25b223ea41 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -236,6 +236,18 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { None } +/// The Edge Cookie identifier `req` carries in its `ts-ec` cookie, or `None` +/// when it carries none, or one holding a character no identifier has. +/// +/// # Errors +/// +/// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails +pub(crate) fn request_cookie_ec( + req: &Request, +) -> Result, Report> { + Ok(parse_ec_from_request(req)?.cookie_ec) +} + /// Captures the EC state for a single request lifecycle. /// /// Created via [`read_from_request`](Self::read_from_request) during diff --git a/crates/trusted-server-core/src/inspect.rs b/crates/trusted-server-core/src/inspect.rs new file mode 100644 index 0000000000..702f117c30 --- /dev/null +++ b/crates/trusted-server-core/src/inspect.rs @@ -0,0 +1,145 @@ +//! What a deployment shows to anyone who asks, with no credential. +//! +//! A publisher can point a reader, a regulator or an auditor at these +//! addresses, and each sees what this deployment decided for their own +//! request without having to take anybody's word for it. Every address is +//! fixed under `/_ts`, so it is the same on every deployment. +//! +//! | Address | What it shows | +//! | --- | --- | +//! | [`permissions::PERMISSIONS_PAGE_PATH`] | The permissions resolved for the request, the signals that produced them and the terms the data is held under | +//! | [`config::CONFIG_PAGE_PATH`] | The settings the deployment is running, with every secret and every value sensitive by default masked | +//! | [`data::DATA_PAGE_PATH`] | What the deployment holds against the Edge Cookie the request carries, with every identifier masked | +//! +//! The first two answer as a page, and as data with `.json` added. The third +//! is about one reader, so it answers a browser opening it as a page and +//! nothing else. +//! +//! Operator data is not here. It is in the platform's own stores, reached +//! with the platform's own authentication, and never through the publisher's +//! domain. + +use serde_json::Value; + +pub mod config; +pub mod data; +pub mod permissions; + +/// What a masked value shows as. +pub const MASK: &str = "XXXX"; + +const DATA_FORM_LEDE: &str = + "Add .json to this address for the same information as data."; + +/// Renders `payload` as a page titled `title`. +#[must_use] +pub fn render_page(title: &str, payload: &Value) -> String { + page(title, DATA_FORM_LEDE, payload) +} + +/// Renders `payload` as [`render_page`] does, under `lede` in place of the +/// line saying where the data form is, for an address that has none. +#[must_use] +pub fn render_page_with_lede(title: &str, lede: &str, payload: &Value) -> String { + page(title, &html_escape(lede), payload) +} + +/// The page, with `lede` written into it as markup. +fn page(title: &str, lede: &str, payload: &Value) -> String { + let pretty = serde_json::to_string_pretty(payload).unwrap_or_else(|_| "{}".to_owned()); + format!( + "\n\ +\n\ +\n\ +\n\ +\n\ +\n\ +{title}\n\ +\n\ +\n\ +\n\ +
\n\ +

{title}

\n\ +

{lede}

\n\ +
{body}
\n\ +
\n\ +\n\ +\n", + title = html_escape(title), + body = html_escape(&pretty), + ) +} + +/// Escapes `value` for HTML text content. +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn a_page_escapes_whatever_its_payload_and_title_carry() { + let html = render_page( + "A ", + &json!({ "value": "</pre><script>x()</script> & more" }), + ); + + assert!( + !html.contains("<script>x()"), + "should not let a value open a tag" + ); + assert!( + html.contains("</pre><script>x()</script> & more"), + "should show the value as text" + ); + assert!( + html.contains("<title>A <title>"), + "should escape the title as well" + ); + } + + #[test] + fn a_page_tells_the_reader_where_the_data_form_is() { + let html = render_page("Permissions", &json!({})); + + assert!( + html.contains(".json"), + "should say how to get the same information as data" + ); + assert!( + html.contains(""), + "should keep the page out of search indexes" + ); + } + + #[test] + fn a_page_with_a_lede_of_its_own_says_that_instead() { + let html = render_page_with_lede("Your data", "Held for & no other", &json!({})); + + assert!( + html.contains("

Held for <you> & no other

"), + "should show the lede as text" + ); + assert!( + !html.contains(".json"), + "should not point at a data form the address does not have" + ); + } +} diff --git a/crates/trusted-server-core/src/inspect/config.rs b/crates/trusted-server-core/src/inspect/config.rs new file mode 100644 index 0000000000..d7ce367734 --- /dev/null +++ b/crates/trusted-server-core/src/inspect/config.rs @@ -0,0 +1,779 @@ +//! The configuration a deployment is running, served to anyone at +//! [`CONFIG_PAGE_PATH`] and [`CONFIG_JSON_PATH`]. +//! +//! What is shown is the settings the handler holds, after defaults have been +//! applied, with values masked as [`MASK`]: +//! +//! 1. Every secret, meaning a value the loader filled in from the secret +//! store or a leaf declared as one, whatever the publisher's document says. +//! 2. Every value sensitive by default, being a settings field serialized +//! with [`crate::redacted::sensitive`] and every +//! [`Redacted`](crate::redacted::Redacted) value, unless `[inspect] show` +//! names it. +//! 3. Everything `[inspect] hide` names. + +use std::collections::HashSet; +use std::fmt::{self, Write as _}; + +use edgezero_core::app_config::{AppConfigMeta as _, SecretPathSegment}; +use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; +use http::{HeaderValue, Request, Response, StatusCode, header}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Map, Value, json}; + +use crate::config::TrustedServerAppConfig; +use crate::error::TrustedServerError; +use crate::integrations::IntegrationBuilder; +use crate::redacted::{SENSITIVE_MARKER, to_marked_value}; +use crate::secret_resolution::unresolved_secrets; +use crate::settings::Settings; + +use super::{MASK, render_page}; + +/// The page form of the configuration. +pub const CONFIG_PAGE_PATH: &str = "/_ts/config"; + +/// The JSON form of the configuration. +pub const CONFIG_JSON_PATH: &str = "/_ts/config.json"; + +/// Both addresses, the page first. +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`"; + +/// The settings table that names the domain's permission policy. +const PERMISSIONS_TABLE: &str = "permissions"; + +/// The policy file's SHA-256, which the push writes into every blob. +const POLICY_SHA256_KEY: &str = "sha256"; + +/// The policy file itself, which the push writes into the blob on the +/// adapters that read it from there, and which the runtime loader takes out of +/// the settings when it loads the policy. +const POLICY_TEXT_KEY: &str = "text"; + +/// What the pattern checks see in place of a SHA-256 the push has not written +/// yet, so a pattern naming it is judged as it is once the push has run. +const SHA256_BEFORE_THE_PUSH: &str = + "0000000000000000000000000000000000000000000000000000000000000000"; + +/// The `[inspect]` section, which says what the configuration page shows. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InspectConfig { + /// Whether the configuration is published. `false` answers not found. + #[serde(default = "default_config")] + pub config: bool, + /// Values masked by default that are shown instead. A secret cannot be. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub show: Vec, + /// Values masked as well as the defaults. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hide: Vec, +} + +fn default_config() -> bool { + true +} + +impl Default for InspectConfig { + fn default() -> Self { + Self { + config: default_config(), + show: Vec::new(), + hide: Vec::new(), + } + } +} + +impl InspectConfig { + /// Whether the section says nothing the defaults do not. + #[must_use] + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + +/// One step of a concrete path through the view. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum PathStep { + /// A key of an object. + Key(String), + /// An index into a list. + Index(usize), +} + +/// A concrete path written the way a pattern is, such as `handlers[0].path`. +#[must_use] +pub fn render_path(steps: &[PathStep]) -> String { + let mut out = String::new(); + for step in steps { + match step { + PathStep::Key(key) => { + if !out.is_empty() { + out.push('.'); + } + out.push_str(key); + } + PathStep::Index(index) => { + let _ = write!(out, "[{index}]"); + } + } + } + out +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PatternStep { + Key(String), + Each, + Index(usize), +} + +/// 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`. +/// +/// A pattern names the values at exactly its own depth. A key holding `.`, +/// `[` or `]` cannot be named. +#[derive(Clone, PartialEq, Eq)] +pub struct PathPattern { + text: String, + steps: Vec, +} + +impl PathPattern { + /// Reads a pattern. + /// + /// # Errors + /// + /// A message naming `text` and what is wrong with it. + pub fn parse(text: &str) -> Result { + let malformed = + |why: &str| format!("`{text}` is not a path pattern, because {why}. {PATTERN_FORM}"); + let mut steps = Vec::new(); + let mut rest = text; + loop { + let end = rest.find(['.', '[', ']']).unwrap_or(rest.len()); + let (key, after_key) = rest.split_at(end); + if key.is_empty() { + return Err(malformed("a key is empty")); + } + if key.chars().any(|c| c.is_whitespace() || c.is_control()) { + return Err(malformed("a key holds a space or a control character")); + } + steps.push(PatternStep::Key(key.to_owned())); + rest = after_key; + while let Some(open) = rest.strip_prefix('[') { + let Some((inside, after)) = open.split_once(']') else { + return Err(malformed("a `[` is not closed")); + }; + let step = if inside.is_empty() { + PatternStep::Each + } else if inside.bytes().all(|b| b.is_ascii_digit()) { + PatternStep::Index( + inside + .parse() + .map_err(|_| malformed("an index is too large"))?, + ) + } else { + return Err(malformed("only a number may sit between `[` and `]`")); + }; + steps.push(step); + rest = after; + } + if rest.is_empty() { + break; + } + match rest.strip_prefix('.') { + Some(after) => rest = after, + None if rest.starts_with(']') => return Err(malformed("a `]` has no `[`")), + None => return Err(malformed("a `]` is followed by neither `.` nor `[`")), + } + } + Ok(Self { + text: text.to_owned(), + steps, + }) + } + + /// The pattern a secret or sensitive declaration names, or `None` for a + /// segment kind a pattern cannot express. + #[must_use] + pub fn from_segments(segments: &[SecretPathSegment]) -> Option { + let mut steps = Vec::with_capacity(segments.len()); + let mut text = String::new(); + for segment in segments { + match segment { + SecretPathSegment::Field(name) | SecretPathSegment::OptionalField(name) => { + if !text.is_empty() { + text.push('.'); + } + text.push_str(name); + steps.push(PatternStep::Key(name.to_string())); + } + SecretPathSegment::ArrayEach => { + text.push_str("[]"); + steps.push(PatternStep::Each); + } + _ => return None, + } + } + Some(Self { text, steps }) + } + + /// The pattern as written. + #[must_use] + pub fn as_str(&self) -> &str { + &self.text + } + + /// Whether the pattern names the policy file in `permissions.text`, or + /// anything beneath it. + fn names_policy_text(&self) -> bool { + matches!( + self.steps.as_slice(), + [PatternStep::Key(table), PatternStep::Key(key), ..] + if table == PERMISSIONS_TABLE && key == POLICY_TEXT_KEY + ) + } + + /// Whether the pattern is `permissions.text` itself. + fn is_policy_text(&self) -> bool { + self.names_policy_text() && self.steps.len() == 2 + } + + /// Whether the concrete path `path` is one the pattern names. + #[must_use] + pub fn matches(&self, path: &[PathStep]) -> bool { + self.steps.len() == path.len() + && self + .steps + .iter() + .zip(path) + .all(|(pattern, step)| match (pattern, step) { + (PatternStep::Key(want), PathStep::Key(got)) => want == got, + (PatternStep::Each, PathStep::Index(_)) => true, + (PatternStep::Index(want), PathStep::Index(got)) => want == got, + _ => false, + }) + } +} + +impl fmt::Debug for PathPattern { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.text) + } +} + +impl fmt::Display for PathPattern { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.text) + } +} + +impl Serialize for PathPattern { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.text) + } +} + +impl<'de> Deserialize<'de> for PathPattern { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let text = String::deserialize(deserializer)?; + Self::parse(&text).map_err(serde::de::Error::custom) + } +} + +/// The configuration as the endpoint shows it. +#[derive(Debug, Clone, PartialEq)] +pub struct ConfigView { + /// Every masked path, concrete and sorted. + pub masked: Vec, + /// The settings, each masked value replaced by [`MASK`] and every + /// object's keys sorted. + pub settings: Value, +} + +/// Builds the view of `settings`. +/// +/// # Errors +/// +/// When the settings cannot be represented as JSON. +pub fn build_view(settings: &Settings) -> Result> { + let marked = marked_settings(settings)?; + Ok(Rules::new(settings).render(&marked)) +} + +fn marked_settings(settings: &Settings) -> Result> { + // The serializer's error is left out, because nothing may carry a value + // out of the settings. + to_marked_value(settings).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: "the settings could not be represented for the configuration view".to_owned(), + }) + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum List { + Show, + Hide, +} + +impl fmt::Display for List { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Show => "show", + Self::Hide => "hide", + }) + } +} + +/// Everything that decides whether a value is masked. +#[derive(Clone)] +struct Rules<'a> { + secret_paths: &'a [Vec], + secret_patterns: Vec, + secret_values: &'a [String], + show: Vec<&'a PathPattern>, + hide: Vec<&'a PathPattern>, +} + +impl<'a> Rules<'a> { + fn new(settings: &'a Settings) -> Self { + let secrets = settings.resolved_secrets(); + // Core's own declarations are added here as well as recorded by the + // loader, so settings built any other way still mask them. + let secret_patterns = secrets + .patterns() + .iter() + .cloned() + .chain( + TrustedServerAppConfig::secret_fields() + .iter() + .filter_map(|field| PathPattern::from_segments(&field.path)), + ) + .collect(); + Self { + secret_paths: secrets.paths(), + secret_patterns, + secret_values: secrets.values(), + show: settings.inspect.show.iter().collect(), + hide: settings.inspect.hide.iter().collect(), + } + } + + /// These rules less one of the publisher's patterns. + fn without(&self, list: List, index: usize) -> Self { + let mut rules = self.clone(); + let _removed = match list { + List::Show => rules.show.remove(index), + List::Hide => rules.hide.remove(index), + }; + rules + } + + fn is_secret(&self, path: &[PathStep]) -> bool { + self.secret_paths.iter().any(|secret| secret == path) + || self + .secret_patterns + .iter() + .any(|pattern| pattern.matches(path)) + } + + fn render(&self, marked: &Value) -> ConfigView { + let mut masked = Vec::new(); + let mut path = Vec::new(); + let mut settings = self.mask(marked, &mut path, &mut masked); + self.scan(&mut settings, &mut path, &mut masked); + masked.sort(); + masked.dedup(); + ConfigView { + masked: masked.iter().map(|steps| render_path(steps)).collect(), + settings, + } + } + + /// `node` with every sensitive marker resolved, every masked value + /// replaced and every object's keys sorted. + fn mask( + &self, + node: &Value, + path: &mut Vec, + masked: &mut Vec>, + ) -> Value { + let (inner, marked_sensitive) = match unmark(node) { + Some(inner) => (inner, true), + None => (node, false), + }; + if is_empty(inner) { + return inner.clone(); + } + let shown = self.show.iter().any(|p| p.matches(path)); + if self.is_secret(path) + || self.hide.iter().any(|p| p.matches(path)) + || (marked_sensitive && !shown) + { + masked.push(path.clone()); + return Value::String(MASK.to_owned()); + } + match inner { + Value::Object(map) => { + let mut entries: Vec<(&String, &Value)> = map.iter().collect(); + entries.sort_unstable_by(|left, right| left.0.cmp(right.0)); + let mut out = Map::new(); + for (key, value) in entries { + path.push(PathStep::Key(key.clone())); + out.insert(key.clone(), self.mask(value, path, masked)); + path.pop(); + } + Value::Object(out) + } + Value::Array(items) => { + let mut out = Vec::with_capacity(items.len()); + for (index, item) in items.iter().enumerate() { + path.push(PathStep::Index(index)); + out.push(self.mask(item, path, masked)); + path.pop(); + } + Value::Array(out) + } + other => other.clone(), + } + } + + /// Masks every string, and every key, that contains a secret value, + /// wherever it sits. A key holding a secret is renamed [`MASK`], or + /// `XXXX-2` and on where that name is taken, and every path already + /// recorded beneath it is replaced by the renamed one, so no recorded path + /// carries the secret. + fn scan(&self, node: &mut Value, path: &mut Vec, masked: &mut Vec>) { + match node { + Value::String(text) => { + if self.holds_secret(text) { + masked.push(path.clone()); + *node = Value::String(MASK.to_owned()); + } + } + Value::Array(items) => { + for (index, item) in items.iter_mut().enumerate() { + path.push(PathStep::Index(index)); + self.scan(item, path, masked); + path.pop(); + } + } + Value::Object(map) => { + let entries = std::mem::take(map); + let kept: HashSet = entries + .keys() + .filter(|key| !self.holds_secret(key)) + .cloned() + .collect(); + let mut sorted = std::collections::BTreeMap::new(); + for (key, mut value) in entries { + if self.holds_secret(&key) { + let mut renamed = MASK.to_owned(); + let mut copy = 1; + while kept.contains(&renamed) || sorted.contains_key(&renamed) { + copy += 1; + renamed = format!("{MASK}-{copy}"); + } + path.push(PathStep::Key(key)); + masked.retain(|recorded| !recorded.starts_with(path)); + path.pop(); + path.push(PathStep::Key(renamed.clone())); + masked.push(path.clone()); + path.pop(); + sorted.insert(renamed, Value::String(MASK.to_owned())); + continue; + } + path.push(PathStep::Key(key.clone())); + self.scan(&mut value, path, masked); + path.pop(); + sorted.insert(key, value); + } + map.extend(sorted); + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } + } + + fn holds_secret(&self, text: &str) -> bool { + self.secret_values + .iter() + .any(|secret| !secret.is_empty() && text.contains(secret.as_str())) + } +} + +/// The value a sensitive marker wraps, or `None` when `node` is not one. +fn unmark(node: &Value) -> Option<&Value> { + match node { + Value::Object(map) if map.len() == 1 => map.get(SENSITIVE_MARKER), + _ => None, + } +} + +/// Whether there is nothing in `value` to hide. +fn is_empty(value: &Value) -> bool { + match value { + Value::Null => true, + Value::String(text) => text.is_empty(), + Value::Array(items) => items.is_empty(), + Value::Object(map) => map.is_empty(), + Value::Bool(_) | Value::Number(_) => false, + } +} + +/// The concrete paths of the values in `marked` that `pattern` names. +fn select(marked: &Value, pattern: &PathPattern) -> Vec> { + fn walk( + node: &Value, + steps: &[PatternStep], + path: &mut Vec, + out: &mut Vec>, + ) { + let node = unmark(node).unwrap_or(node); + let Some((step, rest)) = steps.split_first() else { + out.push(path.clone()); + return; + }; + match (step, node) { + (PatternStep::Key(key), Value::Object(map)) => { + if let Some(child) = map.get(key) { + path.push(PathStep::Key(key.clone())); + walk(child, rest, path, out); + path.pop(); + } + } + (PatternStep::Each, Value::Array(items)) => { + for (index, item) in items.iter().enumerate() { + path.push(PathStep::Index(index)); + walk(item, rest, path, out); + path.pop(); + } + } + (PatternStep::Index(index), Value::Array(items)) => { + if let Some(item) = items.get(*index) { + path.push(PathStep::Index(*index)); + walk(item, rest, path, out); + path.pop(); + } + } + _ => {} + } + } + let mut out = Vec::new(); + walk(marked, &pattern.steps, &mut Vec::new(), &mut out); + out +} + +/// Refuses an `[inspect]` pattern that would not be honored as written, +/// being one that matches nothing, a `show` naming a secret, one another +/// pattern of its list already covers, a `show` a `hide` covers, one that +/// changes nothing the view shows, one under `permissions.text`, which the +/// view never shows, and any pattern beside `config = false`. Costs nothing +/// when the section has no patterns. +/// +/// The settings are checked as the view sees a domain the push wrote, with +/// `permissions.sha256` present and `permissions.text` gone, so a pattern is +/// judged alike before the push, by the push and when the settings load. +/// +/// # Errors +/// +/// Naming the first pattern at fault. +pub(crate) fn validate_patterns(settings: &Settings) -> Result<(), Report> { + let inspect = &settings.inspect; + if inspect.show.is_empty() && inspect.hide.is_empty() { + return Ok(()); + } + if !inspect.config { + return Err(configuration( + "[inspect] show and hide say what the configuration page shows, and config = false \ + publishes no page, so they change nothing. Remove them, or publish the page" + .to_owned(), + )); + } + for (list, patterns) in [(List::Show, &inspect.show), (List::Hide, &inspect.hide)] { + if let Some(pattern) = patterns.iter().find(|pattern| pattern.names_policy_text()) { + let under = if pattern.is_policy_text() { + "" + } else { + ", which is under `permissions.text`" + }; + return Err(configuration(format!( + "[inspect] {list} names `{pattern}`{under}, the policy file the push writes. The \ + configuration page never shows the file, so the pattern changes nothing" + ))); + } + } + let mut marked = marked_settings(settings)?; + as_the_view_sees_a_pushed_domain(&mut marked); + let rules = Rules::new(settings); + let full = rules.render(&marked); + for (list, patterns) in [(List::Show, &inspect.show), (List::Hide, &inspect.hide)] { + for (index, pattern) in patterns.iter().enumerate() { + let selected = select(&marked, pattern); + if selected.is_empty() { + return Err(configuration(format!( + "[inspect] {list} names `{pattern}`, which matches no value in this \ + configuration, so it changes nothing" + ))); + } + if list == List::Show && selected.iter().any(|path| rules.is_secret(path)) { + return Err(configuration(format!( + "[inspect] show names `{pattern}`, which is a secret. A secret is always \ + masked, so it cannot be shown" + ))); + } + let covers = |other: &PathPattern| { + let covered = select(&marked, other); + selected.iter().all(|path| covered.contains(path)) + }; + if let Some(other) = patterns + .iter() + .enumerate() + .find(|(other_index, other)| *other_index != index && covers(other)) + .map(|(_, other)| other) + { + return Err(configuration(format!( + "[inspect] {list} names `{pattern}`, which changes nothing, because \ + `{other}` in the same list already names everything it does" + ))); + } + if list == List::Show + && let Some(hide) = inspect.hide.iter().find(|hide| covers(hide)) + { + return Err(configuration(format!( + "[inspect] show names `{pattern}`, which changes nothing, because hide \ + names it as `{hide}` and hide wins over show" + ))); + } + if rules.without(list, index).render(&marked) == full { + let reason = match list { + List::Show => "nothing it names is masked by default", + List::Hide => "everything it names is masked or empty already", + }; + return Err(configuration(format!( + "[inspect] {list} names `{pattern}`, which changes nothing, because {reason}" + ))); + } + } + } + Ok(()) +} + +/// `marked` with `permissions.sha256` present and `permissions.text` gone, +/// as the view shows a domain the push wrote and the runtime loader loaded. +fn as_the_view_sees_a_pushed_domain(marked: &mut Value) { + let Value::Object(settings) = marked else { + return; + }; + let permissions = settings + .entry(PERMISSIONS_TABLE) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(permissions) = permissions { + permissions.remove(POLICY_TEXT_KEY); + permissions + .entry(POLICY_SHA256_KEY) + .or_insert_with(|| Value::String(SHA256_BEFORE_THE_PUSH.to_owned())); + } +} + +/// [`validate_patterns`] for a deployment's settings before they are pushed, +/// when their secret fields still hold key names. The view is handed every +/// secret leaf the load will look for, the ones only the configuration and +/// `extra_integrations` name included, so a pattern the load refuses is +/// refused here. +/// +/// # Errors +/// +/// As [`validate_patterns`]. +pub(crate) fn validate_patterns_for_deploy( + settings: &Settings, + extra_integrations: &[IntegrationBuilder], +) -> Result<(), Report> { + if settings.inspect.show.is_empty() && settings.inspect.hide.is_empty() { + return Ok(()); + } + // The serializer's error is left out, as in `marked_settings`. + let data = serde_json::to_value(settings).map_err(|_| { + configuration("the settings could not be represented for the configuration view".to_owned()) + })?; + let builders = crate::integrations::all_builders(extra_integrations).collect::>(); + let mut settings = settings.clone(); + settings.set_resolved_secrets(unresolved_secrets::( + &data, + crate::module_secrets::secret_fields(&builders), + )); + validate_patterns(&settings) +} + +fn configuration(message: String) -> Report { + Report::new(TrustedServerError::Configuration { message }) +} + +/// Answers [`CONFIG_PAGE_PATH`] with the page and [`CONFIG_JSON_PATH`] with +/// the JSON, or not found when the publisher does not publish its +/// configuration. +#[must_use] +pub fn handle_config(settings: &Settings, req: &Request) -> Response { + let json = req.uri().path() == CONFIG_JSON_PATH; + if !settings.inspect.config { + let payload = json!({ "error": NOT_PUBLISHED }); + return answer(StatusCode::NOT_FOUND, json, &payload); + } + match build_view(settings) { + Ok(view) => answer(StatusCode::OK, json, &view_payload(&view)), + Err(_) => { + log::error!("the configuration view could not be built"); + let payload = json!({ "error": "The configuration could not be shown." }); + answer(StatusCode::INTERNAL_SERVER_ERROR, json, &payload) + } + } +} + +/// What the endpoint serves for `view`, with the version of the core crate +/// that answered. +#[must_use] +pub fn view_payload(view: &ConfigView) -> Value { + json!({ + "version": env!("CARGO_PKG_VERSION"), + "masked": view.masked, + "settings": view.settings, + }) +} + +fn answer(status: StatusCode, json: bool, payload: &Value) -> Response { + let (content_type, body) = if json { + ("application/json", payload.to_string()) + } else { + ( + "text/html; charset=utf-8", + render_page("Configuration", payload), + ) + }; + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)) + .header(header::CACHE_CONTROL, HeaderValue::from_static("no-store")) + .header( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ) + .body(EdgeBody::from(body.into_bytes())) + .expect("should build configuration response") +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-core/src/inspect/config/tests.rs b/crates/trusted-server-core/src/inspect/config/tests.rs new file mode 100644 index 0000000000..6b61962640 --- /dev/null +++ b/crates/trusted-server-core/src/inspect/config/tests.rs @@ -0,0 +1,1056 @@ +use std::collections::BTreeSet; + +use edgezero_core::blob_envelope::BlobEnvelope; + +use super::*; +use crate::config_payload::settings_from_config_blob_with; +use crate::integrations::{IntegrationBuilder, ModuleSecretSetting}; +use crate::permissions::{SAMPLE_POLICY_NAME, SAMPLE_POLICY_TEXT, policy_sha256}; +use crate::platform::{PlatformError, PlatformSecretStore, PolicyOrigin, StoreId, StoreName}; +use crate::redacted::Redacted; +use crate::secret_resolution::ResolvedSecrets; +use crate::settings::{ProxyAssetRoute, RequestSigning}; +use crate::test_support::tests::{crate_test_settings_str, sample_permissions}; + +/// Every value this store hands back starts with this, so a view can be +/// searched for all of them at once. +const CANARY: &str = "canary-"; + +/// Resolves every key name to a value of its own, long enough for any +/// minimum length a secret has. +struct CanaryStore; + +impl PlatformSecretStore for CanaryStore { + fn get_bytes( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result, Report> { + Ok(format!("{CANARY}{key}-value-0123456789abcdef").into_bytes()) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } +} + +fn settings() -> Settings { + Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings") +} + +/// The test settings with `extra` appended, as a stored document holds +/// them, so every secret in them is the name of one. +fn document(extra: &str) -> Value { + document_from(&format!("{}\n{extra}\n", crate_test_settings_str())) +} + +/// `toml` as a stored document holds it, with the allowed domains a load +/// needs for the test settings' asset hosts and the permission policy the +/// push writes. +fn document_from(toml: &str) -> Value { + let mut settings = Settings::from_toml(toml).expect("should parse the document"); + settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; + settings.permissions = sample_permissions(); + serde_json::to_value(&settings).expect("should serialize the document") +} + +fn load_with( + data: Value, + builders: &[IntegrationBuilder], +) -> Result> { + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); + let envelope = serde_json::to_string(&envelope).expect("should serialize the envelope"); + settings_from_config_blob_with( + &envelope, + &CanaryStore, + &StoreName::from("ts_secrets"), + PolicyOrigin::Blob, + builders, + ) +} + +fn load(data: Value) -> Result> { + load_with(data, &[]) +} + +fn view(settings: &Settings) -> ConfigView { + build_view(settings).expect("should build the view") +} + +fn request(uri: &str) -> Request { + Request::builder() + .uri(uri) + .body(EdgeBody::empty()) + .expect("should build a request") +} + +fn body_text(response: Response) -> String { + let bytes = response + .into_body() + .into_bytes() + .expect("a buffered body") + .to_vec(); + String::from_utf8(bytes).expect("a text body") +} + +/// The JSON and the page the endpoint serves for `settings`. +fn served(settings: &Settings) -> (String, String) { + ( + body_text(handle_config(settings, &request(CONFIG_JSON_PATH))), + body_text(handle_config(settings, &request(CONFIG_PAGE_PATH))), + ) +} + +/// The value at a concrete path, such as `handlers[0].path`. +fn at<'v>(value: &'v Value, path: &str) -> Option<&'v Value> { + let pattern = PathPattern::parse(path).expect("a concrete path"); + let mut node = value; + for step in &pattern.steps { + node = match (step, node) { + (PatternStep::Key(key), Value::Object(map)) => map.get(key)?, + (PatternStep::Index(index), Value::Array(items)) => items.get(*index)?, + _ => return None, + }; + } + Some(node) +} + +fn is_masked(view: &ConfigView, path: &str) -> bool { + view.masked.iter().any(|masked| masked == path) +} + +/// `value` with every sensitive marker replaced by what it wraps. +fn unmarked(value: &Value) -> Value { + if let Some(inner) = unmark(value) { + return unmarked(inner); + } + match value { + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, value)| (key.clone(), unmarked(value))) + .collect(), + ), + Value::Array(items) => Value::Array(items.iter().map(unmarked).collect()), + other => other.clone(), + } +} + +#[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 route = |index| { + vec![ + PathStep::Key("proxy".to_owned()), + PathStep::Key("asset_routes".to_owned()), + PathStep::Index(index), + 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!( + !every.matches(&route(0)[..3]), + "should name only values at its own depth" + ); + assert_eq!(render_path(&handler(1)), "handlers[1].path"); +} + +#[test] +fn a_malformed_pattern_is_refused_when_the_document_is_read() { + for pattern in [ + "", + "a..b", + ".a", + "a.", + "a[x]", + "a[", + "a]", + "[0]", + "a b", + "a[0]b", + "a[99999999999999999999999]", + ] { + let toml = format!( + "{}\n[inspect]\nhide = [{pattern:?}]\n", + crate_test_settings_str() + ); + + let error = Settings::from_toml(&toml).expect_err("a malformed pattern is refused"); + + assert!( + format!("{error:?}").contains(&format!("`{pattern}` is not a path pattern")), + "should name the pattern {pattern:?}: {error:?}" + ); + } +} + +#[test] +fn a_key_the_inspect_section_does_not_have_is_refused_naming_it() { + let toml = format!("{}\n[inspect]\nshows = []\n", crate_test_settings_str()); + + let error = Settings::from_toml(&toml).expect_err("a misspelt key is refused"); + + assert!( + format!("{error:?}").contains("unknown field `shows`"), + "should name the key: {error:?}" + ); +} + +/// Documents that between them hold a secret at every path core declares +/// one, each a key name the canary store resolves to a value of its own. +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", + "api_token": "partner-api", + "pull_sync_enabled": true, + "pull_sync_url": "https://partner.example.com/sync", + "pull_sync_allowed_domains": ["partner.example.com"], + "ts_pull_token": "partner-pull", + }]); + main["trusted_client_ip"] = json!({ + "ip_header": "x-ts-client-ip", + "auth_header": "x-ts-client-ip-auth", + "shared_secret": "client-ip", + }); + main["proxy"]["asset_routes"] = json!([{ + "prefix": "/assets/", + "origin_url": "https://assets.example.com", + "auth": { + "type": "s3_sigv4", + "region": "us-east-1", + "access_key_id": "route-access", + "secret_access_key": "route-secret", + "session_token": "route-session", + }, + }]); + main["tinybird"] = json!({ + "enabled": true, + "api_host": "api.tinybird.example.com", + "auction_token_secret": "telemetry", + }); + + let mut host_signals = document(""); + let ec = host_signals["ec"].as_object_mut().expect("an ec table"); + ec.remove("hmac"); + ec.insert("module".to_owned(), json!("host_signals")); + ec.insert("host_signals".to_owned(), json!({ "passphrase": "host" })); + + let mut legacy = document(""); + let ec = legacy["ec"].as_object_mut().expect("an ec table"); + ec.remove("hmac"); + ec.remove("module"); + ec.insert("passphrase".to_owned(), json!("legacy")); + + let mut labeled = document(""); + let ec = labeled["ec"].as_object_mut().expect("an ec table"); + ec.remove("hmac"); + ec.insert("module".to_owned(), json!("primary")); + ec.insert( + "primary".to_owned(), + json!({ "implementation": "hmac", "passphrase": "labeled" }), + ); + + vec![ + ("main", main), + ("host signals", host_signals), + ("legacy", legacy), + ("labeled", labeled), + ] +} + +/// A concrete path with every index written as `[]`, as a declaration +/// writes it. +fn generalized(path: &[PathStep]) -> String { + let mut out = String::new(); + for step in path { + match step { + PathStep::Key(key) => { + if !out.is_empty() { + out.push('.'); + } + out.push_str(key); + } + PathStep::Index(_) => out.push_str("[]"), + } + } + out +} + +/// Every secret core declares, loaded the way a deployment loads it, is +/// live in the settings and absent from both forms the endpoint serves, +/// and wherever the view still has its path, that path is masked. +#[test] +fn every_declared_core_secret_is_masked_and_never_served() { + let mut covered = BTreeSet::new(); + for (name, data) in documents_holding_every_declared_secret() { + let settings = load(data).unwrap_or_else(|error| panic!("{name}: {error:?}")); + let record = settings.resolved_secrets(); + let normal = serde_json::to_string(&settings).expect("should serialize"); + assert!( + !record.values().is_empty(), + "{name}: the load should record the secrets it wrote" + ); + for value in record.values() { + assert!( + normal.contains(value.as_str()), + "{name}: each resolved secret should be live in the settings" + ); + } + let view = view(&settings); + // A failure lists the paths that are not masked, never the value + // the view holds at them. + let mut unmasked = Vec::new(); + let mut unlisted = Vec::new(); + for path in record.paths() { + let rendered = render_path(path); + if let Some(value) = at(&view.settings, &rendered) { + if value != MASK { + unmasked.push(rendered.clone()); + } + if !is_masked(&view, &rendered) { + unlisted.push(rendered); + } + } + covered.insert(generalized(path)); + } + assert_eq!( + unmasked, + Vec::::new(), + "{name}: the view should hold the mask at every secret's path" + ); + assert_eq!( + unlisted, + Vec::::new(), + "{name}: the view should list every secret's path as masked" + ); + let (json, page) = served(&settings); + assert!(!json.contains(CANARY), "{name}: the JSON carries a secret"); + assert!(!page.contains(CANARY), "{name}: the page carries a secret"); + } + + let declared: BTreeSet = TrustedServerAppConfig::secret_fields() + .iter() + .filter_map(|field| PathPattern::from_segments(&field.path)) + .map(|pattern| pattern.as_str().to_owned()) + .collect(); + let missing: Vec<&String> = declared.difference(&covered).collect(); + assert!( + missing.is_empty(), + "every declared secret should be exercised, missing {missing:?}" + ); +} + +/// Test settings holding a value of its own in every field marked +/// sensitive, with those paths and values. +fn settings_with_every_sensitive_field() -> (Settings, Vec<(&'static str, &'static str)>) { + let expected = vec![ + ("publisher.origin_url", "https://origin-canary.example.com"), + ( + "publisher.origin_host_header_override", + "host-canary.example.com", + ), + ( + "proxy.asset_routes[0].origin_url", + "https://bucket-canary.example.com", + ), + ("handlers[0].path", "^/path-canary"), + ("handlers[0].username", "username-canary"), + ("ec.ec_store", "identity-store-canary"), + ("request_signing.config_store_id", "config-store-canary"), + ("request_signing.secret_store_id", "secret-store-canary"), + ("auction.creative_store", "creative-store-canary"), + ]; + let value = |path: &str| { + expected + .iter() + .find(|(at, _)| *at == path) + .map(|(_, value)| (*value).to_owned()) + .expect("an expected path") + }; + let mut settings = settings(); + settings.publisher.origin_url = value("publisher.origin_url"); + settings.publisher.origin_host_header_override = + Some(value("publisher.origin_host_header_override")); + settings.proxy.asset_routes = vec![ProxyAssetRoute::new( + "/assets/", + value("proxy.asset_routes[0].origin_url"), + )]; + settings.handlers[0].path = value("handlers[0].path"); + settings.handlers[0].username = Redacted::new(value("handlers[0].username")); + settings.ec.ec_store = Some(value("ec.ec_store")); + settings.request_signing = Some(RequestSigning { + enabled: false, + config_store_id: value("request_signing.config_store_id"), + secret_store_id: value("request_signing.secret_store_id"), + }); + settings.auction.creative_store = value("auction.creative_store"); + (settings, expected) +} + +#[test] +fn every_value_sensitive_by_default_is_masked() { + let (settings, expected) = settings_with_every_sensitive_field(); + + let view = view(&settings); + let (json, page) = served(&settings); + + for (path, value) in expected { + assert_eq!(at(&view.settings, path), Some(&json!(MASK)), "{path}"); + assert!(is_masked(&view, path), "{path}"); + assert!(!json.contains(value), "the JSON carries {path}"); + assert!(!page.contains(value), "the page carries {path}"); + } + assert!( + is_masked(&view, "handlers[0].password"), + "a Redacted value is masked too: {:?}", + view.masked + ); +} + +#[test] +fn an_ordinary_serialization_is_unchanged() { + let (settings, expected) = settings_with_every_sensitive_field(); + + let normal = serde_json::to_value(&settings).expect("should serialize"); + let marked = to_marked_value(&settings).expect("should serialize"); + + assert_eq!( + normal, + unmarked(&marked), + "should differ from a marked serialization only by the markers" + ); + assert!(!normal.to_string().contains(SENSITIVE_MARKER)); + for (path, value) in expected { + assert_eq!(at(&normal, path), Some(&json!(value)), "{path}"); + } + assert_eq!(at(&normal, "handlers[0].password"), Some(&json!("pass"))); +} + +#[test] +fn a_stored_document_carries_the_section_only_when_the_publisher_wrote_one() { + let unwritten = serde_json::to_value(settings()).expect("should serialize"); + let written = document("[inspect]\nhide = [\"publisher.domain\"]"); + + assert_eq!( + unwritten.get("inspect"), + None, + "should leave an unwritten section out, so the document is the bytes it was" + ); + assert_eq!( + written.get("inspect"), + Some(&json!({ "config": true, "hide": ["publisher.domain"] })), + "should store a section the publisher wrote" + ); + assert!( + !unwritten.to_string().contains("resolved_secrets"), + "should never store where the loader wrote secrets" + ); +} + +#[test] +fn a_recorded_secret_path_is_masked_without_the_value_scan() { + let mut settings = settings(); + settings.set_resolved_secrets(ResolvedSecrets::recorded( + vec![vec![ + PathStep::Key("publisher".to_owned()), + PathStep::Key("domain".to_owned()), + ]], + Vec::new(), + )); + + let view = view(&settings); + + assert_eq!(at(&view.settings, "publisher.domain"), Some(&json!(MASK))); + assert!(is_masked(&view, "publisher.domain")); +} + +#[test] +fn a_secret_copied_into_an_undeclared_field_is_masked_by_the_value_scan() { + let secret = "canary-copied-secret-value"; + let mut settings = settings(); + settings + .response_headers + .insert("x-debug".to_owned(), format!("prefix {secret} suffix")); + settings + .response_headers + .insert(format!("x-{secret}"), "open".to_owned()); + settings.set_resolved_secrets(ResolvedSecrets::recorded( + Vec::new(), + vec![secret.to_owned()], + )); + + let view = view(&settings); + let (json, page) = served(&settings); + + assert_eq!( + at(&view.settings, "response_headers.x-debug"), + Some(&json!(MASK)) + ); + assert!(is_masked(&view, "response_headers.x-debug")); + assert!( + is_masked(&view, "response_headers.XXXX"), + "a key holding a secret is masked too: {:?}", + view.masked + ); + assert!(!json.contains(secret) && !page.contains(secret)); +} + +#[test] +fn a_path_masked_beneath_a_key_holding_a_secret_lists_the_renamed_key() { + struct Case { + name: &'static str, + real_mask_key: bool, + renamed: [&'static str; 2], + } + let secret = "canary-key-secret-value"; + // Upper case names sort ahead of a real `XXXX` key, so they are + // renamed before it is reached. + let keys = [format!("A-{secret}"), format!("B-{secret}")]; + let path_to = |key: &str| { + vec![ + PathStep::Key("response_headers".to_owned()), + PathStep::Key(key.to_owned()), + ] + }; + for case in [ + Case { + name: "two keys holding the secret", + real_mask_key: false, + renamed: ["response_headers.XXXX", "response_headers.XXXX-2"], + }, + Case { + name: "beside a real key named XXXX", + real_mask_key: true, + renamed: ["response_headers.XXXX-2", "response_headers.XXXX-3"], + }, + ] { + let mut settings = settings(); + for key in &keys { + settings + .response_headers + .insert(key.clone(), "open".to_owned()); + } + if case.real_mask_key { + settings + .response_headers + .insert(MASK.to_owned(), "real".to_owned()); + } + settings.set_resolved_secrets(ResolvedSecrets::recorded( + keys.iter().map(|key| path_to(key)).collect(), + vec![secret.to_owned()], + )); + + let view = view(&settings); + let (json, page) = served(&settings); + + assert!( + !view.masked.iter().any(|path| path.contains(secret)), + "{}: no masked path should carry the secret: {:?}", + case.name, + view.masked + ); + for renamed in case.renamed { + assert!( + is_masked(&view, renamed), + "{}: should list {renamed}: {:?}", + case.name, + view.masked + ); + assert_eq!( + at(&view.settings, renamed), + Some(&json!(MASK)), + "{}", + case.name + ); + } + if case.real_mask_key { + assert_eq!( + at(&view.settings, "response_headers.XXXX"), + Some(&json!("real")), + "{}: should keep the real key's value", + case.name + ); + assert!( + !is_masked(&view, "response_headers.XXXX"), + "{}: should not list the real key as masked: {:?}", + case.name, + view.masked + ); + } + assert!( + !json.contains(secret) && !page.contains(secret), + "{}: should publish no secret", + case.name + ); + } +} + +#[test] +fn show_reveals_a_value_masked_by_default() { + let settings = load(document( + "[inspect]\nshow = [\"publisher.origin_url\", \"handlers[].path\"]", + )) + .expect("should load"); + + let view = view(&settings); + + assert_eq!( + at(&view.settings, "publisher.origin_url"), + Some(&json!("https://origin.test-publisher.com")) + ); + assert_eq!( + at(&view.settings, "handlers[1].path"), + Some(&json!("^/_ts/admin")) + ); + assert!(!view.masked.iter().any(|path| path.ends_with(".path"))); + assert!(!is_masked(&view, "publisher.origin_url")); +} + +#[test] +fn hide_masks_a_value_shown_by_default() { + let settings = load(document("[inspect]\nhide = [\"publisher.domain\"]")).expect("should load"); + + let view = view(&settings); + + assert_eq!(at(&view.settings, "publisher.domain"), Some(&json!(MASK))); + assert!(is_masked(&view, "publisher.domain")); +} + +/// A pattern that would not be honored as written is refused when the +/// document loads. Each row's fragment carries the whole reason, so a +/// pattern refused for another reason fails its row. +#[test] +fn a_pattern_not_honored_as_written_is_refused_saying_why() { + for (section, reason) in [ + ( + r#"show = ["publisher.proxy_secret"]"#, + "show names `publisher.proxy_secret`, which is a secret", + ), + ( + r#"show = ["handlers[].password"]"#, + "show names `handlers[].password`, 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 = ["publisher.domain"]"#, + "show names `publisher.domain`, which changes nothing, because nothing \ + it names is masked by default", + ), + ( + r#"hide = ["publisher.origin_url"]"#, + "hide names `publisher.origin_url`, which changes nothing, because \ + everything it names is masked or empty already", + ), + ( + r#"hide = ["publisher.proxy_secret"]"#, + "hide names `publisher.proxy_secret`, which changes nothing, because \ + everything it names is masked or empty already", + ), + ( + r#"show = ["publisher.origin_url", "publisher.origin_url"]"#, + "show names `publisher.origin_url`, which changes nothing, because \ + `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", + ), + ( + "show = [\"publisher.origin_url\"]\nhide = [\"publisher.origin_url\"]", + "show names `publisher.origin_url`, which changes nothing, because hide \ + names it as `publisher.origin_url` and hide wins over show", + ), + ( + "config = false\nhide = [\"publisher.domain\"]", + "config = false publishes no page, so they change nothing", + ), + ] { + let message = format!( + "{:?}", + load(document(&format!("[inspect]\n{section}"))) + .expect_err("the document should be refused") + ); + + assert!(message.contains(reason), "{section}: {message}"); + } +} + +/// The check a deployment's settings pass before they are pushed refuses +/// the same patterns, so a bad one is caught before it is live. +#[test] +fn the_deploy_check_refuses_a_pattern_that_matches_nothing() { + let toml = format!( + "{}\n[inspect]\nhide = [\"publisher.no_such_setting\"]\n", + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml).expect("should parse the document"); + + let message = format!( + "{:?}", + validate_patterns(&settings).expect_err("the pattern should be refused") + ); + + assert!( + message.contains("hide names `publisher.no_such_setting`, which matches no value"), + "{message}" + ); +} + +/// A secret only the configuration or a deployment's own module names is +/// one the load resolves, so the deploy check refuses showing it as the +/// load does. +#[test] +fn the_deploy_check_refuses_showing_a_secret_only_the_load_resolves() { + let builder = IntegrationBuilder::new( + "probe", + "example-crate", + crate::integrations::registry_test_support::probe_registration, + crate::integrations::registry_test_support::validate_nothing, + ) + .with_module_name("testing.probe") + .with_secret_settings(MODULE_SECRETS); + let labeled = crate::test_support::tests::crate_test_settings_str_with_ec_section( + "[ec] +module = \"primary\" + +[ec.primary] +implementation = \"hmac\" + passphrase = \"labeled-passphrase-key-0123456789\"", + ); + let mut labeled = Settings::from_toml(&format!( + "{labeled} +[inspect] +show = [\"ec.primary.passphrase\"] +" + )) + .expect("should parse the document"); + labeled.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; + let mut module = settings(); + module.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; + module + .insert_module_config( + "testing", + "testing.probe", + &json!({ "lock": true, "key_name": "module-key" }), + ) + .expect("should insert the module's table"); + module.inspect.show = vec![PathPattern::parse("testing.probe.key_name").expect("a pattern")]; + + for (settings, pattern) in [ + (labeled, "ec.primary.passphrase"), + (module, "testing.probe.key_name"), + ] { + let reason = format!("show names `{pattern}`, which is a secret"); + let data = serde_json::to_value(&settings).expect("should serialize the document"); + let loaded = format!( + "{:?}", + load_with(data, &[builder]).expect_err("the load should refuse the pattern") + ); + let deployed = format!( + "{:?}", + crate::config::validate_settings_for_deploy_with(&settings, &[builder]) + .expect_err("the deploy check should refuse the pattern") + ); + + assert!(loaded.contains(&reason), "load: {loaded}"); + assert!(deployed.contains(&reason), "deploy: {deployed}"); + } +} + +/// A loaded domain shows its policy file's name and SHA-256, which nothing +/// masks, so a publisher can check which exact file runs. The file itself is +/// never shown, because the loader takes it out of the settings. +#[test] +fn the_view_shows_the_policy_by_name_and_digest_and_never_its_file() { + let loaded = load(document("")).expect("should load the document"); + let shown = view(&loaded); + + assert_eq!( + at(&shown.settings, "permissions.policy"), + Some(&json!(SAMPLE_POLICY_NAME)) + ); + assert_eq!( + at(&shown.settings, "permissions.sha256"), + Some(&json!(policy_sha256(SAMPLE_POLICY_TEXT))) + ); + assert_eq!(at(&shown.settings, "permissions.text"), None); + assert!( + !shown + .masked + .iter() + .any(|path| path.starts_with("permissions")), + "neither the name nor the digest should be masked: {:?}", + shown.masked + ); + let (json, page) = served(&loaded); + for served in [json, page] { + assert!( + !served.contains("groups:"), + "the policy file should never be served" + ); + } +} + +/// The view never shows `permissions.text`, so a pattern naming it, or +/// anything beneath it, is refused by the deploy check and by the load alike. +#[test] +fn a_pattern_under_the_policy_file_is_refused() { + for (section, reason) in [ + ( + r#"show = ["permissions.text"]"#, + "show names `permissions.text`, the policy file the push writes", + ), + ( + r#"hide = ["permissions.text"]"#, + "hide names `permissions.text`, the policy file the push writes", + ), + ( + r#"hide = ["permissions.text[]"]"#, + "hide names `permissions.text[]`, which is under `permissions.text`, the policy \ + file the push writes", + ), + ] { + let toml = format!("{}\n[inspect]\n{section}\n", crate_test_settings_str()); + let mut settings = Settings::from_toml(&toml).expect("should parse the document"); + settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; + settings.permissions = sample_permissions(); + + let deployed = format!( + "{:?}", + crate::config::validate_settings_for_deploy_with(&settings, &[]) + .expect_err("the deploy check should refuse the pattern") + ); + let loaded = format!( + "{:?}", + load(document(&format!("[inspect]\n{section}"))) + .expect_err("the load should refuse the pattern") + ); + + assert!(deployed.contains(reason), "deploy: {deployed}"); + assert!(loaded.contains(reason), "load: {loaded}"); + } +} + +/// The push always writes `permissions.sha256`, so a pattern naming it is +/// judged before the push, which has not written it yet, as it is when the +/// pushed settings load. +#[test] +fn a_pattern_naming_the_policy_digest_is_judged_alike_before_and_after_the_push() { + let before_the_push = |section: &str| { + let toml = format!( + "{}\n[permissions]\npolicy = \"{SAMPLE_POLICY_NAME}\"\n\n[inspect]\n{section}\n", + crate_test_settings_str() + ); + let mut settings = Settings::from_toml(&toml).expect("should parse the document"); + settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; + crate::config::validate_settings_for_deploy_with(&settings, &[]) + }; + + let hide = r#"hide = ["permissions.sha256"]"#; + before_the_push(hide).expect("hiding the digest should be honored before the push"); + let loaded = load(document(&format!("[inspect]\n{hide}"))) + .expect("hiding the digest should be honored when the settings load"); + assert!(is_masked(&view(&loaded), "permissions.sha256")); + + let show = r#"show = ["permissions.sha256"]"#; + let reason = "show names `permissions.sha256`, which changes nothing, because nothing it \ + names is masked by default"; + let before = format!( + "{:?}", + before_the_push(show).expect_err("showing the digest changes nothing before the push") + ); + let after = format!( + "{:?}", + load(document(&format!("[inspect]\n{show}"))) + .expect_err("showing the digest changes nothing when the settings load") + ); + assert!(before.contains(reason), "before the push: {before}"); + assert!(after.contains(reason), "at the load: {after}"); +} + +#[test] +fn the_page_answers_not_found_when_the_publisher_does_not_publish() { + let settings = load(document("[inspect]\nconfig = false")).expect("should load"); + + for (path, content_type) in [ + (CONFIG_PAGE_PATH, "text/html; charset=utf-8"), + (CONFIG_JSON_PATH, "application/json"), + ] { + let response = handle_config(&settings, &request(path)); + + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + content_type, + "{path}" + ); + assert!(body_text(response).contains(NOT_PUBLISHED), "{path}"); + } +} + +#[test] +fn the_page_and_the_json_carry_the_view() { + let settings = load(document("")).expect("should load"); + + for (path, content_type) in [ + (CONFIG_PAGE_PATH, "text/html; charset=utf-8"), + (CONFIG_JSON_PATH, "application/json"), + ] { + let response = handle_config(&settings, &request(path)); + + assert_eq!(response.status(), StatusCode::OK, "{path}"); + assert_eq!(response.headers()[header::CONTENT_TYPE], content_type); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(response.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*"); + } + let json: Value = serde_json::from_str(&body_text(handle_config( + &settings, + &request(CONFIG_JSON_PATH), + ))) + .expect("a JSON body"); + let keys: Vec<&String> = json.as_object().expect("an object").keys().collect(); + assert_eq!(keys, ["masked", "settings", "version"]); + assert_eq!(json["version"], env!("CARGO_PKG_VERSION")); + assert_eq!( + json["settings"]["publisher"]["domain"], + "test-publisher.com" + ); + assert!( + json["masked"] + .as_array() + .expect("a list") + .contains(&json!("publisher.proxy_secret")), + "{json}" + ); +} + +fn assert_keys_sorted(value: &Value) { + match value { + Value::Object(map) => { + let keys: Vec<&String> = map.keys().collect(); + let mut sorted = keys.clone(); + sorted.sort_unstable(); + assert_eq!(keys, sorted, "keys should be sorted"); + map.values().for_each(assert_keys_sorted); + } + Value::Array(items) => items.iter().for_each(assert_keys_sorted), + _ => {} + } +} + +/// Two loads of one document build hash maps of their own, each iterating +/// in its own order, and the view has to come out the same. +#[test] +fn the_same_settings_give_the_same_view() { + let headers: String = (0..12) + .map(|index| format!("x-header-{index} = \"value-{index}\"\n")) + .collect(); + let data = document_from(&format!( + "{}\n[response_headers]\n{headers}", + crate_test_settings_str() + )); + + let first = load(data.clone()).expect("should load"); + let second = load(data).expect("should load"); + let first_json = view_payload(&view(&first)).to_string(); + let second_json = view_payload(&view(&second)).to_string(); + + assert_eq!(first_json, second_json); + let parsed: Value = serde_json::from_str(&first_json).expect("JSON"); + assert_keys_sorted(&parsed); + assert_eq!( + parsed["settings"]["response_headers"] + .as_object() + .expect("the headers") + .len(), + 12, + "should show every header" + ); + let masked = view(&first).masked; + let mut sorted_masked = masked.clone(); + sorted_masked.sort_unstable(); + assert_eq!(masked, sorted_masked); +} + +fn module_lock_is_on(table: &serde_json::Map) -> bool { + table.get("lock").and_then(Value::as_bool) == Some(true) +} + +const MODULE_SECRETS: &[ModuleSecretSetting] = &[ModuleSecretSetting { + path: &["key_name"], + in_use: module_lock_is_on, +}]; + +/// A secret a module a deployment added declares in its own table is one +/// the loader wrote, so it is masked like any of core's. +#[test] +fn a_secret_a_module_declares_is_masked() { + let builder = IntegrationBuilder::new( + "probe", + "example-crate", + crate::integrations::registry_test_support::probe_registration, + crate::integrations::registry_test_support::validate_nothing, + ) + .with_module_name("testing.probe") + .with_secret_settings(MODULE_SECRETS); + let mut settings = settings(); + settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; + settings.permissions = sample_permissions(); + settings + .insert_module_config( + "testing", + "testing.probe", + &json!({ "lock": true, "key_name": "module-key", "label": "open" }), + ) + .expect("should insert the module's table"); + let data = serde_json::to_value(&settings).expect("should serialize the document"); + + let loaded = load_with(data, &[builder]).expect("should load"); + let view = view(&loaded); + let (json, page) = served(&loaded); + + assert!( + is_masked(&view, "testing.probe.key_name"), + "should mask the module's secret: {:?}", + view.masked + ); + assert_eq!( + at(&view.settings, "testing.probe.label"), + Some(&json!("open")), + "should show the module's other settings" + ); + assert!(!json.contains(CANARY) && !page.contains(CANARY)); +} diff --git a/crates/trusted-server-core/src/inspect/data.rs b/crates/trusted-server-core/src/inspect/data.rs new file mode 100644 index 0000000000..fb85d15184 --- /dev/null +++ b/crates/trusted-server-core/src/inspect/data.rs @@ -0,0 +1,669 @@ +//! What a deployment holds against the reader's own Edge Cookie, at +//! [`DATA_PAGE_PATH`]. +//! +//! 1. Only a browser opening the address as a page is answered, which the +//! browser marks with `Sec-Fetch-Mode: navigate` and +//! `Sec-Fetch-Dest: document`. +//! 2. The page is sent with `Content-Security-Policy: sandbox`, which gives +//! the document an origin of its own, so a page that opened it cannot +//! read it. +//! 3. No identifier is shown. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; +use http::{HeaderValue, Request, Response, StatusCode, header}; +use serde_json::{Value, json}; + +use crate::ec::kv::KvIdentityGraph; +use crate::ec::kv_types::KvEntry; +use crate::ec::module::{AcceptedModules, EdgeCookieModule}; +use crate::error::TrustedServerError; +use crate::response_privacy::RequiredHeaders; + +use super::{MASK, render_page_with_lede}; + +/// The page showing what is held against the reader's own Edge Cookie. +pub const DATA_PAGE_PATH: &str = "/_ts/data"; + +const NOT_OPENED_AS_A_PAGE: &str = "This address shows a reader what is held against their own \ + browser, so it answers only a browser opening it as a page. \ + Open it from the address bar.\n"; + +/// `sandbox` gives the document an origin of its own, so a page that opened +/// it cannot read it, and nothing in it runs or loads but its own style. +const CONTENT_SECURITY_POLICY: &str = "sandbox; default-src 'none'; style-src 'unsafe-inline'; \ + frame-ancestors 'none'; base-uri 'none'; form-action 'none'"; + +/// Answers [`DATA_PAGE_PATH`] for `req`. +/// +/// `kv` is the identity graph the deployment keeps, or `None` when it keeps +/// none, and `module` is the Edge Cookie module it selects. The record is the +/// one stored against the identifier in the request's own `ts-ec` cookie. +/// Nothing is created or written for the reader. +/// +/// # Errors +/// +/// When the request's `Cookie` header cannot be read, or the store cannot be +/// read, or what it holds is not a record. +pub fn handle_data( + kv: Option<&KvIdentityGraph>, + module: Option<&dyn EdgeCookieModule>, + req: &Request, +) -> Result, Report> { + if !is_opened_as_a_page(req) { + return Ok(protected( + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + NOT_OPENED_AS_A_PAGE.to_owned(), + )); + } + let Some(kv) = kv else { + return Ok(page(¬hing_held( + "This deployment keeps no record against an Edge Cookie.", + ))); + }; + let Some(ec_id) = crate::ec::request_cookie_ec(req)? else { + return Ok(page(¬hing_held( + "The request carried no Edge Cookie, so there is nothing to look up.", + ))); + }; + let Some(kv_key) = AcceptedModules::active(module).canonical_kv_key(&ec_id) else { + return Ok(page(¬hing_held( + "The request's Edge Cookie is not one this deployment issued.", + ))); + }; + let Some((entry, _generation)) = kv.get(&kv_key)? else { + return Ok(page(¬hing_held( + "This deployment holds no record against the request's Edge Cookie. A record \ + made in the last few seconds may not be readable yet.", + ))); + }; + Ok(page(&held_payload(&entry))) +} + +/// Whether `req` is a browser opening the address as a page of its own. +/// +/// A browser sends `Sec-Fetch-*` only to a trustworthy origin, so every +/// browser on a plain `http://` host other than localhost fails this. +fn is_opened_as_a_page(req: &Request) -> bool { + let is = |name: &str, expected: &str| { + req.headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case(expected)) + }; + is("sec-fetch-mode", "navigate") && is("sec-fetch-dest", "document") +} + +fn nothing_held(why: &str) -> Value { + json!({ + "held": false, + "why": why, + "version": env!("CARGO_PKG_VERSION"), + }) +} + +/// What the page shows for `entry`, being the record as it is stored with +/// each partner's identifier masked, and the two times in it written out. +fn held_payload(entry: &KvEntry) -> Value { + let mut record = entry.clone(); + for id in record.ids.values_mut() { + MASK.clone_into(&mut id.uid); + } + json!({ + "held": true, + "identifier": MASK, + "created": readable_time(entry.created), + "consent_updated": readable_time(entry.consent.updated), + "withdrawn": !entry.consent.ok, + "record": record, + "version": env!("CARGO_PKG_VERSION"), + }) +} + +/// `unix_seconds` as RFC 3339 in UTC, or `None` beyond what a date can hold. +fn readable_time(unix_seconds: u64) -> Option { + let unix_seconds = i64::try_from(unix_seconds).ok()?; + chrono::DateTime::from_timestamp(unix_seconds, 0) + .map(|time| time.format("%Y-%m-%dT%H:%M:%SZ").to_string()) +} + +fn page(payload: &Value) -> Response { + let lede = format!( + "What this deployment holds against the Edge Cookie your browser sent. Each \ + identifier shows as {MASK}." + ); + protected( + StatusCode::OK, + "text/html; charset=utf-8", + render_page_with_lede("Your data", &lede, payload), + ) +} + +/// A response only the person looking at it can read, being never stored, +/// never framed, in an origin of its own, and offered to no other origin. +/// Its headers are [`RequiredHeaders`], so an operator's own headers of the +/// same names cannot replace them. +fn protected(status: StatusCode, content_type: &'static str, body: String) -> Response { + let mut response = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)) + .header( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, private"), + ) + .header( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(CONTENT_SECURITY_POLICY), + ) + .header(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")) + .header( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ) + .header( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ) + .header( + "cross-origin-resource-policy", + HeaderValue::from_static("same-origin"), + ) + .body(EdgeBody::from(body.into_bytes())) + .expect("should build the data response"); + let required = response.headers().clone(); + response.extensions_mut().insert(RequiredHeaders(required)); + response +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + + use super::*; + use crate::consent::ConsentContext; + use crate::ec::kv_types::{ + KvConsent, KvDevice, KvGeo, KvNetwork, KvPartnerId, KvPubProperties, + }; + use crate::ec::module::HmacModule; + use crate::redacted::Redacted; + + /// An identifier in the form the built-in HMAC module issues. + const EC_ID: &str = + "hmac~0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.aB3dE9"; + /// The part of [`EC_ID`] that no page may carry. + const EC_HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const PARTNER_UID: &str = "partner-uid-canary-1234"; + /// 14 March 2025 at midnight UTC. + const CREATED: u64 = 1_741_910_400; + + fn module() -> HmacModule { + HmacModule::new(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())) + } + + fn request(cookie: Option<&str>, mode: Option<&str>, dest: Option<&str>) -> Request { + let mut builder = Request::builder() + .method("GET") + .uri(format!("https://publisher.example{DATA_PAGE_PATH}")); + for (name, value) in [ + ("cookie", cookie), + ("sec-fetch-mode", mode), + ("sec-fetch-dest", dest), + ] { + if let Some(value) = value { + builder = builder.header(name, value); + } + } + builder + .body(EdgeBody::empty()) + .expect("should build the request") + } + + /// A request as a browser sends it when a person opens the address. + fn opened_as_a_page(cookie: Option<&str>) -> Request { + request(cookie, Some("navigate"), Some("document")) + } + + fn body_text(response: Response) -> String { + let bytes = response + .into_body() + .into_bytes() + .expect("a buffered body") + .to_vec(); + String::from_utf8(bytes).expect("a text body") + } + + /// A graph holding one record against [`EC_ID`], with one partner's + /// identifier in it. + fn graph_with_a_record() -> KvIdentityGraph { + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let module = module(); + let key = AcceptedModules::active(Some(&module)) + .canonical_kv_key(EC_ID) + .expect("the test identifier should be one the module issues"); + let mut entry = KvEntry::new( + &ConsentContext::default(), + None, + CREATED, + "publisher.example", + ); + entry.ids.insert( + "partner.example.com".to_owned(), + KvPartnerId { + uid: PARTNER_UID.to_owned(), + }, + ); + graph.create(&key, &entry).expect("should store the record"); + graph + } + + #[test] + fn only_a_browser_opening_the_page_is_answered() { + let graph = graph_with_a_record(); + let module = module(); + let cookie = format!("ts-ec={EC_ID}"); + + for (mode, dest, who) in [ + (None, None, "a tool that sends neither header"), + (Some("cors"), Some("empty"), "a script's fetch"), + (Some("same-origin"), Some("empty"), "a same-origin fetch"), + (Some("navigate"), Some("iframe"), "a frame"), + (Some("navigate"), Some("object"), "an embedded object"), + ( + Some("navigate"), + None, + "a navigation that names no destination", + ), + ( + Some("no-cors"), + Some("document"), + "a request that is not a navigation", + ), + ] { + let response = handle_data( + Some(&graph), + Some(&module), + &request(Some(&cookie), mode, dest), + ) + .expect("should answer"); + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "should refuse {who}" + ); + assert_eq!( + body_text(response), + NOT_OPENED_AS_A_PAGE, + "should tell {who} nothing about the record" + ); + } + + let response = handle_data( + Some(&graph), + Some(&module), + &opened_as_a_page(Some(&cookie)), + ) + .expect("should answer"); + assert_eq!( + response.status(), + StatusCode::OK, + "should answer a browser opening the page" + ); + } + + #[test] + fn the_record_is_shown_with_every_identifier_masked() { + let graph = graph_with_a_record(); + let module = module(); + let cookie = format!("ts-ec={EC_ID}"); + + let response = handle_data( + Some(&graph), + Some(&module), + &opened_as_a_page(Some(&cookie)), + ) + .expect("should answer"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + let html = body_text(response); + assert!( + html.contains("\"held\": true"), + "should say a record is held" + ); + assert!( + html.contains("partner.example.com"), + "should name the partner that holds an identifier" + ); + assert!( + html.contains("\"created\": \"2025-03-14T00:00:00Z\""), + "should say when the record was made in a form a person reads" + ); + assert!( + html.contains("publisher.example"), + "should show the site the record was made on" + ); + assert!( + !html.contains(PARTNER_UID), + "should not show the partner's identifier" + ); + assert!( + !html.contains(EC_HASH), + "should not show the Edge Cookie identifier" + ); + assert!( + html.contains("\"uid\": \"XXXX\""), + "should show that the partner's identifier is masked" + ); + assert!( + html.contains("Each identifier shows as XXXX."), + "should tell the reader what the mask means" + ); + } + + #[test] + fn an_identifier_sent_in_another_case_reaches_its_own_record() { + let graph = graph_with_a_record(); + let module = module(); + let cookie = format!("ts-ec=hmac~{}.aB3dE9", EC_HASH.to_ascii_uppercase()); + + let html = body_text( + handle_data( + Some(&graph), + Some(&module), + &opened_as_a_page(Some(&cookie)), + ) + .expect("should answer"), + ); + + assert!( + html.contains("\"held\": true"), + "should read the record under the key it is stored under: {html}" + ); + } + + /// Every field the record has is set here and listed below, so a field + /// added to the record fails this test until it is decided whether the + /// field is an identifier. + #[test] + fn every_field_of_the_record_is_shown_or_masked_by_decision() { + fn leaves(value: &Value, path: &str, out: &mut BTreeSet) { + match value { + Value::Object(map) => { + for (key, child) in map { + let key = if path == "ids" { "" } else { key }; + let path = if path.is_empty() { + key.to_owned() + } else { + format!("{path}.{key}") + }; + leaves(child, &path, out); + } + } + _ => { + out.insert(path.to_owned()); + } + } + } + let entry = KvEntry { + v: 1, + created: CREATED, + consent: KvConsent { + tcf: Some("tcf-string".to_owned()), + gpp: Some("gpp-string".to_owned()), + ok: true, + updated: CREATED + 60, + }, + geo: KvGeo { + country: "GB".to_owned(), + region: Some("ENG".to_owned()), + asn: Some(64_496), + dma: Some(807), + }, + pub_properties: Some(KvPubProperties { + origin_domain: "publisher.example".to_owned(), + seen_domains: BTreeSet::from(["publisher.example".to_owned()]), + }), + device: Some(KvDevice { + is_mobile: 0, + ja4_class: Some("t13d1516h2".to_owned()), + platform_class: Some("windows".to_owned()), + h2_fp_hash: Some("0123456789ab".to_owned()), + known_browser: Some(true), + }), + network: Some(KvNetwork { + cluster_size: Some(3), + }), + ids: BTreeMap::from([( + "partner.example.com".to_owned(), + KvPartnerId { + uid: PARTNER_UID.to_owned(), + }, + )]), + }; + + let payload = held_payload(&entry); + + let mut shown = BTreeSet::new(); + leaves(&payload["record"], "", &mut shown); + assert_eq!( + shown.iter().map(String::as_str).collect::>(), + [ + "consent.gpp", + "consent.ok", + "consent.tcf", + "consent.updated", + "created", + "device.h2_fp_hash", + "device.is_mobile", + "device.ja4_class", + "device.known_browser", + "device.platform_class", + "geo.asn", + "geo.country", + "geo.dma", + "geo.region", + "ids..uid", + "network.cluster_size", + "pub_properties.origin_domain", + "pub_properties.seen_domains", + "v", + ], + "a field the record has gained should be shown or masked by decision" + ); + assert_eq!( + payload["record"]["ids"]["partner.example.com"]["uid"], + json!(MASK), + "the one identifier the record holds should be masked" + ); + assert_eq!(payload["identifier"], json!(MASK)); + assert_eq!(payload["consent_updated"], json!("2025-03-14T00:01:00Z")); + assert_eq!(payload["withdrawn"], json!(false)); + assert!( + !payload.to_string().contains(PARTNER_UID), + "should carry no identifier anywhere" + ); + } + + /// The record and the answer the API reference shows. + #[test] + fn a_known_record_gives_the_documented_answer() { + let made = 1_773_480_600; + let entry = KvEntry { + v: 1, + created: made, + consent: KvConsent { + tcf: Some("CP...".to_owned()), + gpp: None, + ok: true, + updated: made, + }, + geo: KvGeo { + country: "GB".to_owned(), + region: Some("ENG".to_owned()), + asn: None, + dma: None, + }, + pub_properties: Some(KvPubProperties { + origin_domain: "example.com".to_owned(), + seen_domains: BTreeSet::from(["example.com".to_owned()]), + }), + device: None, + network: None, + ids: BTreeMap::from([( + "partner.example.com".to_owned(), + KvPartnerId { + uid: PARTNER_UID.to_owned(), + }, + )]), + }; + + assert_eq!( + held_payload(&entry), + json!({ + "consent_updated": "2026-03-14T09:30:00Z", + "created": "2026-03-14T09:30:00Z", + "held": true, + "identifier": "XXXX", + "record": { + "consent": { "ok": true, "tcf": "CP...", "updated": 1_773_480_600 }, + "created": 1_773_480_600, + "geo": { "country": "GB", "region": "ENG" }, + "ids": { "partner.example.com": { "uid": "XXXX" } }, + "pub_properties": { + "origin_domain": "example.com", + "seen_domains": ["example.com"] + }, + "v": 1 + }, + "version": env!("CARGO_PKG_VERSION"), + "withdrawn": false + }) + ); + } + + #[test] + fn a_withdrawal_is_shown_as_what_is_held() { + let payload = held_payload(&KvEntry::tombstone(CREATED)); + + assert_eq!(payload["held"], json!(true)); + assert_eq!( + payload["withdrawn"], + json!(true), + "should say the record is one of a withdrawal" + ); + } + + #[test] + fn a_reader_with_nothing_held_is_told_why() { + let graph = graph_with_a_record(); + let module = module(); + let another = format!("ts-ec=hmac~{}.zZ9yY8", "f".repeat(64)); + + for (kv, cookie, why) in [ + (None, Some("ts-ec=anything"), "keeps no record"), + (Some(&graph), None, "carried no Edge Cookie"), + ( + Some(&graph), + Some("ts-ec=t0op~device-1.issued-monday"), + "is not one this deployment issued", + ), + ( + Some(&graph), + Some(another.as_str()), + "holds no record against the request", + ), + ] { + let response = + handle_data(kv, Some(&module), &opened_as_a_page(cookie)).expect("should answer"); + + assert_eq!(response.status(), StatusCode::OK, "{why}"); + let html = body_text(response); + assert!(html.contains("\"held\": false"), "{why}: {html}"); + assert!(html.contains(why), "should say `{why}`: {html}"); + assert!( + !html.contains("partner.example.com"), + "should show nothing of another reader's record" + ); + } + } + + #[test] + fn a_store_that_cannot_be_read_is_an_error_and_not_an_empty_answer() { + let graph = KvIdentityGraph::failing("test-ec-store"); + let module = module(); + let cookie = format!("ts-ec={EC_ID}"); + + let result = handle_data( + Some(&graph), + Some(&module), + &opened_as_a_page(Some(&cookie)), + ); + + assert!( + result.is_err(), + "should not tell the reader nothing is held when the store was not read" + ); + } + + #[test] + fn every_answer_is_for_the_reader_alone() { + let graph = graph_with_a_record(); + let module = module(); + let cookie = format!("ts-ec={EC_ID}"); + let answered = handle_data( + Some(&graph), + Some(&module), + &opened_as_a_page(Some(&cookie)), + ) + .expect("should answer"); + let refused = handle_data( + Some(&graph), + Some(&module), + &request(Some(&cookie), None, None), + ) + .expect("should answer"); + + for (name, response) in [("the page", answered), ("the refusal", refused)] { + let headers = response.headers(); + assert_eq!( + headers[header::CACHE_CONTROL], + "no-store, private", + "{name} should never be stored" + ); + assert!( + headers[header::CONTENT_SECURITY_POLICY] + .to_str() + .expect("a text policy") + .starts_with("sandbox;"), + "{name} should be in an origin of its own" + ); + assert_eq!(headers[header::X_FRAME_OPTIONS], "DENY", "{name}"); + assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff", "{name}"); + assert_eq!(headers[header::REFERRER_POLICY], "no-referrer", "{name}"); + assert_eq!( + headers["cross-origin-resource-policy"], "same-origin", + "{name}" + ); + assert!( + headers.get(header::ACCESS_CONTROL_ALLOW_ORIGIN).is_none(), + "{name} should be offered to no other origin" + ); + } + } + + #[test] + fn the_page_does_not_offer_a_data_form() { + let html = body_text(page(¬hing_held("nothing"))); + + assert!( + !html.contains(".json"), + "should not point at a form the address does not have" + ); + assert!(html.contains("

Your data

")); + } +} diff --git a/crates/trusted-server-core/src/inspect/permissions.rs b/crates/trusted-server-core/src/inspect/permissions.rs new file mode 100644 index 0000000000..c7faafa25b --- /dev/null +++ b/crates/trusted-server-core/src/inspect/permissions.rs @@ -0,0 +1,347 @@ +//! The permissions resolved for the request being served, at +//! [`PERMISSIONS_PAGE_PATH`] and [`PERMISSIONS_JSON_PATH`]. +//! +//! The answer is the evaluation the page receives as +//! `window.tsjs.permissions`, offered at an address so client code can fetch +//! it, another front end can render it, and a person can read it. It is +//! resolved for the request that asks, exactly as a page's is, and nothing is +//! written for the reader, because the Edge Cookie lifecycle does not run +//! here. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; +use http::{HeaderValue, Request, Response, StatusCode, header}; +use serde_json::{Value, json}; + +use crate::ec::EcContext; +use crate::error::TrustedServerError; +use crate::permissions::PermissionState; +use crate::platform::RuntimeServices; +use crate::settings::Settings; + +use super::render_page; + +/// The permissions page. +pub const PERMISSIONS_PAGE_PATH: &str = "/_ts/permissions"; + +/// The same resolution as data. +pub const PERMISSIONS_JSON_PATH: &str = "/_ts/permissions.json"; + +/// Both addresses, the page first. +pub const PERMISSIONS_PATHS: [&str; 2] = [PERMISSIONS_PAGE_PATH, PERMISSIONS_JSON_PATH]; + +/// The permission resolution for a request, as the endpoint shows it, being +/// the page shape of [`PermissionState::page_value`] with these fields added. +/// +/// - `storageWithdrawn`: whether the request explicitly withdrew device storage. +/// - `modules.contributed`: the signal modules that produced a signal used here. +/// - `modules.configured`: the signal modules the publisher named, or `null`. +/// - `policy`: the permission policy file whose rules the answer resolved, +/// by the name `[permissions] policy` gives it, or `null` for settings that +/// name none, which no loaded domain has. +/// - `version`: the version of the core crate that answered. +#[must_use] +pub fn permissions_payload(state: &PermissionState, settings: &Settings) -> Value { + let mut contributed: Vec<&str> = state.signals().iter().map(|signal| signal.module).collect(); + contributed.sort_unstable(); + contributed.dedup(); + let configured = settings + .permission_signal + .modules + .as_ref() + .map(|names| json!(names)); + + let mut payload = state.page_value(); + if let Value::Object(fields) = &mut payload { + fields.insert("version".to_owned(), json!(env!("CARGO_PKG_VERSION"))); + fields.insert( + "storageWithdrawn".to_owned(), + json!(state.storage_withdrawn()), + ); + fields.insert( + "modules".to_owned(), + json!({ + "contributed": contributed, + "configured": configured, + }), + ); + fields.insert("policy".to_owned(), json!(settings.permissions.policy)); + } + payload +} + +/// The permissions page, or its JSON when `path` is +/// [`PERMISSIONS_JSON_PATH`], for a request whose permissions have been +/// resolved. +/// +/// Never stored, because each answer is the asking request's own, and +/// readable from any origin, so a page on another site can render it. A +/// request from another origin carries no cookies, so what that page reads is +/// the resolution of a request with no stored signal. +/// +/// # Panics +/// +/// Never in practice, because the status and every header are fixed, valid +/// values. +#[must_use] +pub fn permissions_response( + state: &PermissionState, + settings: &Settings, + path: &str, +) -> Response { + let payload = permissions_payload(state, settings); + let (content_type, body) = if path == PERMISSIONS_JSON_PATH { + ("application/json", payload.to_string()) + } else { + ( + "text/html; charset=utf-8", + render_page("Permissions", &payload), + ) + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)) + .header(header::CACHE_CONTROL, HeaderValue::from_static("no-store")) + .header( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ) + .body(EdgeBody::from(body.into_bytes())) + .expect("should build permissions response") +} + +/// Answers [`PERMISSIONS_PAGE_PATH`] or [`PERMISSIONS_JSON_PATH`] for `req`. +/// +/// The permissions are resolved for this request as a page's would be, from +/// its signals and its location, and then shown. Nothing is created or +/// written for the reader. +/// +/// # Errors +/// +/// When the request's state cannot be read, which is when the selected Edge +/// Cookie module cannot be built or the `Cookie` header is not valid UTF-8, +/// the same as for a page. +pub async fn handle_permissions( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result, Report> { + let state = EcContext::read_from_request_resolving_geo(settings, req, services).await?; + Ok(permissions_response( + state.permissions(), + settings, + req.uri().path(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::permissions::{Permission, PermissionSet, ValidSignal}; + use crate::platform::test_support::noop_services; + use crate::tdl::Tdl; + use crate::test_support::tests::create_test_settings; + + fn state_with_signal() -> PermissionState { + PermissionState::new(PermissionSet::none().with(Permission::StoreOnDevice)) + .with_signals(vec![ValidSignal::new("tcf", "tcf", "CPabc123")].into()) + .with_tdls( + vec![ + Tdl::new("https://terms.example.com/marketing/2.txt") + .expect("should accept the test locator"), + ] + .into(), + ) + } + + fn body_of(response: Response) -> String { + String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("the body should be text") + } + + /// A reader comes to see which signal produced the answer, so the signal + /// and the module that read it must both be there. + #[test] + fn the_payload_names_the_signal_and_the_module_that_read_it() { + let payload = permissions_payload(&state_with_signal(), &create_test_settings()); + + assert_eq!(payload["signals"][0]["module"], "tcf"); + assert_eq!(payload["signals"][0]["scheme"], "tcf"); + assert_eq!(payload["signals"][0]["value"], "CPabc123"); + assert_eq!(payload["modules"]["contributed"], json!(["tcf"])); + assert_eq!( + payload["tdls"], + json!(["https://terms.example.com/marketing/2.txt"]) + ); + assert_eq!(payload["set"], json!(["necessary.operations.storage"])); + assert_eq!(payload["storageWithdrawn"], json!(false)); + assert_eq!(payload["version"], json!(env!("CARGO_PKG_VERSION"))); + } + + /// The page shape is spelled once, on the state, so the endpoint cannot + /// drift from what a page is given. + #[test] + fn the_payload_holds_everything_the_page_is_given() { + let state = state_with_signal(); + let payload = permissions_payload(&state, &create_test_settings()); + + let page = state.page_value(); + for (key, value) in page.as_object().expect("the page shape is an object") { + assert_eq!( + &payload[key], value, + "`{key}` should be what the page is given" + ); + } + } + + /// A module that ran and found nothing and a module that was never + /// configured are different facts, and somebody asking why a permission + /// is not set needs to tell them apart. + #[test] + fn configured_and_contributed_modules_are_reported_separately() { + let mut settings = create_test_settings(); + settings.permission_signal.modules = Some(vec!["tcf".to_owned(), "gpc".to_owned()]); + + let payload = permissions_payload(&state_with_signal(), &settings); + + assert_eq!(payload["modules"]["contributed"], json!(["tcf"])); + assert_eq!(payload["modules"]["configured"], json!(["tcf", "gpc"])); + } + + /// With no modules named the selection is the adapter's, which core + /// cannot see, so it says nothing and does not invent a list. + #[test] + fn configured_is_null_when_the_publisher_named_no_modules() { + let mut settings = create_test_settings(); + settings.permission_signal.modules = None; + + let payload = permissions_payload(&state_with_signal(), &settings); + + assert!( + payload["modules"]["configured"].is_null(), + "should not guess which modules the adapter offers" + ); + } + + /// A name is a file's stem, which the configuration page shows with the + /// file's SHA-256, so a publisher matches the answer to one exact file. + #[test] + fn the_payload_names_the_policy_whose_rules_it_resolved() { + let mut settings = create_test_settings(); + settings.permissions.policy = Some("standard".to_owned()); + + let payload = permissions_payload(&state_with_signal(), &settings); + + assert_eq!(payload["policy"], json!("standard")); + } + + /// "No signal was found" is itself the answer somebody came for. + #[test] + fn a_resolution_with_no_signals_still_answers() { + let payload = permissions_payload(&PermissionState::default(), &create_test_settings()); + + assert_eq!(payload["signals"], json!([])); + assert_eq!(payload["set"], json!([])); + assert_eq!(payload["awaiting"], json!([])); + assert_eq!(payload["modules"]["contributed"], json!([])); + } + + #[test] + fn a_withdrawal_of_storage_is_shown_as_one() { + let state = PermissionState::default().with_storage_withdrawn(true); + + let payload = permissions_payload(&state, &create_test_settings()); + + assert_eq!(payload["storageWithdrawn"], json!(true)); + } + + #[test] + fn the_json_address_answers_data_and_the_page_address_a_page() { + let settings = create_test_settings(); + let state = state_with_signal(); + + let data = permissions_response(&state, &settings, PERMISSIONS_JSON_PATH); + assert_eq!(data.status(), StatusCode::OK); + assert_eq!(data.headers()[header::CONTENT_TYPE], "application/json"); + assert_eq!(data.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(data.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*"); + let parsed: Value = + serde_json::from_str(&body_of(data)).expect("the data form should be JSON"); + assert_eq!(parsed, permissions_payload(&state, &settings)); + + let page = permissions_response(&state, &settings, PERMISSIONS_PAGE_PATH); + assert_eq!( + page.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + assert_eq!(page.headers()[header::CACHE_CONTROL], "no-store"); + let html = body_of(page); + assert!(html.contains("

Permissions

"), "should be the page"); + assert!(html.contains("CPabc123"), "should show the signal"); + } + + /// The page carries a signal value straight from the request, so + /// anything that could close the surrounding tag is escaped. + #[test] + fn the_page_escapes_a_hostile_signal_value() { + let state = PermissionState::new(PermissionSet::none()) + .with_signals(vec![ValidSignal::new("tcf", "tcf", "