mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user