Repository navigation
Add inspection endpoints that show what a deployment runs and decides - #1350
Open
jwrosewell wants to merge 34 commits into
Open
jwrosewell wants to merge 34 commits into
jwrosewell wants to merge 34 commits into
Conversation
A deployment answers `GET /_ts/permissions` with a page, and `/_ts/permissions.json` with the same information as data, with no credential. It shows the permissions resolved for the request that asks, the signals that produced them and the terms the data is held under, so a publisher can point a reader, a regulator or an auditor at it and nobody has to take anybody's word for what was decided. The answer is the evaluation a page receives as `window.tsjs.permissions`. `PermissionState::page_value` is now the one place that shape is spelled, and `page_json` writes it. The endpoint adds what a person asking why a permission is not set needs and a page does not: whether storage was explicitly withdrawn, which signal modules the publisher configured and which of them contributed a signal, and the version that answered. The handler is core's, `inspect::permissions::handle_permissions`, and all four adapters route to it, so the answer is the same on each, which a parity test checks. It resolves the request's state as a page's is resolved and writes nothing for the reader. On Fastly it answers before the Edge Cookie lifecycle, so no cookie is set and no identity row is written. The answer is never stored, and is readable from any origin so another site can render it. A request from another origin carries no cookies, so that site reads the resolution of a request with no stored signal.
`inspect::config::build_view` gives the settings a deployment is running, after defaults are applied, with values masked as `XXXX` and every masked path listed. Anyone can read the scripts a page runs, and this is what lets anyone read the configuration that runs at the edge. Three kinds of value are masked. 1. Every secret, whatever the publisher's document says. The settings loader now records each leaf it fills from the secret store, with the value it wrote, and the view masks those paths, every leaf declared a secret, and then any string that still contains one of the values, wherever it sits, a key included. 2. Every value sensitive by default, unless the publisher shows it. `Redacted` values are, and so are the settings now written with `redacted::sensitive`, being the origin URLs, the origin host override, the Basic-auth path, and the store names and ids. 3. Everything the publisher hides. The publisher decides with a new `[inspect]` section, `show`, `hide` and `config = false`, each entry a path pattern such as `proxy.asset_routes[].origin_url`. A pattern that would not be honored as written refuses the configuration, both when a deployment is validated and when the settings load. That is a pattern that matches nothing, a `show` naming a secret, a pattern another pattern already covers, a `show` a `hide` covers, a pattern that changes nothing, and any pattern beside `config = false`. Marking is a mode of serialization. `redacted::to_marked_value` wraps each sensitive value under a marker key while it runs, and every other serialization, the configuration push included, is unchanged, which a test checks field by field. The section is left out of a stored document unless the publisher wrote one, which a test checks too, so an existing document gains nothing. The note at the top of `redacted.rs` said the build script compiles the file. It does not, on this branch or on `main`, so the note goes. `handle_config` answers `/_ts/config` and `/_ts/config.json`. No adapter routes to it yet.
`GET /_ts/config` answers the masked view of the running settings as a page, and `/_ts/config.json` as data, with no credential, on all four adapters. Each routes to core's `handle_config`, so the view is the same on each, which a parity test checks. It says nothing about the request, so on Fastly it is answered ahead of everything a request passes through, and the Edge Cookie lifecycle does not run. `[inspect] config = false` answers `404`. The API reference describes the endpoint, and the configuration guide describes `[inspect]`, what is always masked and what is masked by default.
`inspect::data::handle_data` answers `/_ts/data` with the identity graph record stored against the identifier in the request's own `ts-ec` cookie, being when the record was made, the consent recorded, the location and device class kept, and which partners hold an identifier of their own for the same browser. A deployment that writes an identifier to a browser can show the person using that browser what it keeps against it. The Edge Cookie is `HttpOnly`, so a script on the page cannot read the identifier, and the page does not undo that. 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`. Anything else is answered `403`. 2. The page is sent with `Content-Security-Policy: sandbox`, so the document has an origin of its own and a page that opened it cannot read it. 3. No identifier is shown. The Edge Cookie's value and each partner's identifier show as `XXXX`. 4. It is never stored, and no other origin is told it may read it. Checked in Chrome against a server answering by the same rule with the same headers. A page's `fetch` was refused, with and without the two headers set by the script, and so were a frame and an embedded object. A page that opened the address could not read it, and could once the sandbox policy was left out. A service worker registered for the whole site did read it, as it reads every navigation on the site, and so can whoever holds an identifier and sends it as the cookie. Neither finds an identifier in the page, which is why none is shown. The record is shown as it is stored. A test sets every field the record has and lists them, so a field added to the record fails until it is decided whether the field is an identifier. The page renderer takes a lede of its own, because this address has no `.json` form, and the mask moves up to `inspect` now that two pages use it. `ec::request_cookie_ec` hands out the identifier a request's cookie carries. No adapter routes to the handler yet.
`GET /_ts/data` routes to core's `handle_data` on all four adapters. Fastly keeps the identity graph, so it hands the handler the graph and the Edge Cookie module the deployment selects. It answers ahead of the Edge Cookie lifecycle, so nothing is written for the reader. Axum, Cloudflare and Spin keep no identity graph, so each answers that nothing is held, and a parity test checks that the three refuse and answer alike. The API reference describes the endpoint, who can read the page and why no identifier is in it.
The deploy check of the inspect patterns now sees every secret leaf the settings load resolves, including a labeled Edge Cookie block's passphrase and a module's declared secrets. A show pattern naming one of them is refused at ts config validate as it is at startup. A test covers both kinds. Comments in the inspection code now state each rule once, and the API reference says the data page answers 403 on a plain http host other than localhost.
An operator's response_headers replaced headers of the same name as the response was finalized, so a site-wide Content-Security-Policy removed the sandbox from /_ts/data and a page that opened it could read the record. The data page now marks its headers as required, and core sets them again after the operator's headers on every adapter.
This was referenced Oct 15, 2026
This was referenced Oct 17, 2026
The test that loads every declared secret checked each path with an assertion whose message held the path, and whose failure printed the value the view held there, which would be the secret if the view failed to mask it. The test now collects the paths that are not masked, and the paths not listed as masked, and compares each list with an empty one, so a failure names every such path and prints no value.
For [inspect] show = ["permissions.text"] the refusal read "names `permissions.text`, which is under `permissions.text`". The pattern itself now reads "names `permissions.text`, the policy file the push writes", and only a pattern beneath it, such as permissions.text[], says which key it is under. The test covers both forms.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Changes
crates/trusted-server-core/srcdocs/guidecrates/trusted-server-adapter-fastly/srccrates/trusted-server-integration-tests/testscrates/trusted-server-adapter-axum/testscrates/trusted-server-adapter-cloudflare/testscrates/trusted-server-adapter-spin/testscrates/trusted-server-adapter-spin/srccrates/trusted-server-adapter-axum/srccrates/trusted-server-adapter-cloudflare/srcCloses
Closes #1268
Spec
Spec: inspection-endpoints
Test plan
CI on this pull request at e1b6e44 passed all 20 checks.
cargo test-fastly && cargo test-axumcargo clippy-fastly && cargo clippy-axumcargo fmt --all -- --checkcd crates/trusted-server-js/lib && npx vitest runcd crates/trusted-server-js/lib && npm run formatcd docs && npm run formatcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute serveChecklist
unwrap()in production code (clippy deniesunwrap_usedin CI)logmacros as AGENTS.md asks (notprintln!)Stacks on the one module context (#1349).
A deployment shows what it runs and what it decided, with no credential, at three addresses that are the same on every deployment. A publisher can point a reader, a regulator or an auditor at them.
What changed after the 51Degrees PR review
Agreed at the review on 23 October, point 1. No configuration or permission policy is compiled into the WASM, and a WASM without a policy does not start.
/_ts/configshows the policy's name and SHA-256 and never the file, and/_ts/permissionsnames the policy whose rules resolved the answer.Fixes found in review
[inspect] show = ["permissions.text"]the refusal says the pattern names the policy file the push writes, where it said the pattern was under itself.Kept current
mainat 7aa5c99 and the changes made lower in the stack.What this pull request does
/_ts/permissionsand/_ts/permissions.json/_ts/configand/_ts/config.json/_ts/dataOne handler in core answers each address on all four adapters, and a parity test checks each. All three are read only. On Fastly each answers before the Edge Cookie lifecycle, so no cookie is set and no identity row is written.
The permissions endpoint
The answer is the evaluation a page receives as
window.tsjs.permissions, with four things added that a person asking why a permission is not set needs and a page does not, being whether storage was explicitly withdrawn, which signal modules the publisher configured and which of them contributed a signal, the permission policy whose rules resolved the answer, by the name[permissions] policygives it, and the version that answered. A policy's name is its file's stem, which two versions of one file share, so the SHA-256 that/_ts/configshows is what matches an answer to one exact file.It resolves the request's state as a page's is resolved and writes nothing for the reader. The answer is never stored.
The configuration endpoint
It shows the settings the deployment holds after defaults are applied, with values masked as
XXXXand every masked path listed.Three kinds of value are masked.
The publisher decides with a new
[inspect]section, which holdsshow,hideandconfig = false, the last of which answers404. A pattern that would not be honored as written refuses the configuration, both when a deployment is validated and when the settings load, so a publisher cannot believe a value is hidden when it is shown.The settings show the domain's permission policy by its name,
permissions.policy, and by the SHA-256 of its file,permissions.sha256, neither masked by default, so a publisher can check which exact file a domain runs. The file itself is never shown, because the settings loader takes it out of the settings when it loads the policy, and an[inspect]pattern underpermissions.textis refused, because it would change nothing the page shows.ts config pushalways writespermissions.sha256, so every check counts it as present, and a pattern that names it is judged alike before the push and when the pushed settings load.A stored document is unchanged. The marking that lets the view find sensitive values is a mode of one serialization call, every other serialization writes what it wrote before, and the
[inspect]section is left out of a document unless the publisher wrote one.Both are readable from any origin
/_ts/permissionsand/_ts/configboth answerAccess-Control-Allow-Origin: *, so another site can render them. A request from another origin carries no cookies, so that site reads the permissions of a request with no stored signal.The data endpoint
The page shows the identity graph record stored against the identifier in the request's own
ts-eccookie, as it is stored, being when it was made, the consent recorded, the location and device class kept, and which partners hold an identifier of their own for the same browser.The Edge Cookie is
HttpOnly, so a script on the page cannot read the identifier, and the page does not undo that.Sec-Fetch-Mode: navigateandSec-Fetch-Dest: document. Anything else is answered403. A browser sendsSec-Fetch-*only to a trustworthy origin, so every browser on a plainhttp://host other than localhost is answered403too.Content-Security-Policy: sandbox, so the document has an origin of its own and a page that opened it cannot read it. The adapters set it again after the operator'sresponse_headers, so a site-wideContent-Security-Policycannot remove it.XXXX.device.h2_fp_hashanddevice.ja4_classare shown unmasked, as the record holds them.These were checked in Chrome against a server on localhost answering by the same rule with the same headers. A page's
fetch, the same with the two headers set by the script, a frame and an embedded object were each answered403. A page that opened the address withwindow.opencould not read it, and could once the sandbox policy was left out.So two parties other than the reader can read the page. The first is a service worker the publisher's own site registers for the whole site, and the second is whoever holds an identifier and sends it as the cookie with both headers. Neither finds an identifier in the page, which is why none is shown.
A test lists every field the record has, so a field added to the record fails that test until it is decided whether the field is an identifier.
Only the Fastly adapter keeps an identity graph, so Axum, Cloudflare and Spin answer that nothing is held.
Why
Nothing let a reader, a regulator or an auditor see what a deployment runs and what it decided, so each had to take the publisher's word for it.
What breaks
resolve_secret_references_withnow returnsResult<ResolvedSecrets, _>, the record of where the secrets went, in place ofResult<(), _>.Closes #1268.