Skip to content

Commit d3737a8

Browse files
dahliacodex
andcommitted
Support extensionless nested image URLs in downloadImage
Some image URLs use nested extensionless paths such as /media/12345. Rejecting all such URLs made image rendering skip common CDN/proxy media endpoints. Derive the extension from the filename when present; otherwise, infer it from Content-Type (with a safe fallback). Keep blocking suspicious encoded traversal patterns in the pathname. #608 (comment) Co-Authored-By: Codex <codex@openai.com>
1 parent e859672 commit d3737a8

2 files changed

Lines changed: 55 additions & 4 deletions

File tree

packages/cli/src/imagerenderer.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ test("downloadImage - rejects unsafe extension containing path traversal", async
182182

183183
try {
184184
const result = await downloadImage(
185-
"https://198.51.100.10/image.png/../../../../etc/passwd",
185+
"https://198.51.100.10/image.png/..%2f..%2f..%2fetc%2fpasswd",
186186
);
187187
assert.equal(result, null);
188188
} finally {
@@ -208,3 +208,25 @@ test("downloadImage - falls back to jpg when URL has no extension", async () =>
208208
}
209209
}
210210
});
211+
212+
test("downloadImage - falls back to content type for extensionless nested path", async () => {
213+
const originalFetch = globalThis.fetch;
214+
globalThis.fetch = ((_input: URL | RequestInfo) =>
215+
Promise.resolve(
216+
new Response(new Uint8Array([1, 2, 3]), {
217+
headers: { "content-type": "image/png" },
218+
}),
219+
)) as typeof fetch;
220+
221+
let result: string | null = null;
222+
try {
223+
result = await downloadImage("https://198.51.100.10/media/12345");
224+
assert.notEqual(result, null);
225+
assert.equal(path.extname(result!), ".png");
226+
} finally {
227+
globalThis.fetch = originalFetch;
228+
if (result != null) {
229+
await rm(path.dirname(result), { recursive: true, force: true });
230+
}
231+
}
232+
});

packages/cli/src/imagerenderer.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,30 @@ const KITTY_IDENTIFIERS: string[] = [
2020

2121
type KittyCommand = Record<string, string | number>;
2222

23+
function getExtensionFromContentType(contentType: string | null): string {
24+
const mime = contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
25+
switch (mime) {
26+
case "image/jpeg":
27+
case "image/jpg":
28+
return "jpg";
29+
case "image/png":
30+
return "png";
31+
case "image/gif":
32+
return "gif";
33+
case "image/webp":
34+
return "webp";
35+
case "image/avif":
36+
return "avif";
37+
case "image/bmp":
38+
return "bmp";
39+
case "image/svg+xml":
40+
return "svg";
41+
default:
42+
if (mime.startsWith("image/")) return mime.slice("image/".length);
43+
return "jpg";
44+
}
45+
}
46+
2347
export function detectTerminalCapabilities(): TerminalType {
2448
const termProgram = (process.env.TERM_PROGRAM || "").toLowerCase();
2549

@@ -132,15 +156,20 @@ export async function downloadImage(url: string): Promise<string | null> {
132156
}
133157
const imageData = new Uint8Array(await response.arrayBuffer());
134158
const pathname = new URL(targetUrl).pathname;
159+
const lowerPathname = pathname.toLowerCase();
160+
if (
161+
lowerPathname.includes("%2f") || lowerPathname.includes("%5c") ||
162+
lowerPathname.includes("..")
163+
) {
164+
return null;
165+
}
135166
const pathSegments = pathname.split("/").filter((segment) =>
136167
segment !== ""
137168
);
138169
const filename = pathSegments[pathSegments.length - 1] ?? "";
139170
const extension = filename.includes(".")
140171
? path.extname(filename).slice(1)
141-
: pathSegments.length === 1
142-
? "jpg"
143-
: "";
172+
: getExtensionFromContentType(response.headers.get("content-type"));
144173
if (extension.length < 1) return null;
145174
if (
146175
extension.includes("/") || extension.includes("\\") ||

0 commit comments

Comments
 (0)