Skip to content

Commit 09af1b6

Browse files
authored
fix(sandbox): add JSON bodies to proxy 403/502 responses and include port in HTTP log URLs (#809)
L4 CONNECT and forward-proxy denial responses (403) and upstream failure responses (502) were returned with empty bodies, making them indistinguishable from each other when using curl -s. Add a build_json_error_response() helper that produces consistent JSON bodies with error and detail fields, matching the format already used by the L7 deny path. Also fix Url::to_display_string() in openshell-ocsf to include non-default ports in the shorthand log output. Requests to e.g. http://host:9876/path were logged as http://host/path, dropping the port number. Closes #807, closes #808
1 parent 29a3b1c commit 09af1b6

File tree

3 files changed

+267
-13
lines changed

3 files changed

+267
-13
lines changed

crates/openshell-ocsf/src/format/shorthand.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,35 @@ mod tests {
616616
);
617617
}
618618

619+
#[test]
620+
fn test_http_activity_shorthand_non_default_port() {
621+
let event = OcsfEvent::HttpActivity(HttpActivityEvent {
622+
base: base(4002, "HTTP Activity", 4, "Network Activity", 3, "Get"),
623+
http_request: Some(HttpRequest::new(
624+
"GET",
625+
Url::new("http", "172.20.0.1", "/test", 9876),
626+
)),
627+
http_response: None,
628+
src_endpoint: None,
629+
dst_endpoint: None,
630+
proxy_endpoint: None,
631+
actor: Some(Actor {
632+
process: Process::new("curl", 68),
633+
}),
634+
firewall_rule: Some(FirewallRule::new("allow_host_9876", "mechanistic")),
635+
action: Some(ActionId::Allowed),
636+
disposition: None,
637+
observation_point_id: None,
638+
is_src_dst_assignment_known: None,
639+
});
640+
641+
let shorthand = event.format_shorthand();
642+
assert_eq!(
643+
shorthand,
644+
"HTTP:GET [INFO] ALLOWED curl(68) -> GET http://172.20.0.1:9876/test [policy:allow_host_9876]"
645+
);
646+
}
647+
619648
#[test]
620649
fn test_ssh_activity_shorthand() {
621650
let event = OcsfEvent::SshActivity(SshActivityEvent {

crates/openshell-ocsf/src/objects/http.rs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,21 @@ impl Url {
5858
}
5959

6060
/// Format as a display string.
61+
///
62+
/// Includes the port when it is present and differs from the scheme default
63+
/// (443 for `https`, 80 for `http`).
6164
#[must_use]
6265
pub fn to_display_string(&self) -> String {
6366
let scheme = self.scheme.as_deref().unwrap_or("https");
6467
let hostname = self.hostname.as_deref().unwrap_or("unknown");
6568
let path = self.path.as_deref().unwrap_or("/");
66-
format!("{scheme}://{hostname}{path}")
69+
let port_suffix = match self.port {
70+
Some(443) if scheme == "https" => String::new(),
71+
Some(80) if scheme == "http" => String::new(),
72+
Some(p) => format!(":{p}"),
73+
None => String::new(),
74+
};
75+
format!("{scheme}://{hostname}{port_suffix}{path}")
6776
}
6877
}
6978

@@ -94,9 +103,39 @@ mod tests {
94103
}
95104

96105
#[test]
97-
fn test_url_display_string() {
106+
fn test_url_display_string_default_port() {
98107
let url = Url::new("https", "api.example.com", "/v1/data", 443);
99108
assert_eq!(url.to_display_string(), "https://api.example.com/v1/data");
109+
110+
let url = Url::new("http", "example.com", "/index", 80);
111+
assert_eq!(url.to_display_string(), "http://example.com/index");
112+
}
113+
114+
#[test]
115+
fn test_url_display_string_non_default_port() {
116+
let url = Url::new("http", "172.20.0.1", "/test", 9876);
117+
assert_eq!(url.to_display_string(), "http://172.20.0.1:9876/test");
118+
119+
let url = Url::new("https", "api.example.com", "/v1/data", 8443);
120+
assert_eq!(
121+
url.to_display_string(),
122+
"https://api.example.com:8443/v1/data"
123+
);
124+
125+
// HTTP on 443 is non-default — should show port
126+
let url = Url::new("http", "example.com", "/path", 443);
127+
assert_eq!(url.to_display_string(), "http://example.com:443/path");
128+
}
129+
130+
#[test]
131+
fn test_url_display_string_no_port() {
132+
let url = Url {
133+
scheme: Some("https".to_string()),
134+
hostname: Some("example.com".to_string()),
135+
path: Some("/path".to_string()),
136+
port: None,
137+
};
138+
assert_eq!(url.to_display_string(), "https://example.com/path");
100139
}
101140

102141
#[test]

crates/openshell-sandbox/src/proxy.rs

Lines changed: 197 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,16 @@ async fn handle_tcp_connection(
464464
&deny_reason,
465465
"connect",
466466
);
467-
respond(&mut client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
467+
respond(
468+
&mut client,
469+
&build_json_error_response(
470+
403,
471+
"Forbidden",
472+
"policy_denied",
473+
&format!("CONNECT {host_lc}:{port} not permitted by policy"),
474+
),
475+
)
476+
.await?;
468477
return Ok(());
469478
}
470479

@@ -519,7 +528,16 @@ async fn handle_tcp_connection(
519528
&reason,
520529
"ssrf",
521530
);
522-
respond(&mut client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
531+
respond(
532+
&mut client,
533+
&build_json_error_response(
534+
403,
535+
"Forbidden",
536+
"ssrf_denied",
537+
&format!("CONNECT {host_lc}:{port} blocked: allowed_ips check failed"),
538+
),
539+
)
540+
.await?;
523541
return Ok(());
524542
}
525543
},
@@ -554,7 +572,16 @@ async fn handle_tcp_connection(
554572
&reason,
555573
"ssrf",
556574
);
557-
respond(&mut client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
575+
respond(
576+
&mut client,
577+
&build_json_error_response(
578+
403,
579+
"Forbidden",
580+
"ssrf_denied",
581+
&format!("CONNECT {host_lc}:{port} blocked: invalid allowed_ips in policy"),
582+
),
583+
)
584+
.await?;
558585
return Ok(());
559586
}
560587
}
@@ -595,7 +622,16 @@ async fn handle_tcp_connection(
595622
&reason,
596623
"ssrf",
597624
);
598-
respond(&mut client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
625+
respond(
626+
&mut client,
627+
&build_json_error_response(
628+
403,
629+
"Forbidden",
630+
"ssrf_denied",
631+
&format!("CONNECT {host_lc}:{port} blocked: internal address"),
632+
),
633+
)
634+
.await?;
599635
return Ok(());
600636
}
601637
}
@@ -2040,7 +2076,16 @@ async fn handle_forward_proxy(
20402076
reason,
20412077
"forward",
20422078
);
2043-
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
2079+
respond(
2080+
client,
2081+
&build_json_error_response(
2082+
403,
2083+
"Forbidden",
2084+
"policy_denied",
2085+
&format!("{method} {host_lc}:{port}{path} not permitted by policy"),
2086+
),
2087+
)
2088+
.await?;
20442089
return Ok(());
20452090
}
20462091
};
@@ -2168,7 +2213,16 @@ async fn handle_forward_proxy(
21682213
&reason,
21692214
"forward-l7-deny",
21702215
);
2171-
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
2216+
respond(
2217+
client,
2218+
&build_json_error_response(
2219+
403,
2220+
"Forbidden",
2221+
"policy_denied",
2222+
&format!("{method} {host_lc}:{port}{path} denied by L7 policy"),
2223+
),
2224+
)
2225+
.await?;
21722226
return Ok(());
21732227
}
21742228
}
@@ -2224,7 +2278,16 @@ async fn handle_forward_proxy(
22242278
&reason,
22252279
"ssrf",
22262280
);
2227-
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
2281+
respond(
2282+
client,
2283+
&build_json_error_response(
2284+
403,
2285+
"Forbidden",
2286+
"ssrf_denied",
2287+
&format!("{method} {host_lc}:{port} blocked: allowed_ips check failed"),
2288+
),
2289+
)
2290+
.await?;
22282291
return Ok(());
22292292
}
22302293
},
@@ -2263,7 +2326,18 @@ async fn handle_forward_proxy(
22632326
&reason,
22642327
"ssrf",
22652328
);
2266-
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
2329+
respond(
2330+
client,
2331+
&build_json_error_response(
2332+
403,
2333+
"Forbidden",
2334+
"ssrf_denied",
2335+
&format!(
2336+
"{method} {host_lc}:{port} blocked: invalid allowed_ips in policy"
2337+
),
2338+
),
2339+
)
2340+
.await?;
22672341
return Ok(());
22682342
}
22692343
}
@@ -2306,7 +2380,16 @@ async fn handle_forward_proxy(
23062380
&reason,
23072381
"ssrf",
23082382
);
2309-
respond(client, b"HTTP/1.1 403 Forbidden\r\n\r\n").await?;
2383+
respond(
2384+
client,
2385+
&build_json_error_response(
2386+
403,
2387+
"Forbidden",
2388+
"ssrf_denied",
2389+
&format!("{method} {host_lc}:{port} blocked: internal address"),
2390+
),
2391+
)
2392+
.await?;
23102393
return Ok(());
23112394
}
23122395
}
@@ -2335,7 +2418,16 @@ async fn handle_forward_proxy(
23352418
))
23362419
.build();
23372420
ocsf_emit!(event);
2338-
respond(client, b"HTTP/1.1 502 Bad Gateway\r\n\r\n").await?;
2421+
respond(
2422+
client,
2423+
&build_json_error_response(
2424+
502,
2425+
"Bad Gateway",
2426+
"upstream_unreachable",
2427+
&format!("connection to {host_lc}:{port} failed"),
2428+
),
2429+
)
2430+
.await?;
23392431
return Ok(());
23402432
}
23412433
};
@@ -2374,7 +2466,16 @@ async fn handle_forward_proxy(
23742466
error = %e,
23752467
"credential injection failed in forward proxy"
23762468
);
2377-
respond(client, b"HTTP/1.1 500 Internal Server Error\r\n\r\n").await?;
2469+
respond(
2470+
client,
2471+
&build_json_error_response(
2472+
500,
2473+
"Internal Server Error",
2474+
"credential_injection_failed",
2475+
"unresolved credential placeholder in request",
2476+
),
2477+
)
2478+
.await?;
23782479
return Ok(());
23792480
}
23802481
};
@@ -2403,6 +2504,30 @@ async fn respond(client: &mut TcpStream, bytes: &[u8]) -> Result<()> {
24032504
Ok(())
24042505
}
24052506

2507+
/// Build an HTTP error response with a JSON body.
2508+
///
2509+
/// Returns bytes ready to write to the client socket. The body is a JSON
2510+
/// object with `error` and `detail` fields, matching the format used by the
2511+
/// L7 deny path in `l7/rest.rs`.
2512+
fn build_json_error_response(status: u16, status_text: &str, error: &str, detail: &str) -> Vec<u8> {
2513+
let body = serde_json::json!({
2514+
"error": error,
2515+
"detail": detail,
2516+
});
2517+
let body_str = body.to_string();
2518+
format!(
2519+
"HTTP/1.1 {status} {status_text}\r\n\
2520+
Content-Type: application/json\r\n\
2521+
Content-Length: {}\r\n\
2522+
Connection: close\r\n\
2523+
\r\n\
2524+
{}",
2525+
body_str.len(),
2526+
body_str,
2527+
)
2528+
.into_bytes()
2529+
}
2530+
24062531
/// Check if a miette error represents a benign connection close.
24072532
///
24082533
/// TLS handshake EOF, missing `close_notify`, connection resets, and broken
@@ -3355,4 +3480,65 @@ mod tests {
33553480
let result = implicit_allowed_ips_for_ip_host("*.example.com");
33563481
assert!(result.is_empty());
33573482
}
3483+
3484+
// -- build_json_error_response --
3485+
3486+
#[test]
3487+
fn test_json_error_response_403() {
3488+
let resp = build_json_error_response(
3489+
403,
3490+
"Forbidden",
3491+
"policy_denied",
3492+
"CONNECT api.example.com:443 not permitted by policy",
3493+
);
3494+
let resp_str = String::from_utf8(resp).unwrap();
3495+
3496+
assert!(resp_str.starts_with("HTTP/1.1 403 Forbidden\r\n"));
3497+
assert!(resp_str.contains("Content-Type: application/json\r\n"));
3498+
assert!(resp_str.contains("Connection: close\r\n"));
3499+
3500+
// Extract body after \r\n\r\n
3501+
let body_start = resp_str.find("\r\n\r\n").unwrap() + 4;
3502+
let body: serde_json::Value = serde_json::from_str(&resp_str[body_start..]).unwrap();
3503+
assert_eq!(body["error"], "policy_denied");
3504+
assert_eq!(
3505+
body["detail"],
3506+
"CONNECT api.example.com:443 not permitted by policy"
3507+
);
3508+
}
3509+
3510+
#[test]
3511+
fn test_json_error_response_502() {
3512+
let resp = build_json_error_response(
3513+
502,
3514+
"Bad Gateway",
3515+
"upstream_unreachable",
3516+
"connection to api.example.com:443 failed",
3517+
);
3518+
let resp_str = String::from_utf8(resp).unwrap();
3519+
3520+
assert!(resp_str.starts_with("HTTP/1.1 502 Bad Gateway\r\n"));
3521+
3522+
let body_start = resp_str.find("\r\n\r\n").unwrap() + 4;
3523+
let body: serde_json::Value = serde_json::from_str(&resp_str[body_start..]).unwrap();
3524+
assert_eq!(body["error"], "upstream_unreachable");
3525+
assert_eq!(body["detail"], "connection to api.example.com:443 failed");
3526+
}
3527+
3528+
#[test]
3529+
fn test_json_error_response_content_length_matches() {
3530+
let resp = build_json_error_response(403, "Forbidden", "test", "detail");
3531+
let resp_str = String::from_utf8(resp).unwrap();
3532+
3533+
// Extract Content-Length value
3534+
let cl_line = resp_str
3535+
.lines()
3536+
.find(|l| l.starts_with("Content-Length:"))
3537+
.unwrap();
3538+
let cl: usize = cl_line.split(": ").nth(1).unwrap().trim().parse().unwrap();
3539+
3540+
// Verify body length matches
3541+
let body_start = resp_str.find("\r\n\r\n").unwrap() + 4;
3542+
assert_eq!(resp_str[body_start..].len(), cl);
3543+
}
33583544
}

0 commit comments

Comments
 (0)