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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/) {
|
||||
|
||||
Reference in New Issue
Block a user