diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 1c7c5177..48e25788 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -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 } diff --git a/core/archipelago/src/api/handler/model_proxy.rs b/core/archipelago/src/api/handler/model_proxy.rs index 2db43f91..28c4cb01 100644 --- a/core/archipelago/src/api/handler/model_proxy.rs +++ b/core/archipelago/src/api/handler/model_proxy.rs @@ -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 { .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 { + 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 { let body = serde_json::json!({ "error": msg }); Response::builder() @@ -128,6 +161,20 @@ async fn forward_claude(req: Request, 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, rest: &str) -> Result 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, data_dir: &Path) -> Result> { + 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 { + 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 diff --git a/core/archipelago/src/assistant/egress.rs b/core/archipelago/src/assistant/egress.rs index b8170ba7..24137b4b 100644 --- a/core/archipelago/src/assistant/egress.rs +++ b/core/archipelago/src/assistant/egress.rs @@ -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 diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 142e0010..e699bb0a 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -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/) {