diff --git a/docs/guides/secret-backed-integration.md b/docs/guides/secret-backed-integration.md index a97f256b13..754528c871 100644 --- a/docs/guides/secret-backed-integration.md +++ b/docs/guides/secret-backed-integration.md @@ -98,8 +98,12 @@ Rules: to find the right secret name, then reference that name in a placeholder. - Placeholders only resolve in secret-aware `fetch` paths and capability inputs marked `x-kody-secret`; they are not general string interpolation. -- Never echo the literal `{{secret:...}}` form into chat, logs, issue bodies, or - any content that may later be sent over `fetch`. +- Never echo a resolvable literal placeholder into chat, logs, issue bodies, or + any content that may later be sent over `fetch`. To mention the syntax in + prose, use the inert `{{secret:}}` form — angle brackets are outside the + name charset, so it never resolves. To deliberately deliver a resolvable + placeholder to a third party, set the `x-kody-secret-resolution: off` header + on that `fetch` (the gateway strips it and skips resolution for that request). - For Basic Auth derived from two secrets, use `secretHeaders.basic(...)` from `kody:runtime` (see the secrets usage docs). diff --git a/docs/use/execute.md b/docs/use/execute.md index 81193c210d..f6bcab68ec 100644 --- a/docs/use/execute.md +++ b/docs/use/execute.md @@ -319,9 +319,19 @@ placeholders, host approval, and **`kody.secret_list`** / **`secret_set`**. Treat placeholder syntax as operational wiring, not prose. Do not place the exact **`{{secret:...}}`** token into issue bodies, comments, prompts, logs, or -other content that may be shown to users or sent to third parties. If you need -to mention a placeholder literally, obfuscate it instead of embedding the exact -token. +other content that may be shown to users or sent to third parties — resolution +runs on the final serialized request, so even string concatenation cannot keep a +literal placeholder out of it. + +To **mention** the syntax in prose, use the inert form **`{{secret:}}`** — +angle brackets are outside the placeholder name charset, so it never resolves +anywhere, now or downstream. + +To deliberately deliver a **resolvable** literal placeholder to a third party +(for example, config that Kody itself resolves later), set the +**`x-kody-secret-resolution: off`** header on that fetch. The gateway strips the +header and skips resolution for that request only. The delivered text remains +one resolution step from the real secret, so use this sparingly. ## Values diff --git a/docs/use/secrets-and-values.md b/docs/use/secrets-and-values.md index 90c7408133..d91443c80e 100644 --- a/docs/use/secrets-and-values.md +++ b/docs/use/secrets-and-values.md @@ -70,11 +70,26 @@ specific token exchange with ordinary **`fetch`**. For service-account JSON secrets, pass **`private_key_json_field: "private_key"`** to sign with that field. -Do **not** place literal placeholder tokens into user-visible or -third-party-visible content such as issue bodies, comments, prompts, logs, or -returned strings. If you need to describe a placeholder as text, obfuscate it -instead of embedding the exact **`{{secret:...}}`** form into content that may -later be sent over **`fetch`**. +## Mentioning placeholders without resolving them + +Resolution runs on the **final serialized request** (URL, headers, body), so a +literal placeholder assembled by any means — including string concatenation — +will resolve. Do **not** place resolvable placeholder tokens into user-visible +or third-party-visible content such as issue bodies, comments, prompts, logs, or +returned strings. + +- To **mention** the syntax in prose or docs, write **`{{secret:}}`**. + Angle brackets are outside the placeholder name charset (`[a-zA-Z0-9._-]`), so + this form is inert everywhere — it cannot resolve in this request or any later + one. +- To deliberately send a **resolvable** literal placeholder to a third party + (for example, config text that Kody itself will resolve later), set the + **`x-kody-secret-resolution: off`** header on that **`fetch`**. The gateway + strips the header and skips all placeholder resolution for that one request. + Only the calling code can set headers, so data flowing through a URL or body + can never disable resolution. Use this sparingly: the delivered text is still + one resolution step away from the real secret if it later flows back through a + secret-aware **`fetch`**. ## Host approval diff --git a/packages/worker/src/mcp/fetch-gateway.node.test.ts b/packages/worker/src/mcp/fetch-gateway.node.test.ts index 3e8dc0db74..fd6c15afe6 100644 --- a/packages/worker/src/mcp/fetch-gateway.node.test.ts +++ b/packages/worker/src/mcp/fetch-gateway.node.test.ts @@ -3,6 +3,7 @@ import { expect, test, vi } from 'vitest' import { executeGatewayFetch, expandSecretPlaceholders, + secretResolutionHeaderName, } from '#mcp/fetch-gateway.ts' import { parseHostApprovalRequiredBatchMessage } from '#mcp/secrets/errors.ts' import { @@ -88,6 +89,70 @@ test('fetch gateway blocks or expands secret placeholders based on host approval } }) +test('opt-out header sends placeholders literally, strips itself, and never resolves secrets', async () => { + const resolveSpy = vi.spyOn(secretService, 'resolveSecret') + const request = new Request('https://discord.com/api/channels/1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [secretResolutionHeaderName]: 'off', + }, + body: JSON.stringify({ + content: 'Use {{secret:name}} in your fetch call.', + }), + }) + + try { + const transformed = await expandSecretPlaceholders({ request, props, env }) + expect(transformed.headers.get(secretResolutionHeaderName)).toBeNull() + expect(await transformed.text()).toBe( + JSON.stringify({ content: 'Use {{secret:name}} in your fetch call.' }), + ) + expect(transformed.url).toBe('https://discord.com/api/channels/1/messages') + expect(resolveSpy).not.toHaveBeenCalled() + } finally { + resolveSpy.mockRestore() + } +}) + +test('opt-out header value "on" resolves normally and is stripped; unknown values fail loudly', async () => { + const resolveSpy = vi + .spyOn(secretService, 'resolveSecret') + .mockResolvedValue({ + found: true, + value: 'secret-value', + scope: 'user', + allowedHosts: ['example.com'], + allowedCapabilities: [], + }) + try { + const onRequest = new Request('https://example.com/api', { + method: 'POST', + headers: { + Authorization: 'Bearer {{secret:spotifyRefreshToken|scope=user}}', + [secretResolutionHeaderName]: 'on', + }, + body: '{}', + }) + const transformed = await expandSecretPlaceholders({ + request: onRequest, + props, + env, + }) + expect(transformed.headers.get('Authorization')).toBe('Bearer secret-value') + expect(transformed.headers.get(secretResolutionHeaderName)).toBeNull() + + const invalidRequest = new Request('https://example.com/api', { + headers: { [secretResolutionHeaderName]: 'of' }, + }) + await expect( + expandSecretPlaceholders({ request: invalidRequest, props, env }), + ).rejects.toThrow(`Invalid ${secretResolutionHeaderName} header value "of"`) + } finally { + resolveSpy.mockRestore() + } +}) + test('fetch gateway expands placeholders in form-urlencoded bodies', async () => { const resolveSpy = vi .spyOn(secretService, 'resolveSecret') diff --git a/packages/worker/src/mcp/fetch-gateway.ts b/packages/worker/src/mcp/fetch-gateway.ts index 496efbf5ef..f914a7a333 100644 --- a/packages/worker/src/mcp/fetch-gateway.ts +++ b/packages/worker/src/mcp/fetch-gateway.ts @@ -30,6 +30,19 @@ type FetchGatewayProps = { } export type { FetchGatewayProps } +/** + * Request header that disables secret placeholder resolution for one gateway + * fetch: `x-kody-secret-resolution: off`. The header is stripped before the + * request leaves the gateway, and any `{{secret:...}}` text passes through + * literally. Out-of-band by design — only calling code can set a header, so + * attacker-controlled *data* in a URL or body can never disable resolution. + * Use it when a third party must receive literal placeholder text (for + * example, writing config that Kody itself resolves later). For merely + * mentioning the syntax in prose, prefer the inert `{{secret:}}` form + * instead, which never resolves anywhere. + */ +export const secretResolutionHeaderName = 'x-kody-secret-resolution' + export class KodyFetchGateway extends WorkerEntrypoint { async fetch(request: Request) { return executeGatewayFetch({ @@ -136,17 +149,34 @@ export async function expandSecretPlaceholders(input: { env: Pick }) { const headers = new Headers(input.request.headers) + const baseUrl = input.props.baseUrl.trim() + if (!baseUrl) { + throw new Error('Fetch gateway requires a non-empty baseUrl in props.') + } const requestBody = await readRequestBody(input.request) + if (readSecretResolutionMode(headers) === 'off') { + return new Request( + resolveRequestUrlForFetchGateway(input.request.url, baseUrl), + { + method: input.request.method, + headers, + body: requestBody ?? undefined, + redirect: input.request.redirect, + credentials: input.request.credentials, + mode: input.request.mode, + cache: input.request.cache, + integrity: input.request.integrity, + keepalive: input.request.keepalive, + signal: input.request.signal, + }, + ) + } const resolvedSecrets: Array<{ referenced: ReferencedSecret resolved: ResolvedSecret }> = [] const replacements = new Map() const resolvedValues = new Map() - const baseUrl = input.props.baseUrl.trim() - if (!baseUrl) { - throw new Error('Fetch gateway requires a non-empty baseUrl in props.') - } const basicAuthPlaceholders = dedupeBasicAuthSecretPlaceholders([ ...collectReferencedBasicAuthSecretPlaceholders([ input.request.url, @@ -338,6 +368,23 @@ function ensureFetchAllowed(props: FetchGatewayProps) { } } +/** + * Read and strip the resolution opt-out header. Unknown values fail loudly: + * a typo like "of" silently resolving placeholders would defeat the point of + * opting out. + */ +function readSecretResolutionMode(headers: Headers): 'on' | 'off' { + const raw = headers.get(secretResolutionHeaderName) + if (raw == null) return 'on' + headers.delete(secretResolutionHeaderName) + const value = raw.trim().toLowerCase() + if (value === 'off') return 'off' + if (value === 'on') return 'on' + throw new Error( + `Invalid ${secretResolutionHeaderName} header value "${raw}". Use "off" to send secret placeholders literally without resolution, or omit the header for normal resolution.`, + ) +} + function collectReferencedSecrets(values: Array) { return dedupeReferencedSecrets( values.flatMap((value) => (value ? parseSecretPlaceholders(value) : [])),