Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/gateway/src/secrets/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Expand Down
71 changes: 58 additions & 13 deletions packages/gateway/src/secrets/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <secret>". 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.
*/
Expand Down Expand Up @@ -60,28 +65,68 @@ 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<string> {
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,
env: NodeJS.ProcessEnv,
store: SecretStore | null,
context: string
): Promise<string> {
// 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(
Expand Down
Loading