fix(sse): replace spoofable .includes() PromptQL issuer check with hostname comparison (#8029) (#8042)

isDdnProjectPromptQlToken() (jwt.ts) and isLikelyDdnToken() (usage/promptql.ts) used
`iss.includes("auth.pro.hasura.io")`, which a spoofed issuer like
"https://auth.pro.hasura.io.evil.com/ddn/token" also satisfies
(js/incomplete-url-substring-sanitization, 2 open CodeQL high alerts).

Adds a shared issuerHostIsTrusted() helper in jwt.ts that parses `iss` with `new URL()`
and compares the hostname (exact match or trusted subdomain), and points both call
sites at it, de-duplicating the previously copy-pasted predicate.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-21 21:42:36 -03:00
committed by GitHub
parent 5e234d503d
commit 898e2bfcaa
4 changed files with 130 additions and 5 deletions

View File

@@ -0,0 +1 @@
- fix(sse): replace spoofable `.includes()` PromptQL issuer check with hostname comparison (#8029)

View File

@@ -102,13 +102,31 @@ export function isPlaygroundPromptQlToken(token: string): boolean {
return false;
}
/**
* Hosts trusted as PromptQL DDN/lux token issuers. A bare `String.includes()` against
* `iss` is spoofable (`https://auth.pro.hasura.io.evil.com/...` also "contains" the
* trusted substring) — CodeQL js/incomplete-url-substring-sanitization, issue #8029.
* Always parse `iss` as a URL and compare the hostname instead.
*/
const TRUSTED_DDN_ISSUER_HOSTS = ["auth.pro.hasura.io", "auth.pro.ql.app"];
/** True when `iss` parses as a URL whose hostname is (or is a subdomain of) a trusted host. */
export function issuerHostIsTrusted(iss: string): boolean {
try {
const host = new URL(iss).hostname.toLowerCase();
return TRUSTED_DDN_ISSUER_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
} catch {
return false;
}
}
/** DDN/lux project JWT (iss auth.pro.hasura.io) — credits yes, playground chat no. */
export function isDdnProjectPromptQlToken(token: string): boolean {
if (!token || isPlaygroundPromptQlToken(token)) return false;
const payload = decodeJwtPayload(token);
if (!payload) return false;
const iss = readStr(payload.iss).toLowerCase();
if (iss.includes("auth.pro.hasura.io") || iss.includes("auth.pro.ql.app")) return true;
const iss = readStr(payload.iss);
if (issuerHostIsTrusted(iss)) return true;
// aud is a project UUID and no hasura claims → treat as DDN
const aud = payload.aud;
if (typeof aud === "string" && looksLikeUuid(aud)) return true;

View File

@@ -23,6 +23,7 @@ import {
looksLikeUuid,
extractProjectIdFromToken,
decodeJwtPayload,
issuerHostIsTrusted,
} from "../promptql/jwt.ts";
// Re-exported for backward compatibility — external/test consumers previously
@@ -121,9 +122,9 @@ function collectCreditsTokens(
function isLikelyDdnToken(token: string): boolean {
const json = decodeJwtPayload(token);
if (!json) return false;
const iss = typeof json.iss === "string" ? json.iss.toLowerCase() : "";
if (iss.includes("auth.pro.hasura.io") || iss.includes("auth.pro.ql.app")) return true;
if (iss === "enrich-token" || iss.includes("enrich-token")) return false;
const iss = typeof json.iss === "string" ? json.iss : "";
if (issuerHostIsTrusted(iss)) return true;
if (iss.toLowerCase().includes("enrich-token")) return false;
const aud = json.aud;
if (typeof aud === "string" && looksLikeUuid(aud)) return true;
return false;

View File

@@ -0,0 +1,105 @@
// Regression test for #8029 — PromptQL issuer checks used a bare `String.includes()`
// against the JWT `iss` claim, which is an incomplete-url-substring-sanitization bug
// (CodeQL js/incomplete-url-substring-sanitization). A spoofed issuer such as
// `https://auth.pro.hasura.io.evil.com/ddn/token` satisfied `.includes("auth.pro.hasura.io")`
// even though its actual host is `auth.pro.hasura.io.evil.com`, not `auth.pro.hasura.io`.
//
// Two call sites shared the same flawed predicate (copy-pasted):
// - open-sse/services/promptql/jwt.ts::isDdnProjectPromptQlToken()
// - open-sse/services/usage/promptql.ts::isLikelyDdnToken()
//
// The fix replaces both with a shared `issuerHostIsTrusted()` helper that parses the
// issuer with `new URL()` and compares the hostname (exact match or a `.`-delimited
// subdomain of a trusted host), never a raw substring check.
import { describe, it } from "node:test";
import assert from "node:assert/strict";
const jwtMod = await import("../../open-sse/services/promptql/jwt.ts");
const usageMod = await import("../../open-sse/services/usage/promptql.ts");
function makeFakeJwt(claims: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
return `${header}.${payload}.sig`;
}
const NON_UUID_AUD = "not-a-project-uuid";
const spoofedDdnJwt = makeFakeJwt({
iss: "https://auth.pro.hasura.io.evil.com/ddn/token",
aud: NON_UUID_AUD,
exp: Math.floor(Date.now() / 1000) + 3600,
});
const spoofedQlAppJwt = makeFakeJwt({
iss: "https://auth.pro.ql.app.evil.com/ddn/token",
aud: NON_UUID_AUD,
exp: Math.floor(Date.now() / 1000) + 3600,
});
const legitimateDdnJwt = makeFakeJwt({
iss: "https://auth.pro.hasura.io/ddn/token",
aud: NON_UUID_AUD,
exp: Math.floor(Date.now() / 1000) + 3600,
});
const legitimateSubdomainJwt = makeFakeJwt({
iss: "https://eu.auth.pro.hasura.io/ddn/token",
aud: NON_UUID_AUD,
exp: Math.floor(Date.now() / 1000) + 3600,
});
const garbageIssuerJwt = makeFakeJwt({
iss: "not a url at all",
aud: NON_UUID_AUD,
exp: Math.floor(Date.now() / 1000) + 3600,
});
describe("BUG #8029 — PromptQL issuer host check (js/incomplete-url-substring-sanitization)", () => {
it("jwt.ts::isDdnProjectPromptQlToken rejects a spoofed host that merely CONTAINS the trusted substring", () => {
assert.equal(jwtMod.isDdnProjectPromptQlToken(spoofedDdnJwt), false);
assert.equal(jwtMod.isDdnProjectPromptQlToken(spoofedQlAppJwt), false);
});
it("jwt.ts::isDdnProjectPromptQlToken still accepts the real trusted host and its subdomains", () => {
assert.equal(jwtMod.isDdnProjectPromptQlToken(legitimateDdnJwt), true);
assert.equal(jwtMod.isDdnProjectPromptQlToken(legitimateSubdomainJwt), true);
});
it("jwt.ts::isDdnProjectPromptQlToken rejects a non-URL issuer instead of throwing", () => {
assert.equal(jwtMod.isDdnProjectPromptQlToken(garbageIssuerJwt), false);
});
it("jwt.ts::issuerHostIsTrusted is exported and shared", () => {
assert.equal(typeof jwtMod.issuerHostIsTrusted, "function");
assert.equal(jwtMod.issuerHostIsTrusted("https://auth.pro.hasura.io.evil.com/x"), false);
assert.equal(jwtMod.issuerHostIsTrusted("https://auth.pro.hasura.io/x"), true);
assert.equal(jwtMod.issuerHostIsTrusted("https://eu.auth.pro.hasura.io/x"), true);
assert.equal(jwtMod.issuerHostIsTrusted("https://auth.pro.ql.app/x"), true);
assert.equal(jwtMod.issuerHostIsTrusted("garbage"), false);
});
it("usage/promptql.ts collectCreditsTokens sorts a spoofed-issuer token as non-DDN (via getPromptQlUsage token ordering)", async () => {
// isLikelyDdnToken is not exported directly; exercise it indirectly through
// getPromptQlUsage's "onlyEnrich" fallback message, which flips to the DDN-required
// message only when at least one token is classified as DDN-shaped.
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ errors: [{ message: "access-denied" }] }), {
status: 200,
})) as typeof fetch;
try {
const result = await usageMod.getPromptQlUsage(spoofedDdnJwt, {
projectId: "01a0fe61-baf4-4e31-9311-8cc0bb3eba91",
});
// A spoofed-host token must NOT be treated as DDN-shaped, so the fallback
// "onlyEnrich" branch (which requires isLikelyDdnToken to be false for every
// token) is taken and the DDN-specific instructional message is returned.
assert.ok("message" in result);
assert.match(String(result.message), /DDN\/project JWT/);
} finally {
globalThis.fetch = originalFetch;
}
});
});