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:
decolua
2026-09-10 23:18:45 +07:00
parent 248d7da01c
commit da6aa90128
2 changed files with 57 additions and 10 deletions

View File

@@ -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();
});
});