Reject unsupported js_repl image MIME types (#19292)

## Summary

`codex.emitImage` accepted arbitrary image MIME types for byte payloads
and data URLs. That allowed a value like `image/rgba` to be wrapped as
an `input_image`, even though it is not a supported encoded image
format, so the invalid image could reach the model-input path and
trigger output sanitization.

This results in a panic in debug builds because the output sanitization
is meant as a final safety net, not a primary means of rejecting invalid
image types. I've hit this case multiple times when executing certain
long-running tasks.

This PR rejects unsupported image MIME types before they are emitted
from `js_repl`.

## Changes

- Validate `codex.emitImage({ bytes, mimeType })` in the JS kernel so
only encoded PNG, JPEG, WebP, or GIF payloads are accepted.
- Apply the same MIME allowlist to direct image data URLs, including the
Rust host-side validation path.
- Clarify the JS REPL instructions so agents know byte payloads must
already be encoded as PNG/JPEG/WebP/GIF.
This commit is contained in:
Eric Traut
2026-04-24 00:14:51 -07:00
committed by GitHub
parent b68366718b
commit ac8c9fc49c
5 changed files with 118 additions and 5 deletions
+34
View File
@@ -1214,6 +1214,7 @@ function encodeByteImage(bytes, mimeType, detail) {
if (typeof mimeType !== "string" || !mimeType) {
throw new Error("codex.emitImage expected a non-empty mimeType");
}
assertEmitImageMimeType(mimeType);
const image_url = `data:${mimeType};base64,${Buffer.from(bytes).toString("base64")}`;
return { image_url, detail };
}
@@ -1240,9 +1241,42 @@ function normalizeEmitImageUrl(value) {
if (!/^data:/i.test(value)) {
throw new Error("codex.emitImage only accepts data URLs");
}
const mimeType = parseDataUrlMimeType(value);
assertEmitImageMimeType(mimeType);
return value;
}
const SUPPORTED_EMIT_IMAGE_MIME_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
];
function parseDataUrlMimeType(dataUrl) {
const commaIndex = dataUrl.indexOf(",");
if (commaIndex < 0) {
throw new Error("codex.emitImage expected a valid image data URL");
}
const mediaType = dataUrl.slice("data:".length, commaIndex).split(";")[0];
if (!mediaType) {
throw new Error("codex.emitImage expected image data URL to include a MIME type");
}
return mediaType;
}
function assertEmitImageMimeType(mimeType) {
const normalized = typeof mimeType === "string" ? mimeType.toLowerCase() : "";
if (!SUPPORTED_EMIT_IMAGE_MIME_TYPES.includes(normalized)) {
const supportedTypes = `${SUPPORTED_EMIT_IMAGE_MIME_TYPES.slice(0, -1).join(", ")}, or ${
SUPPORTED_EMIT_IMAGE_MIME_TYPES[SUPPORTED_EMIT_IMAGE_MIME_TYPES.length - 1]
}`;
throw new Error(
`codex.emitImage only supports ${supportedTypes}`,
);
}
}
function parseInputImageItem(value) {
if (!isPlainObject(value) || value.type !== "input_image") {
return null;
+19 -2
View File
@@ -1798,13 +1798,30 @@ fn emitted_image_content_item(
}
fn validate_emitted_image_url(image_url: &str) -> Result<(), String> {
if image_url
if !image_url
.get(..5)
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("data:"))
{
return Err("codex.emitImage only accepts data URLs".to_string());
}
let media_type = image_url
.split_once(',')
.and_then(|(header, _)| header.get(5..))
.and_then(|header| header.split(';').next())
.filter(|media_type| !media_type.is_empty())
.ok_or_else(|| "codex.emitImage expected a valid image data URL".to_string())?;
if matches!(
media_type.to_ascii_lowercase().as_str(),
"image/png" | "image/jpeg" | "image/webp" | "image/gif"
) {
Ok(())
} else {
Err("codex.emitImage only accepts data URLs".to_string())
Err(
"codex.emitImage only supports image/png, image/jpeg, image/webp, or image/gif"
.to_string(),
)
}
}
@@ -1619,6 +1619,55 @@ await codex.emitImage({ bytes: png });
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_emit_image_rejects_unsupported_byte_mime_type() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, turn) = make_session_and_context().await;
if !turn
.model_info
.input_modalities
.contains(&InputModality::Image)
{
return Ok(());
}
let session = Arc::new(session);
let turn = Arc::new(turn);
*session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let code = r#"
await codex.emitImage({
bytes: Buffer.from([255, 0, 0, 255]),
mimeType: "image/rgba",
});
"#;
let err = manager
.execute(
Arc::clone(&session),
turn,
tracker,
JsReplArgs {
code: code.to_string(),
timeout_ms: Some(15_000),
},
)
.await
.expect_err("unsupported byte MIME type should fail");
assert!(
err.to_string()
.contains("only supports image/png, image/jpeg, image/webp, or image/gif")
);
assert!(session.get_pending_input().await.is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_emit_image_rejects_non_data_url() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
@@ -1662,6 +1711,19 @@ await codex.emitImage("https://example.com/image.png");
Ok(())
}
#[test]
fn validate_emitted_image_url_rejects_unsupported_mime_type() {
assert_eq!(
validate_emitted_image_url("data:image/rgba;base64,AAAA").expect_err("unsupported MIME"),
"codex.emitImage only supports image/png, image/jpeg, image/webp, or image/gif"
);
}
#[test]
fn validate_emitted_image_url_accepts_supported_mime_type_case_insensitive() {
assert!(validate_emitted_image_url("DATA:image/PNG;base64,AAAA").is_ok());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_emit_image_accepts_case_insensitive_data_url() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {