Repository navigation
Conversation
Invitation, password-reset and workflow-callback tokens travel in URL
paths, and every request wrote its raw path to the console and Seq:
Serilog's completion line, the hosting scope's RequestPath on every
event, and GlobalExceptionFilter. The SPA's nginx logged the request
line and the Referer, which a page opened at /invite/<token> sends with
every asset. Anyone who could read logs could join a team, reset a
password or resume a run. A route that binds a `token` parameter is now
logged by its template (/api/invitations/{token}/accept), read live
from the first middleware. Where no bearer route answers -- a GET or
HEAD on a callback URL, a wrong method, the SPA's invite or reset link
opened on the API, any line logged before routing such as the https
redirect's port warning -- a path with a bearer route's shape is logged
with that segment as {token}. nginx logs {token} in place of the
segment. URL shapes are unchanged.
The callback-wait token was projected to every team member on run
detail and pending-wait, so a Viewer could resume a run anonymously
with a body of their choosing, bypassing runs.control. Both reads now
carry a fixed placeholder in its place -- not null, which the decoder
every earlier SPA ships refuses -- and the token is handed out only by
RevealWorkflowCallbackUrlCommand (runs.control, logged as a reveal).
The run page still shows a token that an API from before the reveal
projects, so the page works whichever role is deployed first.
Migration 0248 is the expand step of taking the token out of plaintext.
It adds token_digest, backfilled for every minted Callback token, and
token_ciphertext, an AES-GCM copy under the environment-held variable
master key for the reveal -- not Data Protection, whose key ring sits
in the same database. The token column keeps the token, and new waits
still write it, because a pod on the previous build looks a callback up
by comparing that column with what was presented. Hashing it in place
made previous-version API pods answer 404 to every issued URL, made
them accept the stored digest -- which the old UI shows every member --
as a working token, and made a rollback strand every URL. Now any
rollout order and a rollback keep every URL resuming, and the digest is
a key nowhere. This version resumes by digest, or by the token column
for rows the previous build wrote, so a later release can blank that
column once no older pod can run.
The run page keys the callback panel by its wait, so a revealed URL
never outlives the wait it belongs to.
ppXD
force-pushed
the
fix/keep-bearer-url-tokens-out-of-logs-and-views
branch
from
October 13, 2026 07:38
7b289b0 to
c115826
Compare
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
Problem. Invitation, password-reset and workflow-callback tokens travel in URL paths. Every request wrote its raw path to the console and Seq: Serilog's completion line, the hosting scope's
RequestPathon every event, andGlobalExceptionFilter. The SPA's nginx logged the request line and the Referer, and a page opened at/invite/<token>sends that URL as the Referer of every asset. Anyone who could read logs could join a team, reset a password or resume a run. Separately, run detail and pending-wait projected a Callback wait's token to every team member, so a Viewer could resume a run anonymously with a body of their choosing and bypassruns.control.Redaction where a bearer route answers. A route that binds a
tokenparameter is logged by its template (/api/invitations/{token}/accept).BearerRouteLogging(backend/src/CodeSpace.Api/Logging/BearerRouteLogging.cs) does this and runs first in the pipeline (Startup.cs).Redaction where no bearer route answers. Some requests never reach a bearer route:
/invite/…or/reset-password/…link opened on the API origin;UseHttpsRedirection's port warning.For these, a path with a bearer route's shape is logged with that segment as
{token}. The shapes come from the host's endpoint table plus the SPA routes thatPublicBaseUrlSettingbuilds links on.nginx.
frontend/nginx.conflogs{token}in place of the segment in both the request line and the Referer. URL shapes are unchanged, and every issued link keeps working.Callback token out of run reads. For a Callback wait,
GET /api/workflows/runs/{id}andGET /api/workflows/runs/{id}/pending-waitnow send the fixed placeholderwithheld-reload-the-page-to-reveal(WorkflowWaitKinds.WithheldBearerToken) where the token used to be. Other wait kinds are unchanged.nullbecause the pending-wait decoder in every shipped SPA refuses anything but a 1..128-character string, so an older SPA would have shown "Pending action unavailable".{token:length(32)}, and no version mints that shape.Reveal endpoint.
POST /api/workflows/runs/{runId}/waits/{waitId}/callback-url(RevealWorkflowCallbackUrlCommand) returns{ waitId, path }.runs.control. Anyone else gets 403.callback_url_unrecoverableonly for a row with neither form. No version up to this one writes such a row.Run page. Members with
runs.controlget a "Reveal URL" button, and everyone else is told who can reveal it. The panel is keyed by the wait, so a revealed URL never outlives its wait. When an API from before this change answers, the page shows the URL that API projects, as it always did.Storage: the expand step of taking the token out of plaintext. Migration
0248_workflow_callback_token_digest.sqlis additive:token_digest, the lowercase hex SHA-256, backfilled for every Callback row that holds a minted token.token_ciphertext, AES-GCM under the environment-heldVariables:MasterKey. Data Protection is deliberately not used, because its key ring lives in the same database. SQL cannot reach the key, so only waits minted by this version get a ciphertext.token_digestfor pending Callback waits.tokencolumn keeps the plaintext token, and new waits still write it there, together with the digest and the ciphertext.Resume. A resume matches the digest of the presented value, or matches a minted-shape value against
token. The stored digest is never a key:token, which never holds a digest.Not delivered here: at-rest secrecy of callback tokens. The plaintext stays in
tokenfor this release, exactly as onmaintoday, so that previous-version pods and a rollback keep working. The contract step below removes it.Deploy and rollback
Program.cs), so 0248 runs on the first new pod of either role to boot.UPDATEthat fills only aNULLdigest.workflow_run_waitwhile it scans the table once.token. This version resumes it and reveals it from that column.TODO — follow-up contract release (needs migration 0250+)
This release keeps the plaintext token in
workflow_run_wait.token. A later release must blank it, and only under these conditions:token. Its PR must say "roll back only to this version or later".token_digestwhere it is stillNULL. These are Callback rows that previous-version pods parked after 0248 ran.token_ciphertextin the application for Callback rows that lack it. SQL cannot reachVariables:MasterKey.token = ''for Callback rows and stop writing the plaintext inWorkflowEngine(Token = bearerToken?.Token ?? correlationToken).token. Every build before the contract step compares whatever is presented with that column.A_url_whose_token_a_later_release_blanked_resumes_by_its_digest_and_reveals_from_its_ciphertext. A row blanked without a ciphertext still resumes but returns 409 on reveal.wait.Token.ShouldBe(token)inCallbackFlowTests;storedToken.ShouldBe(token)inCallbackUrlRevealE2ETests;wait.Token.ShouldBe(issued, …)inCallbackTokenDigestMigrationTests.Test plan
WorkflowCallbackTokenSealerTestsandWorkflowWaitKindsTests(60/60).CallbackTokenDigestMigrationTests. The previous-version pod is simulated by the previous build's lookup run verbatim as SQL (token = @presented AND status = 'Pending' AND wait_kind = 'Callback'), followed by its resume.tokenunchanged and seals nothing. A second run changes nothing.CallbackFlowTests,ParallelWaitResumeScopeTests,BearerRouteLoggingTestsandFailuresAreAlwaysLoggedTests. With the class above, 48/48.CallbackUrlRevealE2ETests,BearerUrlTokenLogE2ETestsandWebhookOutsiderHardeningE2ETests(25/25).SuspendedPanel.test.tsxcovers reveal gated byruns.control, the panel reset on a new wait, and showing an older API's projected URL.workflowRunPendingWaitApi.test.tschecks that the placeholder passes the decoder.static-hosting.test.tscovers the nginx masks.pnpm linthas 0 errors.tsc --noEmit -p tsconfig.app.jsonpasses. The full vitest suite passes (2542/2542).pnpm buildpasses.frontend/docker/e2e/run.sh: check the served nginx access log masks the token. This needs Docker.{token}for invite, reset and callback paths.