fix(video/vertex): reject job ids and model ids that escape the URL path
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user