Gateway: normalize HEIC input_image sources#38122
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟡 Potential image decompression bomb / CPU & memory exhaustion via HEIC→JPEG normalization (no pixel limits, conversion before size check)
DescriptionThe new HEIC support normalizes
Vulnerable code (conversion performed without preflight dimension/pixel checks): const normalizedBuffer = await convertHeicToJpeg(params.buffer);
if (normalizedBuffer.byteLength > params.limits.maxBytes) {
throw new Error(`Image too large after HEIC conversion: ...`);
}Related backend code shows return (buffer) => sharp(buffer, { failOnError: false });RecommendationAdd pixel/dimension limits and fail fast before conversion. Suggested approach:
Example (sharp backend): // image-ops.ts
const MAX_IMAGE_PIXELS = 20_000_000; // example
async function loadSharp(): Promise<(buffer: Buffer) => ReturnType<Sharp>> {
const mod = (await import("sharp")) as unknown as { default?: Sharp };
const sharp = mod.default ?? (mod as unknown as Sharp);
return (buffer) => sharp(buffer, {
failOnError: true,
limitInputPixels: MAX_IMAGE_PIXELS,
});
}Example (call site preflight): const meta = await getImageMetadata(params.buffer);
if (!meta || meta.width * meta.height > params.limits.maxPixels) {
throw new Error("Image dimensions exceed pixel limit");
}
const normalizedBuffer = await convertHeicToJpeg(params.buffer);For the 2. 🟡 MIME allowlist bypass via fallback to user-/server-claimed Content-Type when sniffing fails
Description
This creates a content-type spoofing path:
Concrete example (base64 spoofing):
Security impact depends on downstream handling, but this breaks the intended guarantee that RecommendationDo not trust the claimed/header MIME when magic-number detection fails for Recommended: require successful sniffing and (optionally) enforce consistency with the claimed type. Example fix: const detected = normalizeMimeType(
await detectMime({ buffer: params.buffer, headerMime: params.mimeType })
);
if (!detected) {
throw new Error("Unsupported/unknown image format");
}
if (!params.limits.allowedMimes.has(detected)) {
throw new Error(`Unsupported image MIME type: ${detected}`);
}
// Optional: if a caller-supplied mimeType exists and is non-generic, require match
const claimed = normalizeMimeType(params.mimeType);
if (claimed && claimed !== detected) {
throw new Error(`MIME mismatch: claimed ${claimed} but detected ${detected}`);
}Additionally:
3. 🔵 MIME allowlist bypass via HEIC/HEIF normalization returning JPEG without re-checking allowedMimes
Description
This creates a policy bypass when operators configure a custom allowlist that permits HEIC/HEIF but forbids JPEG:
Vulnerable code: const sourceMime = ...;
if (!params.limits.allowedMimes.has(sourceMime)) {
throw new Error(`Unsupported image MIME type: ${sourceMime}`);
}
...
if (HEIC_INPUT_IMAGE_MIMES.has(sourceMime)) {
const normalizedBuffer = await convertHeicToJpeg(params.buffer);
return {
type: "image",
data: normalizedBuffer.toString("base64"),
mimeType: "image/jpeg",
};
}Call path showing this can affect operator-configured policies:
Because downstream code uses RecommendationEnforce the allowlist against the final delivered MIME type (post-normalization), or split configuration into separate allowlists for source vs delivered types. Minimal fix (validate output mime after normalization): const sourceMime = ...;
if (!params.limits.allowedMimes.has(sourceMime)) {
throw new Error(`Unsupported image MIME type: ${sourceMime}`);
}
if (HEIC_INPUT_IMAGE_MIMES.has(sourceMime)) {
const normalizedMime = NORMALIZED_INPUT_IMAGE_MIME; // image/jpeg
if (!params.limits.allowedMimes.has(normalizedMime)) {
throw new Error(`Unsupported normalized image MIME type: ${normalizedMime}`);
}
const normalizedBuffer = await convertHeicToJpeg(params.buffer);
...
return { type: "image", data: normalizedBuffer.toString("base64"), mimeType: normalizedMime };
}Also update docs/config help to clarify whether
If the intent is "allow HEIC but always normalize to JPEG", consider automatically requiring/adding Analyzed PR: #38122 at commit Last updated on: 2026-03-06T16:34:04Z |
Greptile SummaryThis PR adds HEIC/HEIF support to the Gateway HTTP Key observations:
Confidence Score: 3/5
Last reviewed commit: 9d14504 |
| async function normalizeInputImage(params: { | ||
| buffer: Buffer; | ||
| mimeType?: string; | ||
| limits: InputImageLimits; | ||
| }): Promise<InputImageContent> { | ||
| const sourceMime = | ||
| normalizeMimeType(await detectMime({ buffer: params.buffer, headerMime: params.mimeType })) ?? | ||
| normalizeMimeType(params.mimeType) ?? | ||
| "application/octet-stream"; | ||
| if (!params.limits.allowedMimes.has(sourceMime)) { | ||
| throw new Error(`Unsupported image MIME type: ${sourceMime}`); | ||
| } | ||
|
|
||
| if (!HEIC_INPUT_IMAGE_MIMES.has(sourceMime)) { | ||
| return { | ||
| type: "image", | ||
| data: params.buffer.toString("base64"), | ||
| mimeType: sourceMime, | ||
| }; | ||
| } | ||
|
|
||
| const normalizedBuffer = await convertHeicToJpeg(params.buffer); | ||
| if (normalizedBuffer.byteLength > params.limits.maxBytes) { | ||
| throw new Error( | ||
| `Image too large after HEIC conversion: ${normalizedBuffer.byteLength} bytes (limit: ${params.limits.maxBytes} bytes)`, | ||
| ); | ||
| } | ||
| return { | ||
| type: "image", | ||
| data: normalizedBuffer.toString("base64"), | ||
| mimeType: NORMALIZED_INPUT_IMAGE_MIME, | ||
| }; | ||
| } |
There was a problem hiding this comment.
detectMime sniffing now applies to all image types, not just HEIC
normalizeInputImage calls detectMime (which runs fileTypeFromBuffer magic-byte sniffing) for every image processed, not only for HEIC/HEIF. Previously the declared mediaType was trusted at face value; now the sniffed type takes precedence.
This creates a subtle behavioral change for non-HEIC images: if fileTypeFromBuffer returns a MIME type that differs from the declared one (e.g. a real PNG sent with mediaType: "image/webp"), the sniffed "image/png" will be used for the allowlist check and for the mimeType returned downstream. In the worst case, an existing caller whose declared type disagrees with the actual magic bytes could start receiving rejections.
The PR description explicitly scopes the change to HEIC handling. Consider guarding the detectMime call behind the HEIC check so that non-HEIC images continue to use the declared type:
async function normalizeInputImage(params: {
buffer: Buffer;
mimeType?: string;
limits: InputImageLimits;
}): Promise<InputImageContent> {
const declaredMime =
normalizeMimeType(params.mimeType) ?? "application/octet-stream";
// Only sniff for HEIC/HEIF to confirm actual format before conversion.
const sourceMime = HEIC_INPUT_IMAGE_MIMES.has(declaredMime)
? (normalizeMimeType(
await detectMime({ buffer: params.buffer, headerMime: params.mimeType }),
) ?? declaredMime)
: declaredMime;
if (!params.limits.allowedMimes.has(sourceMime)) {
throw new Error(`Unsupported image MIME type: ${sourceMime}`);
}
if (!HEIC_INPUT_IMAGE_MIMES.has(sourceMime)) {
return {
type: "image",
data: params.buffer.toString("base64"),
mimeType: sourceMime,
};
}
const normalizedBuffer = await convertHeicToJpeg(params.buffer);
if (normalizedBuffer.byteLength > params.limits.maxBytes) {
throw new Error(
`Image too large after HEIC conversion: ${normalizedBuffer.byteLength} bytes (limit: ${params.limits.maxBytes} bytes)`,
);
}
return {
type: "image",
data: normalizedBuffer.toString("base64"),
mimeType: NORMALIZED_INPUT_IMAGE_MIME,
};
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/media/input-files.ts
Line: 232-264
Comment:
**`detectMime` sniffing now applies to all image types, not just HEIC**
`normalizeInputImage` calls `detectMime` (which runs `fileTypeFromBuffer` magic-byte sniffing) for every image processed, not only for HEIC/HEIF. Previously the declared `mediaType` was trusted at face value; now the sniffed type takes precedence.
This creates a subtle behavioral change for non-HEIC images: if `fileTypeFromBuffer` returns a MIME type that differs from the declared one (e.g. a real PNG sent with `mediaType: "image/webp"`), the sniffed `"image/png"` will be used for the allowlist check and for the `mimeType` returned downstream. In the worst case, an existing caller whose declared type disagrees with the actual magic bytes could start receiving rejections.
The PR description explicitly scopes the change to HEIC handling. Consider guarding the `detectMime` call behind the HEIC check so that non-HEIC images continue to use the declared type:
```
async function normalizeInputImage(params: {
buffer: Buffer;
mimeType?: string;
limits: InputImageLimits;
}): Promise<InputImageContent> {
const declaredMime =
normalizeMimeType(params.mimeType) ?? "application/octet-stream";
// Only sniff for HEIC/HEIF to confirm actual format before conversion.
const sourceMime = HEIC_INPUT_IMAGE_MIMES.has(declaredMime)
? (normalizeMimeType(
await detectMime({ buffer: params.buffer, headerMime: params.mimeType }),
) ?? declaredMime)
: declaredMime;
if (!params.limits.allowedMimes.has(sourceMime)) {
throw new Error(`Unsupported image MIME type: ${sourceMime}`);
}
if (!HEIC_INPUT_IMAGE_MIMES.has(sourceMime)) {
return {
type: "image",
data: params.buffer.toString("base64"),
mimeType: sourceMime,
};
}
const normalizedBuffer = await convertHeicToJpeg(params.buffer);
if (normalizedBuffer.byteLength > params.limits.maxBytes) {
throw new Error(
`Image too large after HEIC conversion: ${normalizedBuffer.byteLength} bytes (limit: ${params.limits.maxBytes} bytes)`,
);
}
return {
type: "image",
data: normalizedBuffer.toString("base64"),
mimeType: NORMALIZED_INPUT_IMAGE_MIME,
};
}
```
How can I resolve this? If you propose a fix, please make it concise.| vi.mock("./image-ops.js", () => ({ | ||
| convertHeicToJpeg: (...args: unknown[]) => convertHeicToJpegMock(...args), | ||
| })); |
There was a problem hiding this comment.
Missing mock for detectMime / mime.js
normalizeInputImage (called by extractImageContentFromSource) now imports and calls detectMime from ./mime.js, which in turn invokes fileTypeFromBuffer from the file-type package. The test file mocks ./image-ops.js but leaves ./mime.js unmocked, so the real fileTypeFromBuffer runs against the fake test buffers (Buffer.from("heic-source"), Buffer.from("heic-url-source")).
The tests pass today because fileTypeFromBuffer returns undefined for unrecognised byte sequences, allowing detectMime to fall back to the supplied headerMime. However:
- If a future version of
file-typehappens to recognise the fake bytes as a known format, the MIME used in the allowlist check would change and the test would fail silently. - The unit under test (
extractImageContentFromSource) is no longer truly isolated from the MIME detection layer.
Adding a mock keeps the test hermetic:
const detectMimeMock = vi.fn();
vi.mock("./mime.js", () => ({
detectMime: (...args: unknown[]) => detectMimeMock(...args),
}));Then in the HEIC tests:
detectMimeMock.mockResolvedValue("image/heic");Prompt To Fix With AI
This is a comment left during a code review.
Path: src/media/input-files.fetch-guard.test.ts
Line: 10-12
Comment:
**Missing mock for `detectMime` / `mime.js`**
`normalizeInputImage` (called by `extractImageContentFromSource`) now imports and calls `detectMime` from `./mime.js`, which in turn invokes `fileTypeFromBuffer` from the `file-type` package. The test file mocks `./image-ops.js` but leaves `./mime.js` unmocked, so the real `fileTypeFromBuffer` runs against the fake test buffers (`Buffer.from("heic-source")`, `Buffer.from("heic-url-source")`).
The tests pass today because `fileTypeFromBuffer` returns `undefined` for unrecognised byte sequences, allowing `detectMime` to fall back to the supplied `headerMime`. However:
- If a future version of `file-type` happens to recognise the fake bytes as a known format, the MIME used in the allowlist check would change and the test would fail silently.
- The unit under test (`extractImageContentFromSource`) is no longer truly isolated from the MIME detection layer.
Adding a mock keeps the test hermetic:
```ts
const detectMimeMock = vi.fn();
vi.mock("./mime.js", () => ({
detectMime: (...args: unknown[]) => detectMimeMock(...args),
}));
```
Then in the HEIC tests:
```ts
detectMimeMock.mockResolvedValue("image/heic");
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9d14504ded
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| data: normalizedBuffer.toString("base64"), | ||
| mimeType: NORMALIZED_INPUT_IMAGE_MIME, |
There was a problem hiding this comment.
Recount normalized HEIC bytes against total image budget
extractImageContentFromSource now rewrites HEIC/HEIF base64 inputs to JPEG (normalizedBuffer.toString("base64")), but OpenAI request accounting still adds estimateBase64DecodedBytes(source.data) for base64 sources before extraction and never replaces that with the normalized size (src/gateway/openai-http.ts:302-314). This means maxTotalImageBytes can be bypassed whenever HEIC compresses much smaller than the converted JPEG (for example multiple small HEICs that expand after conversion), so requests that should be rejected can proceed with a much larger effective image payload.
Useful? React with 👍 / 👎.
* Media: normalize HEIC input images * Gateway: accept HEIC image input schema * Media: add HEIC input normalization tests * Gateway: cover HEIC input schema parity * Docs: document HEIC input image support * Changelog: note HEIC input image fix
* Media: normalize HEIC input images * Gateway: accept HEIC image input schema * Media: add HEIC input normalization tests * Gateway: cover HEIC input schema parity * Docs: document HEIC input image support * Changelog: note HEIC input image fix
* Media: normalize HEIC input images * Gateway: accept HEIC image input schema * Media: add HEIC input normalization tests * Gateway: cover HEIC input schema parity * Docs: document HEIC input image support * Changelog: note HEIC input image fix
* Media: normalize HEIC input images * Gateway: accept HEIC image input schema * Media: add HEIC input normalization tests * Gateway: cover HEIC input schema parity * Docs: document HEIC input image support * Changelog: note HEIC input image fix
* Media: normalize HEIC input images * Gateway: accept HEIC image input schema * Media: add HEIC input normalization tests * Gateway: cover HEIC input schema parity * Docs: document HEIC input image support * Changelog: note HEIC input image fix
* Media: normalize HEIC input images * Gateway: accept HEIC image input schema * Media: add HEIC input normalization tests * Gateway: cover HEIC input schema parity * Docs: document HEIC input image support * Changelog: note HEIC input image fix
* Media: normalize HEIC input images * Gateway: accept HEIC image input schema * Media: add HEIC input normalization tests * Gateway: cover HEIC input schema parity * Docs: document HEIC input image support * Changelog: note HEIC input image fix (cherry picked from commit 9aceb51)
* Media: normalize HEIC input images * Gateway: accept HEIC image input schema * Media: add HEIC input normalization tests * Gateway: cover HEIC input schema parity * Docs: document HEIC input image support * Changelog: note HEIC input image fix (cherry picked from commit 9aceb51)
* Media: normalize HEIC input images * Gateway: accept HEIC image input schema * Media: add HEIC input normalization tests * Gateway: cover HEIC input schema parity * Docs: document HEIC input image support * Changelog: note HEIC input image fix (cherry picked from commit 9aceb51)
Summary
input_imagehandling only acceptedjpeg/png/gif/webpand passed HEIC through inconsistently.input_imagesources now accept HEIC/HEIF, normalize them to JPEG before provider delivery, and ship with matching schema/docs/tests.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
input_imagenow acceptsimage/heicandimage/heifin the default image MIME allowlist.Security Impact (required)
Yes/No) NoYes/No) NoYes/No) NoYes/No) NoYes/No) NoYes, explain risk + mitigation:Repro + Verification
Environment
input_imagegateway.http.endpoints.responses.images.allowedMimesSteps
input_imagerequest using HEIC/HEIF base64 or URL input.Expected
Actual
Evidence
Attach at least one:
Human Verification (required)
What you personally verified (not just CI), and how:
Compatibility / Migration
Yes/No) YesYes/No) NoYes/No) NoFailure Recovery (if this breaks)
gateway.http.endpoints.responses.images.allowedMimesto excludeimage/heic/image/heif.src/media/input-files.ts,src/gateway/open-responses.schema.tsRisks and Mitigations