Harden js_repl emitImage to accept only data: URLs (#13507)

### Motivation

- Prevent untrusted js_repl code from supplying arbitrary external URLs
that the host would forward into model input and cause external fetches
/ data exfiltration. This change narrows the emitImage contract to safe,
self-contained data URLs.

### Description

- Kernel: added `normalizeEmitImageUrl` and enforce that string-valued
`codex.emitImage(...)` inputs and `input_image`/content-item paths only
accept non-empty `data:` URLs; byte-based paths still produce data URLs
as before (`kernel.js`).
- Host: added `validate_emitted_image_url` and check `EmitImage`
requests before creating `FunctionCallOutputContentItem::InputImage`,
returning an error to the kernel if the URL is not a `data:` URL
(`mod.rs`).
- Tests/docs: added a runtime test
`js_repl_emit_image_rejects_non_data_url` to assert rejection of
non-data URLs and updated user-facing docs/instruction text to state
`data URL` support instead of generic direct image URLs (`mod.rs`,
`docs/js_repl.md`, `project_doc.rs`).

### Testing

- Ran `just fmt` in `codex-rs`; it completed successfully.
- Added a runtime test (`cargo test -p codex-core
js_repl_emit_image_rejects_non_data_url`) but executing the test in this
environment failed due to a missing system dependency required by
`codex-linux-sandbox` (the vendored `bubblewrap` build requires
`libcap.pc` via `pkg-config`), so the test could not be run here.
- Attempted a focused `cargo test` invocation with and without default
features; both compile/test attempts were blocked by the same missing
system `libcap` dependency in this environment.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_i_69a7837bce98832d91db92d5f76d6cbe)
This commit is contained in:
Curtis 'Fjord' Hawthorne
2026-03-05 12:12:32 -08:00
committed by GitHub
Unverified
parent a63624a61a
commit cfbbbb1dda
4 changed files with 168 additions and 34 deletions
+19 -13
View File
@@ -1228,15 +1228,27 @@ function parseImageDetail(detail) {
return detail;
}
function normalizeEmitImageUrl(value) {
if (typeof value !== "string" || !value) {
throw new Error("codex.emitImage expected a non-empty image_url");
}
if (!/^data:/i.test(value)) {
throw new Error("codex.emitImage only accepts data URLs");
}
return value;
}
function parseInputImageItem(value) {
if (!isPlainObject(value) || value.type !== "input_image") {
return null;
}
if (typeof value.image_url !== "string" || !value.image_url) {
throw new Error("codex.emitImage expected a non-empty image_url");
}
return {
images: [{ image_url: value.image_url, detail: parseImageDetail(value.detail) }],
images: [
{
image_url: normalizeEmitImageUrl(value.image_url),
detail: parseImageDetail(value.detail),
},
],
textCount: 0,
};
}
@@ -1253,11 +1265,8 @@ function parseContentItems(items) {
throw new Error("codex.emitImage received malformed content items");
}
if (item.type === "input_image") {
if (typeof item.image_url !== "string" || !item.image_url) {
throw new Error("codex.emitImage expected a non-empty image_url");
}
images.push({
image_url: item.image_url,
image_url: normalizeEmitImageUrl(item.image_url),
detail: parseImageDetail(item.detail),
});
continue;
@@ -1308,7 +1317,7 @@ function normalizeMcpImageData(data, mimeType) {
if (typeof data !== "string" || !data) {
throw new Error("codex.emitImage expected MCP image data");
}
if (data.startsWith("data:")) {
if (/^data:/i.test(data)) {
return data;
}
const normalizedMimeType =
@@ -1375,10 +1384,7 @@ function requireSingleImage(parsed) {
function normalizeEmitImageValue(value) {
if (typeof value === "string") {
if (!value) {
throw new Error("codex.emitImage expected a non-empty image URL");
}
return { image_url: value };
return { image_url: normalizeEmitImageUrl(value) };
}
const directItem = parseInputImageItem(value);
+144 -16
View File
@@ -1134,22 +1134,31 @@ impl JsReplManager {
let emit_id = req.id.clone();
let response =
if let Some(ctx) = exec_contexts.lock().await.get(&exec_id).cloned() {
let content_item = emitted_image_content_item(
ctx.turn.as_ref(),
req.image_url,
req.detail,
);
JsReplManager::record_exec_content_item(
&exec_tool_calls,
&exec_id,
content_item,
)
.await;
HostToKernel::EmitImageResult(EmitImageResult {
id: emit_id,
ok: true,
error: None,
})
match validate_emitted_image_url(&req.image_url) {
Ok(()) => {
let content_item = emitted_image_content_item(
ctx.turn.as_ref(),
req.image_url,
req.detail,
);
JsReplManager::record_exec_content_item(
&exec_tool_calls,
&exec_id,
content_item,
)
.await;
HostToKernel::EmitImageResult(EmitImageResult {
id: emit_id,
ok: true,
error: None,
})
}
Err(error) => HostToKernel::EmitImageResult(EmitImageResult {
id: emit_id,
ok: false,
error: Some(error),
}),
}
} else {
HostToKernel::EmitImageResult(EmitImageResult {
id: emit_id,
@@ -1467,6 +1476,17 @@ fn emitted_image_content_item(
}
}
fn validate_emitted_image_url(image_url: &str) -> Result<(), String> {
if image_url
.get(..5)
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("data:"))
{
Ok(())
} else {
Err("codex.emitImage only accepts data URLs".to_string())
}
}
fn default_output_image_detail_for_turn(turn: &TurnContext) -> Option<ImageDetail> {
(turn.config.features.enabled(Feature::ImageDetailOriginal)
&& turn.model_info.supports_image_detail_original)
@@ -2005,6 +2025,22 @@ mod tests {
);
}
#[test]
fn validate_emitted_image_url_accepts_case_insensitive_data_scheme() {
assert_eq!(
validate_emitted_image_url("DATA:image/png;base64,AAA"),
Ok(())
);
}
#[test]
fn validate_emitted_image_url_rejects_non_data_scheme() {
assert_eq!(
validate_emitted_image_url("https://example.com/image.png"),
Err("codex.emitImage only accepts data URLs".to_string())
);
}
#[test]
fn summarize_tool_call_response_for_multimodal_custom_output() {
let response = ResponseInputItem::CustomToolCallOutput {
@@ -2905,6 +2941,98 @@ await codex.emitImage({ bytes: png });
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 {
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("https://example.com/image.png");
"#;
let err = manager
.execute(
Arc::clone(&session),
turn,
tracker,
JsReplArgs {
code: code.to_string(),
timeout_ms: Some(15_000),
},
)
.await
.expect_err("non-data URLs should fail");
assert!(err.to_string().contains("only accepts data URLs"));
assert!(session.get_pending_input().await.is_empty());
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 {
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("DATA:image/png;base64,AAA");
"#;
let result = manager
.execute(
Arc::clone(&session),
turn,
tracker,
JsReplArgs {
code: code.to_string(),
timeout_ms: Some(15_000),
},
)
.await?;
assert_eq!(
result.content_items.as_slice(),
[FunctionCallOutputContentItem::InputImage {
image_url: "DATA:image/png;base64,AAA".to_string(),
detail: None,
}]
.as_slice()
);
assert!(session.get_pending_input().await.is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_emit_image_rejects_invalid_detail() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {