Skip to content

Commit aeaae5b

Browse files
pi0claude
andcommitted
fix(storage/http): return 400 for unparseable ids instead of 500
Malformed percent-encoding in ids no longer throws an uncaught URIError (falls back to the raw id, matching the URL parser) and unparseable ids throw a 400 IPX_INVALID_URL instead of leaking a TypeError as a 500. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 85290d9 commit aeaae5b

2 files changed

Lines changed: 99 additions & 2 deletions

File tree

src/storage/http.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ export type HTTPStorageOptions = {
3636

3737
const HTTP_RE = /^https?:\/\//;
3838

39+
function decode(input: string) {
40+
try {
41+
return decodeURIComponent(input);
42+
} catch {
43+
// Keep malformed percent-encoding as-is (e.g. `100%.jpg`)
44+
return input;
45+
}
46+
}
47+
3948
/**
4049
* Creates an HTTP storage handler for IPX that fetches image data from external URLs.
4150
* This handler allows configuration to specify allowed domains, caching behaviour and custom fetch options.
@@ -70,7 +79,18 @@ export function ipxHttpStorage(_options: HTTPStorageOptions = {}): IPXStorage {
7079
);
7180

7281
function validateId(id: string) {
73-
const url = new URL(decodeURIComponent(id));
82+
let url: URL;
83+
try {
84+
// Ids are usually already decoded by the URL parser but the storage API
85+
// can also be used directly with still encoded ids.
86+
url = new URL(decode(id));
87+
} catch {
88+
throw new HTTPError({
89+
statusCode: 400,
90+
statusText: `IPX_INVALID_URL`,
91+
message: `Invalid URL: ${id}`,
92+
});
93+
}
7494
if (!url.hostname) {
7595
throw new HTTPError({
7696
statusCode: 403,

test/storage/http.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
22

33
import { ipxHttpStorage } from "../../src/storage/http.ts";
44

55
describe("http", () => {
6+
afterEach(() => {
7+
vi.unstubAllGlobals();
8+
});
9+
610
describe("getMeta", () => {
711
const storage = ipxHttpStorage({});
812
const sut = storage.getMeta;
@@ -17,4 +21,77 @@ describe("http", () => {
1721
);
1822
});
1923
});
24+
25+
describe("validateId", () => {
26+
const storage = ipxHttpStorage({ domains: ["example.com"] });
27+
28+
it("unparseable id throws a 400 HTTPError", async () => {
29+
await expect(storage.getData("not-a-url")).rejects.toMatchObject({
30+
statusCode: 400,
31+
statusText: "IPX_INVALID_URL",
32+
message: expect.stringContaining("not-a-url"),
33+
});
34+
});
35+
36+
it("missing hostname throws a 403 HTTPError", async () => {
37+
await expect(storage.getData("file://")).rejects.toMatchObject({
38+
statusCode: 403,
39+
statusText: "IPX_MISSING_HOSTNAME",
40+
});
41+
});
42+
43+
it("forbidden host throws a 403 HTTPError", async () => {
44+
await expect(
45+
storage.getData("https://not-example.com/image.png"),
46+
).rejects.toMatchObject({
47+
statusCode: 403,
48+
statusText: "IPX_FORBIDDEN_HOST",
49+
});
50+
});
51+
52+
it("malformed percent-encoding is not a URIError (forbidden host)", async () => {
53+
await expect(
54+
storage.getData("https://not-example.com/100%.jpg"),
55+
).rejects.toMatchObject({
56+
statusCode: 403,
57+
statusText: "IPX_FORBIDDEN_HOST",
58+
});
59+
});
60+
61+
it("malformed percent-encoding is fetched as-is for allowed hosts", async () => {
62+
const fetch = vi
63+
.fn()
64+
.mockResolvedValue(new Response(new Uint8Array([1, 2, 3])));
65+
vi.stubGlobal("fetch", fetch);
66+
67+
await expect(
68+
storage.getData("https://example.com/100%.jpg"),
69+
).resolves.toBeInstanceOf(ArrayBuffer);
70+
71+
expect(fetch).toHaveBeenCalledWith(
72+
"https://example.com/100%.jpg",
73+
expect.anything(),
74+
);
75+
});
76+
77+
it("encoded ids are decoded (direct storage usage)", async () => {
78+
const fetch = vi
79+
.fn()
80+
.mockResolvedValue(new Response(new Uint8Array([1, 2, 3])));
81+
vi.stubGlobal("fetch", fetch);
82+
83+
await storage.getData("https%3A%2F%2Fexample.com%2Fimage.png");
84+
expect(fetch).toHaveBeenCalledWith(
85+
"https://example.com/image.png",
86+
expect.anything(),
87+
);
88+
});
89+
90+
it("getMeta propagates HTTPError for invalid ids", async () => {
91+
await expect(storage.getMeta("not-a-url")).rejects.toMatchObject({
92+
statusCode: 400,
93+
statusText: "IPX_INVALID_URL",
94+
});
95+
});
96+
});
2097
});

0 commit comments

Comments
 (0)