mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
committed by
GitHub
parent
5e234d503d
commit
898e2bfcaa
1
changelog.d/fixes/8029-promptql-issuer-substring.md
Normal file
1
changelog.d/fixes/8029-promptql-issuer-substring.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): replace spoofable `.includes()` PromptQL issuer check with hostname comparison (#8029)
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
105
tests/unit/bug-8029-promptql-issuer-substring.test.ts
Normal file
105
tests/unit/bug-8029-promptql-issuer-substring.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user