fix(security): session-gate web search + screen forwarder egress (S3/S4)

S4: /aiui/api/web-search proxied straight to SearXNG with no auth — anyone
reaching the web port ran searches attributed to the node's IP. Now routed
through the daemon's session-gated model proxy like the claude/ollama legs
(both nginx server blocks), forcing format=json upstream (the client never
sent it — search could 200 with HTML that parsed as nothing).

S3: the forwarder also serves the STANDALONE frontend, whose bodies carry
full history/images with no assistant loop behind them — a pasted seed
phrase went to Anthropic unscreened. The forwarder now runs the egress
secret-shape scan (G-B1) with the node's own secrets dir as deny corpus on
Claude bodies and search queries; blocked requests get a plain-language 400.

Also fixes a REAL gap in the egress tokenizer found by these tests: a JSON
key glued to a string value's first word ('content":"abandon...') dropped
that word, so an exactly-12-word seed pasted as a bare message yielded an
11-member run — checksum misses, backstop misses. Non-member words now
rescan within the token. egress 15/15 + model_proxy 10/10 green.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 08:03:07 -04:00
co-authored by Claude
parent d746fbd812
commit d0c9ea6e61
4 changed files with 241 additions and 20 deletions
+1 -1
View File
@@ -441,7 +441,7 @@ impl ApiHandler {
// from the cookie inside handle_model_proxy — it does not trust
// nginx to have gated the request already, the same "don't trust
// the front door" discipline as /lnd-connect-info below.
(_, p) if p.starts_with("/aiui/api/claude/") || p.starts_with("/aiui/api/ollama/") => {
(_, p) if p.starts_with("/aiui/api/claude/") || p.starts_with("/aiui/api/ollama/") || p.starts_with("/aiui/api/web-search") => {
self.handle_model_proxy(req_with_bytes, p).await
}
@@ -29,6 +29,11 @@ const CLAUDE_UPSTREAM: &str = "https://api.anthropic.com/";
/// Local Ollama. No key — the session gate exists purely to stop anonymous
/// consumption of local GPU/CPU inference (T-13-11), not to protect a secret.
const OLLAMA_UPSTREAM: &str = "http://127.0.0.1:11434/";
/// Local SearXNG. Same gate rationale as Ollama: anonymous web search
/// through this endpoint attributes arbitrary queries to the node's IP at
/// external engines (S4 — the old nginx location proxied straight to :8888
/// with no session check at all).
const SEARXNG_UPSTREAM: &str = "http://127.0.0.1:8888/";
/// Generous enough for a multi-turn tool-call round trip; `mesh/listener/
/// assist.rs`'s OLLAMA_TIMEOUT (60s) is airtime-tuned for LoRa and not
/// reusable here — this path has no such constraint (13-AI-SPEC.md Pitfall 6).
@@ -66,6 +71,8 @@ async fn route_model_proxy(
forward_claude(req, rest, data_dir).await
} else if let Some(rest) = path.strip_prefix("/aiui/api/ollama/") {
forward_ollama(req, rest).await
} else if path.starts_with("/aiui/api/web-search") {
forward_web_search(req, data_dir).await
} else {
// Unreachable given the caller's prefix match in mod.rs, but never
// fall through to an unauthenticated 200 on an unrecognized path.
@@ -107,6 +114,32 @@ fn key_not_configured() -> Response<Body> {
.unwrap_or_else(|_| Response::new(Body::from("Claude is not configured")))
}
/// S3: a forwarded body/query carried secret-shaped content (BIP39 words,
/// key/token shapes, or a literal value from this node's secrets dir). The
/// backends' egress screen never sees the forwarder path — the standalone
/// frontend posts FULL history and images straight here — so the forwarder
/// screens for itself. 400, plain-language, never naming what matched.
fn blocked_secret_shaped() -> Response<Body> {
let body = serde_json::json!({
"error": "Blocked: this request contained secret-shaped content (e.g. a seed phrase, key, or token). It was not sent anywhere."
});
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
.unwrap_or_else(|_| Response::new(Body::from("Blocked: secret-shaped content")))
}
/// Screen a string about to leave the node through the forwarder against
/// the assistant's secret-shape rules (G-B1) with this node's own secrets
/// as the deny corpus. Returns Some(kind) — kind only, never the value —
/// when the content must not leave.
async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> {
let secrets =
crate::assistant::egress::load_known_secrets(&data_dir.join("secrets")).await;
crate::assistant::egress::scan_secret_shapes(text, &secrets)
}
fn bad_gateway(msg: &str) -> Response<Body> {
let body = serde_json::json!({ "error": msg });
Response::builder()
@@ -128,6 +161,20 @@ async fn forward_claude(req: Request<Body>, rest: &str, data_dir: &Path) -> Resu
return Ok(key_not_configured());
}
};
// S3: screen the outbound body before it leaves. The forwarder also
// serves the STANDALONE frontend, whose requests carry full history and
// base64 images with no assistant loop (and no egress screen) behind
// them — an operator pasting a seed phrase into standalone chat would
// otherwise send it straight to Anthropic.
let (parts, body) = req.into_parts();
let payload = hyper::body::to_bytes(body)
.await
.map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
if let Some(kind) = forward_screen(&String::from_utf8_lossy(&payload), data_dir).await {
tracing::error!(kind, "model proxy: blocked claude forward — secret-shaped content");
return Ok(blocked_secret_shaped());
}
let req = Request::from_parts(parts, Body::from(payload));
forward(
req,
rest,
@@ -147,6 +194,59 @@ async fn forward_ollama(req: Request<Body>, rest: &str) -> Result<Response<Body>
forward(req, rest, OLLAMA_UPSTREAM, "127.0.0.1:11434", &[]).await
}
/// Build the upstream SearXNG path from the inbound query string, forcing
/// `format=json` — the AIUI client speaks only JSON, SearXNG answers HTML
/// unless asked, and the old nginx location passed the query through
/// untouched, so "web search" could 200 with a page that parsed as nothing.
/// A caller-supplied `format=` is stripped first so it cannot win.
fn web_search_upstream_path(query: &str) -> String {
let kept: Vec<&str> = query
.split('&')
.filter(|pair| !pair.is_empty() && !pair.starts_with("format="))
.collect();
if kept.is_empty() {
"search?format=json".to_string()
} else {
format!("search?{}&format=json", kept.join("&"))
}
}
/// Forward an already-authenticated GET to the node's local SearXNG. GET
/// only; 30s is plenty for a metasearch round trip and keeps a wedged
/// upstream from pinning a daemon task. The query is screened (S3): SearXNG
/// fans it out to upstream engines, so a pasted seed phrase would leave the
/// node here exactly as surely as in a Claude body.
async fn forward_web_search(req: Request<Body>, data_dir: &Path) -> Result<Response<Body>> {
if req.method() != Method::GET {
return Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.header("Content-Type", "application/json")
.body(Body::from("{\"error\":\"GET only\"}"))
.unwrap_or_else(|_| Response::new(Body::from("GET only"))));
}
let query = req.uri().query().unwrap_or_default();
// Minimal decode for the word-shape scan: percent-encoded spaces and
// `+` are how a pasted phrase's words separate inside a query string.
let decoded = query.replace('+', " ").replace("%20", " ");
if let Some(kind) = forward_screen(&decoded, data_dir).await {
tracing::error!(kind, "model proxy: blocked web-search query — secret-shaped content");
return Ok(blocked_secret_shaped());
}
let rest = web_search_upstream_path(query);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| anyhow::anyhow!("client build: {e}"))?;
let url = format!("{}{}", SEARXNG_UPSTREAM, rest);
match client.get(&url).send().await {
Ok(resp) => stream_response(resp),
Err(e) => {
tracing::warn!("model proxy: searxng upstream failed: {}", e);
Ok(bad_gateway("web search upstream unavailable"))
}
}
}
/// Shared forwarding core for both backends. Copies ONLY the inbound
/// `content-type`/`accept` request headers plus whatever `extra_headers`
/// the caller supplies (the Claude key + version pin) — the inbound
@@ -294,6 +394,27 @@ mod tests {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn web_search_without_session_is_401() {
let store = test_store().await;
let data_dir = tempfile::tempdir().unwrap();
let req = req_with_cookie("GET", "/aiui/api/web-search?q=bitcoin", None);
let resp = route_model_proxy(&store, data_dir.path(), req, "/aiui/api/web-search")
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn web_search_query_forces_json_and_strips_format() {
assert_eq!(web_search_upstream_path("q=bitcoin"), "search?q=bitcoin&format=json");
assert_eq!(
web_search_upstream_path("q=bitcoin+halving&format=html"),
"search?q=bitcoin+halving&format=json"
);
assert_eq!(web_search_upstream_path(""), "search?format=json");
}
#[tokio::test]
async fn claude_with_invalid_session_is_401() {
let store = test_store().await;
@@ -332,6 +453,74 @@ mod tests {
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
fn req_with_cookie_and_body(method: &str, path: &str, cookie: Option<&str>, body: &'static str) -> Request<Body> {
let mut builder = Request::builder().method(method).uri(path);
if let Some(c) = cookie {
builder = builder.header("cookie", format!("session={c}"));
}
builder.body(Body::from(body)).unwrap()
}
async fn store_and_keyed_dir() -> (SessionStore, tempfile::TempDir) {
let store = test_store().await;
let data_dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(data_dir.path().join("secrets")).unwrap();
std::fs::write(
data_dir.path().join("secrets/claude-api-key"),
"sk-ant-test-KEYVALUE-should-never-leak",
)
.unwrap();
(store, data_dir)
}
#[tokio::test]
async fn claude_body_carrying_node_secret_is_blocked() {
let (store, data_dir) = store_and_keyed_dir().await;
let token = store.create().await;
let req = req_with_cookie_and_body(
"POST",
"/aiui/api/claude/v1/messages",
Some(&token),
r#"{"messages":[{"role":"user","content":"remember this: sk-ant-test-KEYVALUE-should-never-leak"}]}"#,
);
let resp = route_model_proxy(&store, data_dir.path(), req, "/aiui/api/claude/v1/messages")
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn claude_body_carrying_bip39_is_blocked() {
let (store, data_dir) = store_and_keyed_dir().await;
let token = store.create().await;
// The canonical checksum-valid test mnemonic.
let req = req_with_cookie_and_body(
"POST",
"/aiui/api/claude/v1/messages",
Some(&token),
r#"{"messages":[{"role":"user","content":"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"}]}"#,
);
let resp = route_model_proxy(&store, data_dir.path(), req, "/aiui/api/claude/v1/messages")
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn web_search_query_carrying_bip39_is_blocked() {
let (store, data_dir) = store_and_keyed_dir().await;
let token = store.create().await;
let req = req_with_cookie(
"GET",
"/aiui/api/web-search?q=abandon+abandon+abandon+abandon+abandon+abandon+abandon+abandon+abandon+abandon+abandon+about",
Some(&token),
);
let resp = route_model_proxy(&store, data_dir.path(), req, "/aiui/api/web-search")
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
/// Minimal local capture server (hyper 0.14, same crate `server.rs`
/// already builds on) standing in for an upstream — records the headers
/// of the one request it receives so the test can assert what actually
+33 -17
View File
@@ -233,27 +233,43 @@ fn has_bip39_length_word_run(body: &str) -> bool {
// token may carry punctuation (a JSON quote closing the string) — take
// its leading alphabetic segment, and treat anything alphanumeric AFTER
// that segment as the end of the phrase.
//
// A token can also carry SEVERAL words glued together by JSON
// punctuation — `{"content":"abandon` has the phrase's first word glued
// to its key. Scanning only the leading word DROPS that first word, and
// an exactly-12-word seed pasted as a bare string value then yields an
// 11-member run that neither checksum-parses nor reaches the implausible
// -run backstop — the canonical leak walked straight through. So after a
// NON-member word (a key can never be seed material) keep scanning the
// token's remainder; after a member word whose rest carries
// alphanumerics the phrase has ended (clear, then keep scanning for a
// new run). Member chains across values remain possible exactly as
// before only when the boundary word is itself a member — the
// checksum window is what keeps that precise, as it did for 13-10.
for token in body.split_whitespace() {
let lead = token.trim_start_matches(|c: char| !c.is_ascii_alphabetic());
let word_len = lead
.find(|c: char| !c.is_ascii_alphabetic())
.unwrap_or(lead.len());
let (word, rest) = lead.split_at(word_len);
let is_member = !word.is_empty()
&& word.chars().all(|c| c.is_ascii_lowercase())
&& wordlist.binary_search(&word).is_ok();
let mut seg = token.trim_start_matches(|c: char| !c.is_ascii_alphabetic());
while !seg.is_empty() {
let word_len = seg
.find(|c: char| !c.is_ascii_alphabetic())
.unwrap_or(seg.len());
let (word, rest) = seg.split_at(word_len);
let is_member = !word.is_empty()
&& word.chars().all(|c| c.is_ascii_lowercase())
&& wordlist.binary_search(&word).is_ok();
if is_member {
run.push(word);
if run_is_seed_material(&run) {
return true;
}
// `accident"` ends a string — the phrase stopped there.
if rest.chars().any(|c| c.is_ascii_alphanumeric()) {
if is_member {
run.push(word);
if run_is_seed_material(&run) {
return true;
}
// `accident"` ends a string — the phrase stopped there.
if rest.chars().any(|c| c.is_ascii_alphanumeric()) {
run.clear();
}
} else {
run.clear();
}
} else {
run.clear();
seg = rest.trim_start_matches(|c: char| !c.is_ascii_alphabetic());
}
}
false
+18 -2
View File
@@ -126,12 +126,16 @@ server {
proxy_send_timeout 120s;
}
# AIUI web search proxy — SearXNG on port 8888
# AIUI web search — session-gated through the daemon's model proxy
# (S4; it re-derives auth from the session cookie and forces JSON
# upstream). Never proxy straight to SearXNG: that left an open search
# relay attributing arbitrary queries to this node's IP.
location /aiui/api/web-search {
proxy_pass http://127.0.0.1:8888/search;
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 30s;
proxy_read_timeout 30s;
error_page 502 503 =503 @searxng_unavailable;
@@ -1049,6 +1053,18 @@ server {
proxy_read_timeout 300s;
proxy_send_timeout 120s;
}
# Session-gated web search (S4) — same rationale and shape as the HTTP
# server block above; both blocks must carry it (T-13-15).
location /aiui/api/web-search {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_connect_timeout 30s;
proxy_read_timeout 30s;
error_page 502 503 =503 @searxng_unavailable;
}
# Icons, favicon, manifest — always revalidate (no heuristic caching)
location ~* ^/(favicon\.ico|manifest\.webmanifest|assets/icon/) {