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