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 DEFAULT_LOCATION = "us-central1";
|
||||||
|
|
||||||
const encodeJobId = (name) => Buffer.from(name, "utf8").toString("base64url");
|
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) {
|
async function resolveAuth(credentials, log) {
|
||||||
const saJson = parseVertexSaJson(credentials?.apiKey);
|
const saJson = parseVertexSaJson(credentials?.apiKey);
|
||||||
@@ -103,17 +121,11 @@ export default {
|
|||||||
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}` };
|
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
let operationName;
|
const operationName = decodeJobId(requestId);
|
||||||
try {
|
if (!operationName) return { error: "Invalid Vertex video job id" };
|
||||||
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" };
|
|
||||||
return {
|
return {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `${base}/v1/${modelPath}:fetchPredictOperation`,
|
url: `${base}/v1/${modelPathOf(operationName)}:fetchPredictOperation`,
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ operationName }),
|
body: JSON.stringify({ operationName }),
|
||||||
};
|
};
|
||||||
@@ -131,6 +143,8 @@ export default {
|
|||||||
return { error: "Invalid JSON body" };
|
return { error: "Invalid JSON body" };
|
||||||
}
|
}
|
||||||
if (!body.model) return { error: "Vertex video requires a model (e.g. vertex/veo-3.1-generate-preview)" };
|
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" };
|
if (!body.prompt && !body.image && !body.image_url) return { error: "Vertex video requires a prompt or an image" };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -260,4 +260,37 @@ describe("vertex (veo) video adapter", () => {
|
|||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
expect(global.fetch).not.toHaveBeenCalled();
|
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