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