From da6aa90128c0861beccdeea1a6f23b0d1816673a Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 10 Sep 2026 23:18:45 +0700 Subject: [PATCH] fix(video/vertex): reject job ids and model ids that escape the URL path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Vertex job id is a base64url-encoded operation name, and base64url decoding accepts arbitrary bytes without throwing, so the previous decode-and-split check let a crafted id splice a path traversal into the fetch URL while the Bearer token stayed attached — e.g. "..%2F..%2Fevil" resolved to /v1/evil:fetchPredictOperation on the Vertex host. body.model had the same shape on the create path, where it is interpolated into the URL unescaped. decodeJobId now requires a charset-only id, a byte-for-byte round-trip, and a decoded name matching ^projects/{p}/locations/{l}/publishers/{pub}/models/{m}/operations/{op}$ — no field may contain "/", so ".." can never reach the URL. model ids are restricted to [A-Za-z0-9._-]. Co-Authored-By: Claude Code --- open-sse/handlers/videoProviders/vertex.js | 34 +++++++++++++++------- tests/unit/video-providers.test.js | 33 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/open-sse/handlers/videoProviders/vertex.js b/open-sse/handlers/videoProviders/vertex.js index 25394b94..a4ff66c6 100644 --- a/open-sse/handlers/videoProviders/vertex.js +++ b/open-sse/handlers/videoProviders/vertex.js @@ -13,7 +13,25 @@ import { parseVertexSaJson, refreshVertexToken } from "../../services/tokenRefre const DEFAULT_LOCATION = "us-central1"; const encodeJobId = (name) => Buffer.from(name, "utf8").toString("base64url"); -const decodeJobId = (id) => Buffer.from(id, "base64url").toString("utf8"); + +// Operation name shape: projects/{p}/locations/{l}/publishers/{pub}/models/{m}/operations/{op}. +// Anchored and single-segment-per-field so a decoded path can never carry `..` or a +// host-changing prefix into the request URL. +const OPERATION_NAME_RE = /^projects\/[^/]+\/locations\/[^/]+\/publishers\/[^/]+\/models\/[^/]+\/operations\/[^/]+$/; + +function modelPathOf(operationName) { + return operationName.slice(0, operationName.indexOf("/operations/")); +} + +function decodeJobId(id) { + const raw = String(id ?? ""); + // Buffer.from(x, "base64url") silently drops invalid characters instead of + // throwing, so only ids that re-encode byte-for-byte are accepted. + if (!raw || raw.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(raw)) return null; + const decoded = Buffer.from(raw, "base64url").toString("utf8"); + if (Buffer.from(decoded, "utf8").toString("base64url") !== raw) return null; + return OPERATION_NAME_RE.test(decoded) ? decoded : null; +} async function resolveAuth(credentials, log) { const saJson = parseVertexSaJson(credentials?.apiKey); @@ -103,17 +121,11 @@ export default { const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}` }; if (requestId) { - let operationName; - try { - operationName = decodeJobId(requestId); - } catch { - return { error: "Invalid Vertex video job id" }; - } - const modelPath = operationName.split("/operations/")[0]; - if (!modelPath || modelPath === operationName) return { error: "Invalid Vertex video job id" }; + const operationName = decodeJobId(requestId); + if (!operationName) return { error: "Invalid Vertex video job id" }; return { method: "POST", - url: `${base}/v1/${modelPath}:fetchPredictOperation`, + url: `${base}/v1/${modelPathOf(operationName)}:fetchPredictOperation`, headers, body: JSON.stringify({ operationName }), }; @@ -131,6 +143,8 @@ export default { return { error: "Invalid JSON body" }; } if (!body.model) return { error: "Vertex video requires a model (e.g. vertex/veo-3.1-generate-preview)" }; + // Plain model id only — a path segment carrying "/" or ".." would rewrite the URL. + if (!/^[A-Za-z0-9._-]+$/.test(body.model)) return { error: "Invalid Vertex video model id" }; if (!body.prompt && !body.image && !body.image_url) return { error: "Vertex video requires a prompt or an image" }; return { diff --git a/tests/unit/video-providers.test.js b/tests/unit/video-providers.test.js index 680db5a5..bfa26589 100644 --- a/tests/unit/video-providers.test.js +++ b/tests/unit/video-providers.test.js @@ -260,4 +260,37 @@ describe("vertex (veo) video adapter", () => { expect(result.status).toBe(400); expect(global.fetch).not.toHaveBeenCalled(); }); + + // A base64url id decodes to arbitrary bytes, so a crafted one used to splice a + // path traversal into the fetch URL while the Authorization header stayed on. + it("rejects job ids that decode outside the projects/…/operations/ shape", async () => { + refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" }); + const jid = (s) => Buffer.from(s, "utf8").toString("base64url"); + + for (const id of [ + jid("../../evil"), + jid("projects/p/locations/l/publishers/google/models/m/operations/../../x"), + jid("../../evil/operations/op"), + "!!!not-base64!!!", + `${JOB_ID}=`, + `${JOB_ID}\n`, + ]) { + const result = await handleVideoProxyCore({ provider: "vertex", requestId: id, credentials: { apiKey: saJson } }); + expect(result.status).toBe(400); + expect(global.fetch).not.toHaveBeenCalled(); + } + }); + + it("rejects a model id carrying path separators", async () => { + refreshVertexToken.mockResolvedValue({ accessToken: "vertex-tok" }); + const result = await handleVideoProxyCore({ + provider: "vertex", + action: "generations", + rawBody: JSON.stringify({ model: "../../evil", prompt: "x" }), + contentType: "application/json", + credentials: { apiKey: saJson }, + }); + expect(result.status).toBe(400); + expect(global.fetch).not.toHaveBeenCalled(); + }); });