Skip to content

Commit 61d28dd

Browse files
committed
test(replay): lock down SSRF loopback denies for web_fetch+a2a_discover
1 parent d8c4059 commit 61d28dd

3 files changed

Lines changed: 185 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[
2+
{
3+
"choices": [
4+
{
5+
"index": 0,
6+
"message": {
7+
"role": "assistant",
8+
"content": null,
9+
"tool_calls": [
10+
{
11+
"id": "call_1",
12+
"type": "function",
13+
"function": {
14+
"name": "a2a_discover",
15+
"arguments": "{\"url\":\"http://127.0.0.1/\"}"
16+
}
17+
}
18+
]
19+
},
20+
"finish_reason": "tool_calls"
21+
}
22+
]
23+
}
24+
]
25+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
[
2+
{
3+
"choices": [
4+
{
5+
"index": 0,
6+
"message": {
7+
"role": "assistant",
8+
"content": null,
9+
"tool_calls": [
10+
{
11+
"id": "call_1",
12+
"type": "function",
13+
"function": {
14+
"name": "web_fetch",
15+
"arguments": "{\"url\":\"http://127.0.0.1/\"}"
16+
}
17+
}
18+
]
19+
},
20+
"finish_reason": "tool_calls"
21+
}
22+
]
23+
}
24+
]
25+

crates/rexos/tests/session_replay.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,3 +1070,138 @@ async fn replay_fixture_blocks_a2a_discover_egress_host_mismatches() {
10701070

10711071
server.abort();
10721072
}
1073+
1074+
#[tokio::test]
1075+
#[serial]
1076+
async fn replay_fixture_denies_web_fetch_loopback_when_allow_private_false() {
1077+
// Ensure this test doesn't depend on external approval-mode env.
1078+
let _mode = EnvVarGuard::set("LOOPFORGE_APPROVAL_MODE", "off");
1079+
let _allow = EnvVarGuard::set("LOOPFORGE_APPROVAL_ALLOW", "");
1080+
1081+
let fixture = support::openai_compat_fixture::load_json_array(include_str!(
1082+
"fixtures/replay/session_ssrf_web_fetch_loopback_denied.json"
1083+
));
1084+
let server = support::openai_compat_fixture::FixtureServer::spawn(fixture).await;
1085+
1086+
let tmp = tempfile::tempdir().unwrap();
1087+
let (agent, _paths, workspace_root) = fixture_agent(
1088+
&tmp,
1089+
server.base_url.clone(),
1090+
rexos::security::SecurityConfig::default(),
1091+
);
1092+
1093+
let session_id = "s-replay-ssrf-web-fetch";
1094+
agent
1095+
.set_session_allowed_tools(session_id, vec!["web_fetch".to_string()])
1096+
.unwrap();
1097+
1098+
let err = agent
1099+
.run_session(
1100+
workspace_root,
1101+
session_id,
1102+
None,
1103+
"fetch loopback",
1104+
TaskKind::Coding,
1105+
)
1106+
.await
1107+
.unwrap_err();
1108+
let err_text = err.to_string();
1109+
assert!(
1110+
err_text.contains("loopback/private address"),
1111+
"expected loopback/private deny, got: {err_text}"
1112+
);
1113+
assert!(
1114+
err_text.contains("web_fetch"),
1115+
"expected tool name in error, got: {err_text}"
1116+
);
1117+
1118+
let requests = server.requests.lock().unwrap().clone();
1119+
assert_eq!(requests.len(), 1, "expected one chat completions call");
1120+
assert_eq!(
1121+
compact_request(&requests[0]),
1122+
json!({
1123+
"model": "fixture-model",
1124+
"temperature": 0.0,
1125+
"tools": [{
1126+
"name": "web_fetch",
1127+
"type": "function",
1128+
"param_type": "object",
1129+
"required": ["url"],
1130+
"properties": ["allow_private", "max_bytes", "timeout_ms", "url"],
1131+
"additional_properties": false,
1132+
}],
1133+
"message_roles": ["user"],
1134+
"assistant_tool_calls": [],
1135+
"tool_messages": [],
1136+
})
1137+
);
1138+
1139+
server.abort();
1140+
}
1141+
1142+
#[tokio::test]
1143+
#[serial]
1144+
async fn replay_fixture_denies_a2a_discover_loopback_when_allow_private_false() {
1145+
let _mode = EnvVarGuard::set("LOOPFORGE_APPROVAL_MODE", "off");
1146+
let _allow = EnvVarGuard::set("LOOPFORGE_APPROVAL_ALLOW", "");
1147+
1148+
let fixture = support::openai_compat_fixture::load_json_array(include_str!(
1149+
"fixtures/replay/session_ssrf_a2a_discover_loopback_denied.json"
1150+
));
1151+
let server = support::openai_compat_fixture::FixtureServer::spawn(fixture).await;
1152+
1153+
let tmp = tempfile::tempdir().unwrap();
1154+
let (agent, _paths, workspace_root) = fixture_agent(
1155+
&tmp,
1156+
server.base_url.clone(),
1157+
rexos::security::SecurityConfig::default(),
1158+
);
1159+
1160+
let session_id = "s-replay-ssrf-a2a-discover";
1161+
agent
1162+
.set_session_allowed_tools(session_id, vec!["a2a_discover".to_string()])
1163+
.unwrap();
1164+
1165+
let err = agent
1166+
.run_session(
1167+
workspace_root,
1168+
session_id,
1169+
None,
1170+
"discover loopback",
1171+
TaskKind::Coding,
1172+
)
1173+
.await
1174+
.unwrap_err();
1175+
let err_text = err.to_string();
1176+
assert!(
1177+
err_text.contains("loopback/private address"),
1178+
"expected loopback/private deny, got: {err_text}"
1179+
);
1180+
assert!(
1181+
err_text.contains("a2a_discover"),
1182+
"expected tool name in error, got: {err_text}"
1183+
);
1184+
1185+
let requests = server.requests.lock().unwrap().clone();
1186+
assert_eq!(requests.len(), 1, "expected one chat completions call");
1187+
assert_eq!(
1188+
compact_request(&requests[0]),
1189+
json!({
1190+
"model": "fixture-model",
1191+
"temperature": 0.0,
1192+
"tools": [{
1193+
"name": "a2a_discover",
1194+
"type": "function",
1195+
"param_type": "object",
1196+
"required": ["url"],
1197+
"properties": ["allow_private", "url"],
1198+
"additional_properties": false,
1199+
}],
1200+
"message_roles": ["user"],
1201+
"assistant_tool_calls": [],
1202+
"tool_messages": [],
1203+
})
1204+
);
1205+
1206+
server.abort();
1207+
}

0 commit comments

Comments
 (0)