diff --git a/packages/gateway/src/secrets/store.test.ts b/packages/gateway/src/secrets/store.test.ts index c045da7..4783ec3 100644 --- a/packages/gateway/src/secrets/store.test.ts +++ b/packages/gateway/src/secrets/store.test.ts @@ -34,6 +34,37 @@ describe("resolveInjectionValue", () => { ); }); + it("resolves a bao: ref embedded in a larger value", async () => { + const store = new MemorySecretStore(); + await store.put("upstreams/publora", "token", "sk_live_123"); + await expect( + resolveInjectionValue("Bearer bao:upstreams/publora#token", env, store, "ctx") + ).resolves.toBe("Bearer sk_live_123"); + }); + + it("resolves multiple embedded tokens (ref + ${VAR}) in one value", async () => { + const store = new MemorySecretStore(); + await store.put("p", "a", "AAA"); + await expect( + resolveInjectionValue("k=bao:p#a; e=${MY_TOKEN}; end", env, store, "ctx") + ).resolves.toBe("k=AAA; e=env-value; end"); + }); + + it("fails an embedded bao: ref when no store is configured", async () => { + await expect( + resolveInjectionValue("Bearer bao:a#b", env, null, "ctx") + ).rejects.toThrow(/BAO_ADDR/); + }); + + it("returns a whole-value secret verbatim without re-scanning its contents", async () => { + const store = new MemorySecretStore(); + // Secret content that itself looks like a ${VAR} / ref must not be re-resolved. + await store.put("p", "f", "Bearer ${MY_TOKEN} bao:x#y"); + await expect(resolveInjectionValue("bao:p#f", env, store, "ctx")).resolves.toBe( + "Bearer ${MY_TOKEN} bao:x#y" + ); + }); + it("fails a bao: ref when no store is configured", async () => { await expect(resolveInjectionValue("bao:a#b", env, null, "ctx")).rejects.toThrow(/BAO_ADDR/); }); diff --git a/packages/gateway/src/secrets/store.ts b/packages/gateway/src/secrets/store.ts index bb4b0e8..553ec94 100644 --- a/packages/gateway/src/secrets/store.ts +++ b/packages/gateway/src/secrets/store.ts @@ -6,6 +6,11 @@ * "kv:secret-name" — a secret in Azure Key Vault (flat name→value, no fields) * "literal ${VAR}" — env substitution (config.ts substituteEnv) * + * Refs may be EMBEDDED in a larger value: "Bearer bao:upstreams/x#token" + * resolves to "Bearer ". A ref token is delimited by whitespace, so + * paths/fields must not contain spaces. When the whole value is a single ref + * the secret is returned verbatim (no env-substitution of its contents). + * * Secret VALUES are never persisted in SQLite, never logged (labels and * sha256 prefixes only), and never returned by the admin API. */ @@ -60,6 +65,38 @@ export function parseSecretRef(value: string): SecretRef { export const secretFingerprint = (value: string): string => createHash("sha256").update(value).digest("hex").slice(0, 8); +/** Resolve a single, whole-value ref against the store (with the plumbing checks). */ +async function resolveRef( + ref: string, + store: SecretStore | null, + context: string +): Promise { + if (!store) { + throw new Error( + `${context} references a secret ("${ref}") but no secret store is configured — set BAO_ADDR or KEY_VAULT_URI` + ); + } + const scheme = schemeOf(ref); + if (store.scheme !== scheme) { + throw new Error( + `${context} uses a "${scheme}:" ref but the configured secret store serves "${store.scheme}:" refs` + ); + } + return store.get(parseSecretRef(ref)); +} + +// A ref or ${VAR} embedded in a larger string. Ref tokens use a conservative +// path/field charset (letters, digits and - _ . /) so a ref ends cleanly at +// surrounding punctuation — "Bearer bao:upstreams/x#token" or "…#token; more". +// "kv:secret-name" uses the KV charset. Matched left-to-right in a single pass +// so resolved secret contents are never re-scanned. Whole-value refs (handled +// before this) keep the broader parseSecretRef charset for back-compat. +const REF_CHARS = "A-Za-z0-9_./-"; +const EMBEDDED_TOKEN = new RegExp( + `bao:[${REF_CHARS}]+#[${REF_CHARS}]+|kv:[0-9A-Za-z-]{1,127}|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}`, + "g" +); + /** Resolve one injection value (header or env var) at connect time. */ export async function resolveInjectionValue( value: string, @@ -67,21 +104,29 @@ export async function resolveInjectionValue( store: SecretStore | null, context: string ): Promise { + // Whole value is a single ref → return the secret verbatim (its contents are + // never treated as a ref or ${VAR}). Also the only path that errors on a + // malformed whole-value ref such as "bao:missing-hash". if (isSecretRef(value)) { - if (!store) { - throw new Error( - `${context} references a secret ("${value}") but no secret store is configured — set BAO_ADDR or KEY_VAULT_URI` - ); - } - const scheme = schemeOf(value); - if (store.scheme !== scheme) { - throw new Error( - `${context} uses a "${scheme}:" ref but the configured secret store serves "${store.scheme}:" refs` - ); - } - return store.get(parseSecretRef(value)); + return resolveRef(value, store, context); + } + // Otherwise resolve any embedded refs / ${VAR}s in a single left-to-right + // pass, splicing resolved values in without re-scanning them. + const matches = [...value.matchAll(EMBEDDED_TOKEN)]; + if (matches.length === 0) return value; + let out = ""; + let cursor = 0; + for (const match of matches) { + const token = match[0]; + const index = match.index ?? 0; + out += value.slice(cursor, index); + out += token.startsWith("${") + ? substituteEnv(token, env, context) + : await resolveRef(token, store, context); + cursor = index + token.length; } - return substituteEnv(value, env, context); + out += value.slice(cursor); + return out; } export async function resolveInjectionRecord(