Repository navigation
fix(fly-io): redact sensitive fields in GraphQL add-on responses - #867
Merged
Mkassabov merged 10 commits intoOct 21, 2026
Merged
Conversation
A 2xx body for an operation whose output has a sensitive member reached the caller unredacted when it did not match the output schema. In strict mode, `<Sdk>ParseError.body` carried the raw text or the parsed JSON, so a response that drifted from the spec put the secret into an error that gets logged. In lenient mode, a non-JSON body (one cut off mid-secret, for example) came back as the success value. `hasSensitiveMember(ast)`, beside `wrapSensitive` in `protocol-rest`, reports whether any member reachable from an output is marked `SensitiveValue`. It looks inside map values too. For such outputs `makeRestProtocol` fails a non-JSON 2xx body in both modes and builds every parse error with `body: "[REDACTED]"`, the sentinel ACME and ZeroSSL already use; the cause stays the schema error. The whole body is withheld because in a mismatched body the secret may sit at a key the schema does not model. Outputs without sensitive members behave as before. Protocols that do not go through `makeRestProtocol` are unchanged.
`hasSensitiveMember` visited each list element but checked the `SensitiveValue` mark only on an object's members, so an output whose only secret is a list element (a stored header map typed `Record<string, Array<string>>` whose elements are marked) counted as not sensitive, and a parse failure reported its body unredacted. It now checks the mark on each list element too, as it does on a member or a map value. No generated output marks a list element today, so no SDK changes until the generator and `wrapSensitive` honor such a mark.
Stripe's hand-written protocol wires `validateResponse` itself, so the core fix does not reach it. A 2xx body for an output with a sensitive member (`CreateWebhookEndpoint`'s `secret`, `CreateEphemeralKey`'s `secret`) that did not match the schema put the secret into `StripeParseError.body` in strict mode, and a non-JSON body (one cut off mid-secret) came back as the success value in lenient mode. It now does what `makeRestProtocol` does: for an output where `hasSensitiveMember(outputAst)`, a non-JSON 2xx body fails in every mode and every parse error reports `body: "[REDACTED]"`. Outputs without sensitive members behave as before.
Fly.io's GraphQL and Sprites decoders wire `validateResponse` themselves, so the core fix does not reach them. A 2xx body for an output with a sensitive member (`addOn`'s `password` and `publicUrl`, `createOrganizationToken`'s `token`) that did not match the schema put the secret into `FlyIoParseError.body` in strict mode, and a non-JSON body (one cut off mid-secret) came back as the success value in lenient mode. Both now do what `makeRestProtocol` does: for an output where `hasSensitiveMember(outputAst)`, a non-JSON 2xx body fails in every mode and every parse error reports `body: "[REDACTED]"`. The Sprites token mint that runs before every Sprites operation but `createOrganizationToken` reported `json ?? text` when the reply carried no usable token, so a reply cut off mid-token leaked it from an ordinary call such as `deleteSprite`; it now always reports `"[REDACTED]"`, since its reply exists only to carry the token. Outputs without sensitive members behave as before.
Only core's REST protocol, Stripe and Fly.io fail a non-JSON 2xx body for an output with sensitive members; Azure, Coinbase, MongoDB Atlas, Slack, STACKIT, Supabase and ZeroSSL decode it themselves and still return it as text in lenient mode. The decoder comments now say what they do (fail a non-JSON body, withhold the body from parse errors) instead of claiming the body is never returned unredacted, which a secret under a map value still is on success.
Every successful add-on call returned the add-on's `password` and `publicUrl` (a connection URL with the password in it) as plain strings, though their generated types say `string | Redacted<string> | null`. The GraphQL decoder returned the validated payload as read, skipping the `wrapSensitive` step the REST and Sprites decoders take. `graphqlDecode` now passes the validated payload through `wrapSensitive(outputAst, ...)` in lenient and strict mode alike, so the fields marked `SensitiveValue` on the add-on output shapes in `services/addons.ts` come back wrapped in `Redacted`, nested ones such as `createAddOn.addOn` included. Unmarked fields stay plain, `environment` among them.
`addOn` and `createAddOn`, called through a fake HTTP client in lenient and strict mode, return `password` and `publicUrl` wrapped in `Redacted`, with `Redacted.value` giving the original string; `name`, which is not marked, stays plain, and the secret appears nowhere in the inspected result. All four cases fail before the decoder change, on `Redacted.isRedacted(...password)`.
Port the parse-error redaction onto alchemy-run#873's synchronous decoders: - core: makeRestProtocol's decodeText reads `withholdBody` (hasSensitiveMember) from the per-operation DecodePlan. - stripe: decodeResponse withholds the body the same way. alchemy-run#873's strict checker returns a SchemaMismatch whose `actual` and message carry the offending value, so a mismatch at a sensitive member put the secret back into the parse error's cause. `withholdActual` (core response-validation) replaces that value with "[REDACTED]"; every site that withholds the body now withholds it from the cause too. A core test covers a strict mismatch at the sensitive member itself.
Contributor
|
Install the packages built from this commit: @distilled.cloudpnpm install https://pkg.distilled.cloud/@distilled.cloud/core/pr:867:af3e230pnpm install https://pkg.distilled.cloud/@distilled.cloud/fly-io/pr:867:af3e230Published |
…ss-redact # Conflicts: # packages/fly-io/src/protocol.test.ts
Collaborator
|
Thanks @mattespapier — merged after #839. Add-on -- claude |
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.
A successful Fly GraphQL add-on call returns the add-on's
passwordandpublicUrl(a connection URL with the password in it) as plain strings, thoughservices/addons.tsmarks bothT.SensitiveValueand their types saystring | Redacted<string> | null. The REST protocols (core'smakeRestProtocol) and the Sprites decoder pass the success value throughwrapSensitive;graphqlDecodereturns the validated payload as read. Live on Fly.io, anaddOn({ id })read of an Upstash Redis add-on on #839's head returned both as plain strings, and the password showed ininspect(result)(table under Tested).Depends on #839, which edits the same function's error paths. Review the last two commits (
6d76c16bc,be7cad36c); the five before them are #839 at4db9f2b7f.Change
graphqlDecodereturnswrapSensitive(outputAst, validated)in lenient and strict mode alike, soaddOn,addOns,organization,createAddOn,updateAddOnandresetAddOnPasswordreturnpasswordandpublicUrlasRedacted, nested ones such ascreateAddOn.addOnincluded. Unmarked fields stay plain.addOnandcreateAddOnin both modes return both fields redacted,Redacted.valuegives the original string,namestays plain, and the secret appears nowhere ininspect(result).Tested
The four new cases fail on #839's head, each on
Redacted.isRedacted(...password), and pass here.Live, Fly.io, one Upstash Redis add-on (
upstash_redis, Pay-as-you-go) created in a personal org for the run, read once through each head, and deleted (2026-10-09 UTC):passwordpublicUrladdOn({ id }), #839 headstring(32 chars)string(90 chars)addOn({ id }), this branchRedacted(32 chars)Redacted(90 chars)createAddOn, this branchRedactedRedactedOn #839's head the password showed in
inspect(result)of theaddOnread; on this branch it didn't. ThecreateAddOnresult still shows it, throughenvironment(below).An earlier run against a Fly Tigris storage add-on showed only that a live reply still decodes: Tigris add-ons have no
passwordorpublicUrl, so both came backnull.Not covered
environmentis not marked sensitive.createAddOnreturns the add-on's credentials in it, plain:AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYfor a Tigris storage add-on,REDIS_PASSWORDandREDIS_URLfor Redis (both seen live). Marking it would change nothing at runtime:wrapSensitivewraps only string members, andenvironmentis anS.Unknownobject. Redacting its values needs a new shape (a string map with sensitive values), which changes what Alchemy'sFly/Bucket.tsreads, so it is left for a follow-up with a companion Alchemy change.Alchemy needs no change:
Fly/Redis.tsalready unwrapspublicUrlwhether or not it isRedactedand never readspassword, andFly/Bucket.tsreads neither.Checked:
pnpm vitest run packages/fly-io(36 pass; 32 on #839's head) andpackages/core(731 pass),lint,format:check,tsc -b packages/fly-io --noCheck false; Alchemymain(41a0a4e74) with the submodule at this head:tsc -bwith no errors andlint:providers.