diff --git a/documentation/audits/permanent-gate-2026-10-01/VERDICT.md b/documentation/audits/permanent-gate-2026-10-01/VERDICT.md new file mode 100644 index 00000000..d5c973b7 --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/VERDICT.md @@ -0,0 +1,96 @@ +# Permanent household gate with family accounts — VERDICT + +**The spike PASSES: exit items 1–5 all pass, measured on 9202 on 2026-10-01 between 19:19 and 19:25 UTC.** +The exit test (`EXIT-TEST.md`) was committed at 19:13 UTC (felhom.eu `7c50dba`), before anything was built or measured. +Operator ruling `09` §3 decision 63 (option A). **Nothing was built into the product. The build waits for the operator's +go.** + +**Method.** The gate was a throwaway forwardAuth service (`familygate/main.go`, about 230 lines of Go; it never ran +outside 9202). It read the visitor by controller v0.286.0's rule. Grimmory v3.4.1 (with MariaDB 11.4) and MeTube +2026.09.29 were started by hand with `docker compose` on 9202, from `familygate/spike-compose.yml`. They were not from +the drill catalog: MeTube has no template, and a hand compose keeps the live catalog untouched either way. Requests +were made through 9202's traefik, both from the LAN and through the simulated tunnel (a container at cloudflared's +fixed address `172.16.253.2`, the same method as Part A). + +## Exit items + +| # | Item | Result | Measured | +|---|---|---|---| +| 1 | **A stranger reaches nothing.** | **PASS** | 18 paths × 2 routes (LAN and tunnel) = 36 stranger requests. All 36 got the gate's answer (302 to the sign-in page, or 401) and **0 reached an app**. The paths covered the front page, the API, setup, the app's own login, Grimmory's `/ws` websocket, MeTube's socket.io (polling and websocket upgrade), `/add`, `/download`, static files and an unknown path. Evidence: `items-1-2.txt`. | +| 2 | **Each family member has their own login. It lasts days. Logout works.** | **PASS** | Anna and Béla each signed in with their own password, not the dashboard's, and got the apps (200). MeTube's websocket upgrade gave Béla **101**; a stranger got 401 (`item-2-websocket.txt`). The cookie lasts 30 days (`Max-Age 2592000`), is HttpOnly, Secure and SameSite=Lax, and has no Domain attribute, so it is host-only. It survived a gate restart. After logout the old cookie was refused. A wrong password got 401 and no cookie. One app's cookie did not open another app (each app host has its own session). | +| 3 | **A stranger's wrong guesses lock only the stranger.** | **PASS** | The stranger tried 7 times through the tunnel, with a new forged leftmost address each time. Tries 1–5 got 401; from try 6 on, 429. Even Anna's right password got **429** while that visitor was locked. Then Anna from `203.0.113.10` (tunnel) and Béla from the LAN both signed in **at once** (302 + cookie). The gate's log counted the stranger at his real address, not the forged ones (`item-3.txt`). | +| 4 | **Grimmory's e-reader paths work through a per-app path exception, and the app's own login still applies there.** | **PASS, with one build requirement** | With no family cookie, through the tunnel or the LAN: OPDS v1 with the OPDS user's own login → **200** (the feed); wrong password → 401; no credentials → 401. Kobo `/v1/initialization` and `/v1/library/sync` with the device token → **200** (the first call took ~15 s: Grimmory asks Kobo's store first, then falls back); a made-up token → 401. KOReader `users/auth` with its own user and md5 key → **200**; wrong key → 401; `users/create` (registration) → 401. Komga API with no credentials → 401. Path tricks out of the exception (`../`, `%2e%2e`) → **the gate** (traefik cleans the path before it routes). **Finding F1:** `PathPrefix(/api/v1/opds)` also matched `/api/v1/opdsx`, which then reached the app ungated. Grimmory's own login refused it (401), but a build must anchor every exception: `PathRegexp(^/api/v1/opds(/\|$))`. Evidence: `item-4.txt`, `item-4-setup.txt` (secrets redacted). | +| 5 | **The family login cannot reach the box dashboard.** | **PASS** | The family cookie is host-only, so a browser never sends it to `felhom.`. Sent by hand anyway, the dashboard answered 302 to `/login`, and its API answered 401. Anna's family password at the dashboard login got "Hibás jelszó." and no session (`item-5-6.txt`). | +| 6 | **Cost.** | measured | **Time per gated request: +0.4 ms.** Median of 60 pairs: 14.6 ms gated vs 14.2 ms on an ungated name for the same service. Inside the controller, the setup gate already measured ~2 ms (decision 46). **Gate's answerer down:** every gated path answers **500**, so it fails closed, not open; the sign-in page answers 502; the e-reader exceptions keep working, because they never asked the gate. **Build cost:** two sessions; see below. | + +## Answers to the brief's questions + +- **Where do family accounts live, and who manages them?** In the controller's data directory, as a `family.json` + next to `settings.json`, with bcrypt hashes. That puts them in the controller's own backup and restore, and the hub + never sees them. **The household's dashboard admin manages them** from a "Család" (family) card: add a member with a + name and a generated password shown once, reset a password, remove a member. Removing a member ends their sessions. + Members have no dashboard access of any kind. +- **One sign-in for all gated apps, or one per app?** **One sign-in, with a cookie per app.** The spike signed in per + app host, and that works, but a family member would then sign in to every app separately. The setup gate already has + the right shape: a session on the dashboard host, plus a 60-second, one-use token that mints a host-only cookie for + each app (`/__gate/start`). A family session on the dashboard host would mint each app's cookie the same way. + - That family session is a different cookie from the household admin session, and it never opens the dashboard + (item 5's rule). + - Each app still gets its own host-only cookie, so no app's backend ever sees another app's session. +- **How are Radicale- and Dawarich-style API clients let through?** With an anchored per-app exception list, as + Grimmory's measured here. + - The list belongs in the template, e.g. `family_gate.except: ["^/api/v1/opds(/|$)", …]`, and the controller turns + it into a router that has no gate. + - Dawarich's phone app uses `/api/v1/*` with its API key; its exception keeps the app's own key check. + - **Radicale should not be family-gated.** Every request it serves comes from a calendar client. It already has its + own login, and the exception would be the whole host. +- **Does MeTube become publishable behind it?** **Yes, behind the gate and only behind it.** Its fit verdict R-767 was + "stop: no login at all"; the gate becomes its login. + - Measured: a stranger reached nothing, including socket.io and `/add`. + - Two caveats for its page: every family member shares one MeTube (one queue, one download folder), and downloads + fill the drive. It also still needs its own checklist record before publishing (new-app gate). + - Grimmory behind the gate makes R-775 doubly settled: Part A already makes its sign-in lock per visitor, and the + gate puts the web sign-in out of a stranger's reach. + +## Build plan (if the operator says go) + +**Session 1: the controller (one release).** +1. `family.json`: members with bcrypt hashes; add, reset and remove; removing a member revokes their sessions. +2. A dashboard card "Család", in both languages, with a member list and a password shown once. +3. A family session on the dashboard host, as a separate cookie that never opens the dashboard. +4. The `/__family/login` page, with lock-out per visitor (`clientIP`) and logout. +5. `ServeGateAuth` grows a permanent mode: for an app with `family_gate`, a valid family app-cookie → 200, otherwise + the same 302/401 as today, then the token handshake. +6. A traefik file per gated app (the setup gate's writer), plus an ungated router for each anchored exception. +7. Tests, red-proofed, for each exit item. Live on 9202 against items 1–5. + +**Session 2: the catalog.** +1. `family_gate:` with `except:` in the `.felhom.yml` format and its gate. +2. Grimmory: the template with OPDS, Kobo, KOReader and Komga exceptions, its checklist record (R-775's held template + from `audits/new-apps-2026-10-01/wip/grimmory/`), published. +3. MeTube: a new template plus its checklist record, published behind the gate. +4. Both live on 9202 and one demo box. + +**Not in the build:** an identity app (decision 63, option B), which stays possible later behind the same forwardAuth +hook; per-member rights inside an app (the app's own users do that). + +**What a build inherits from the setup gate's measured costs:** a gated app answers 500 while the controller is +restarting or down, which is seconds during a self-update. A phone app reaches a gated app only through its exception +list. + +## Findings + +- **F1 (build requirement, not a product defect):** traefik's `PathPrefix` is a plain string prefix. An exception must + be anchored, or a look-alike path walks past the gate. Recorded in the build row. +- Kobo's first `/v1/initialization` takes ~15 s: Grimmory asks Kobo's store first, then falls back. Not a gate cost. + +## Teardown (three layers) + +- **Machine:** + - Removed with `docker compose -p spike down -v`: `spike-gate`, `gm-spike`, `gm-spike-db`, `mt-spike`, the + `gm-internal` network and the `gm_spike_db` volume. + - Images removed: grimmory, metube, alpine:3.20, mariadb:11.4. + - Deleted: `/root/spike` and traefik's `dynamic/spike-family.yml`. + - Checked afterwards: no `spike` container or volume is left. +- **Host:** nothing was provisioned on demo-hp itself; 9202's disk is its own. +- **Hub:** nothing. 9202 is not enrolled. diff --git a/documentation/audits/permanent-gate-2026-10-01/familygate/go.mod b/documentation/audits/permanent-gate-2026-10-01/familygate/go.mod new file mode 100644 index 00000000..a7f4f5bc --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/familygate/go.mod @@ -0,0 +1,5 @@ +module familygate + +go 1.22 + +require golang.org/x/crypto v0.31.0 // indirect diff --git a/documentation/audits/permanent-gate-2026-10-01/familygate/main.go b/documentation/audits/permanent-gate-2026-10-01/familygate/main.go new file mode 100644 index 00000000..5b863767 --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/familygate/main.go @@ -0,0 +1,225 @@ +// familygate — THROWAWAY spike (permanent-gate-2026-10-01). A traefik forwardAuth answerer with a family list: each +// member signs in with their OWN name and password; the session lasts 30 days, survives a restart, and logout revokes +// it. Wrong passwords are counted per VISITOR, read by controller v0.286's rule (clientaddr.go): believed only from +// traefik; the rightmost X-Forwarded-For entry is the hop traefik saw; the tunnel's fixed address → CF-Connecting-IP. +// Never shipped: the build, if the operator says go, lives in the controller. +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "html" + "log" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const ( + cookieName = "felhom_family" + life = 30 * 24 * time.Hour + tunnelAddr = "172.16.253.2" + maxWrong = 5 + window = time.Minute + dataDir = "/data" +) + +type sess struct { + User string `json:"u"` + Host string `json:"h"` + Exp time.Time `json:"e"` +} + +var ( + mu sync.Mutex + users map[string]string // name -> bcrypt + sessions = map[string]sess{} + wrong = map[string][]time.Time{} + traefik []string + trAt time.Time +) + +func save() { + b, _ := json.Marshal(sessions) + _ = os.WriteFile(dataDir+"/sessions.json", b, 0o600) +} + +func isTraefik(ip string) bool { + if time.Since(trAt) > 30*time.Second { + traefik, _ = net.LookupHost("traefik") + trAt = time.Now() + } + for _, a := range traefik { + if a == ip { + return true + } + } + return false +} + +func visitor(r *http.Request) string { + peer, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + peer = r.RemoteAddr + } + if !isTraefik(peer) { + return peer + } + var hops []string + for _, v := range r.Header.Values("X-Forwarded-For") { + for _, h := range strings.Split(v, ",") { + if h = strings.TrimSpace(h); h != "" { + hops = append(hops, h) + } + } + } + if len(hops) == 0 || net.ParseIP(hops[len(hops)-1]) == nil { + return peer + } + hop := hops[len(hops)-1] + if hop == tunnelAddr { + if cf := strings.TrimSpace(r.Header.Get("CF-Connecting-IP")); net.ParseIP(cf) != nil { + return cf + } + } + return hop +} + +func valid(r *http.Request, host string) (string, bool) { + c, err := r.Cookie(cookieName) + if err != nil { + return "", false + } + mu.Lock() + defer mu.Unlock() + s, ok := sessions[c.Value] + if !ok || time.Now().After(s.Exp) || s.Host != host { + return "", false + } + return s.User, true +} + +// /auth — traefik forwardAuth. +func auth(w http.ResponseWriter, r *http.Request) { + host := strings.ToLower(r.Header.Get("X-Forwarded-Host")) + if i := strings.LastIndex(host, ":"); i != -1 { + host = host[:i] + } + uri := r.Header.Get("X-Forwarded-Uri") + if u, ok := valid(r, host); ok { + w.Header().Set("X-Family-User", u) + w.WriteHeader(200) + return + } + m := r.Header.Get("X-Forwarded-Method") + if (m == "" || m == "GET") && strings.Contains(r.Header.Get("Accept"), "text/html") { + http.Redirect(w, r, "https://"+host+"/__family/login?"+url.Values{"rd": {uri}}.Encode(), http.StatusFound) + return + } + log.Printf("refused %s %s%s from %s", m, host, uri, visitor(r)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + fmt.Fprint(w, `{"error":"sign in with your family login"}`) +} + +func page(w http.ResponseWriter, msg, rd string, code int) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(code) + fmt.Fprintf(w, `Belépés

%s

+ +
`, html.EscapeString(msg), html.EscapeString(rd)) +} + +func login(w http.ResponseWriter, r *http.Request) { + rd := r.FormValue("rd") + if !strings.HasPrefix(rd, "/") || strings.HasPrefix(rd, "//") { + rd = "/" + } + if r.Method != http.MethodPost { + page(w, "", rd, 200) + return + } + v := visitor(r) + now := time.Now() + mu.Lock() + var keep []time.Time + for _, t := range wrong[v] { + if now.Sub(t) < window { + keep = append(keep, t) + } + } + wrong[v] = keep + if len(keep) >= maxWrong { + mu.Unlock() + log.Printf("locked: visitor %s (%d wrong in %s)", v, len(keep), window) + page(w, "Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva.", rd, 429) + return + } + hash, known := users[r.FormValue("user")] + mu.Unlock() + if !known || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil { + mu.Lock() + wrong[v] = append(wrong[v], now) + mu.Unlock() + log.Printf("wrong password from visitor %s", v) + page(w, "Hibás név vagy jelszó.", rd, 401) + return + } + b := make([]byte, 32) + _, _ = rand.Read(b) + id := hex.EncodeToString(b) + host := strings.ToLower(strings.Split(r.Host, ":")[0]) + mu.Lock() + delete(wrong, v) + sessions[id] = sess{User: r.FormValue("user"), Host: host, Exp: now.Add(life)} + save() + mu.Unlock() + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: id, Path: "/", MaxAge: int(life.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}) + log.Printf("signed in: %s on %s from visitor %s", r.FormValue("user"), host, v) + http.Redirect(w, r, rd, http.StatusFound) +} + +func logout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(cookieName); err == nil { + mu.Lock() + delete(sessions, c.Value) + save() + mu.Unlock() + } + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true}) + http.Redirect(w, r, "/__family/login", http.StatusFound) +} + +func main() { + if len(os.Args) == 3 && os.Args[1] == "hash" { // familygate hash — for the users file + h, _ := bcrypt.GenerateFromPassword([]byte(os.Args[2]), bcrypt.DefaultCost) + fmt.Println(string(h)) + return + } + raw, err := os.ReadFile(dataDir + "/users.json") + if err != nil { + log.Fatal(err) + } + if err := json.Unmarshal(raw, &users); err != nil { + log.Fatal(err) + } + if b, err := os.ReadFile(dataDir + "/sessions.json"); err == nil { + _ = json.Unmarshal(b, &sessions) + } + _ = hmac.New(sha256.New, nil) + http.HandleFunc("/auth", auth) + http.HandleFunc("/__family/login", login) + http.HandleFunc("/__family/logout", logout) + log.Printf("familygate: %d members, %d sessions", len(users), len(sessions)) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/documentation/audits/permanent-gate-2026-10-01/familygate/spike-compose.yml b/documentation/audits/permanent-gate-2026-10-01/familygate/spike-compose.yml new file mode 100644 index 00000000..987cecd8 --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/familygate/spike-compose.yml @@ -0,0 +1,75 @@ +# THROWAWAY — permanent-gate spike 2026-10-01 on 9202 only. Removed afterwards. +services: + spike-gate: + image: alpine:3.20 + container_name: spike-gate + command: ["/gate/familygate"] + volumes: + - /root/spike/bin:/gate:ro + - /root/spike/gate-data:/data + networks: [traefik-public] + grimmory: + image: ghcr.io/grimmory-tools/grimmory:v3.4.1 + container_name: gm-spike + environment: + - TZ=Europe/Budapest + - USER_ID=1000 + - GROUP_ID=1000 + - DATABASE_URL=jdbc:mariadb://gm-spike-db:3306/grimmory + - DATABASE_USERNAME=grimmory + - DATABASE_PASSWORD=${DB_PASSWORD} + - SWAGGER_ENABLED=false + - FORCE_DISABLE_OIDC=true + volumes: + - /root/spike/gm/data:/app/data + - /root/spike/gm/books:/books + - /root/spike/gm/bookdrop:/bookdrop + networks: [traefik-public, gm-internal] + depends_on: + grimmory-db: {condition: service_healthy} + labels: + - "traefik.enable=true" + - "traefik.docker.network=traefik-public" + - "traefik.http.routers.gm-spike.rule=Host(`gm-spike.enkisfelhom.hu`)" + - "traefik.http.routers.gm-spike.entrypoints=websecure" + - "traefik.http.routers.gm-spike.tls=true" + - "traefik.http.routers.gm-spike.middlewares=spike-family-auth@file" + - "traefik.http.routers.gm-spike.service=gm-spike" + # the e-reader path exception: NO family gate here — Grimmory's own authentication decides + - "traefik.http.routers.gm-spike-ereader.rule=Host(`gm-spike.enkisfelhom.hu`) && (PathPrefix(`/api/v1/opds`) || PathPrefix(`/api/v2/opds`) || PathPrefix(`/api/kobo/`) || PathPrefix(`/api/koreader/`) || PathPrefix(`/komga/api/`))" + - "traefik.http.routers.gm-spike-ereader.entrypoints=websecure" + - "traefik.http.routers.gm-spike-ereader.tls=true" + - "traefik.http.routers.gm-spike-ereader.service=gm-spike" + - "traefik.http.services.gm-spike.loadbalancer.server.port=6060" + grimmory-db: + image: mariadb:11.4 + container_name: gm-spike-db + environment: + - MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD} + - MARIADB_DATABASE=grimmory + - MARIADB_USER=grimmory + - MARIADB_PASSWORD=${DB_PASSWORD} + volumes: [gm_spike_db:/var/lib/mysql] + networks: [gm-internal] + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + retries: 10 + start_period: 30s + metube: + image: ghcr.io/alexta69/metube:2026.09.29 + container_name: mt-spike + volumes: [/root/spike/mt:/downloads] + networks: [traefik-public] + labels: + - "traefik.enable=true" + - "traefik.http.routers.mt-spike.rule=Host(`mt-spike.enkisfelhom.hu`)" + - "traefik.http.routers.mt-spike.entrypoints=websecure" + - "traefik.http.routers.mt-spike.tls=true" + - "traefik.http.routers.mt-spike.middlewares=spike-family-auth@file" + - "traefik.http.services.mt-spike.loadbalancer.server.port=8081" +volumes: + gm_spike_db: +networks: + traefik-public: {external: true} + gm-internal: diff --git a/documentation/audits/permanent-gate-2026-10-01/familygate/spike-family.yml b/documentation/audits/permanent-gate-2026-10-01/familygate/spike-family.yml new file mode 100644 index 00000000..2ec30884 --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/familygate/spike-family.yml @@ -0,0 +1,24 @@ +# THROWAWAY — permanent-gate spike 2026-10-01 (9202 only). The family gate's middleware and its sign-in pages. +http: + middlewares: + spike-family-auth: + forwardAuth: + address: "http://spike-gate:8080/auth" + routers: + spike-gm-direct: + rule: "Host(`gm-direct.enkisfelhom.hu`)" + entryPoints: [websecure] + tls: {} + service: gm-spike@docker + spike-family-pages: + rule: "(Host(`gm-spike.enkisfelhom.hu`) || Host(`mt-spike.enkisfelhom.hu`)) && PathPrefix(`/__family/`)" + priority: 100000 + entryPoints: [websecure] + tls: {} + service: spike-gate + services: + spike-gate: + loadBalancer: + servers: + - url: "http://spike-gate:8080" + # item 6 only: the same Grimmory service on a second name WITHOUT the gate, to time the gate's cost diff --git a/documentation/audits/permanent-gate-2026-10-01/gate-log.txt b/documentation/audits/permanent-gate-2026-10-01/gate-log.txt new file mode 100644 index 00000000..757c577a --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/gate-log.txt @@ -0,0 +1,89 @@ +2026/10/01 19:17:15 familygate: 2 members, 0 sessions +2026/10/01 19:19:38 refused GET gm-spike.enkisfelhom.hu/ from 192.168.0.180 +2026/10/01 19:19:40 refused GET gm-spike.enkisfelhom.hu/ from 198.51.100.66 +2026/10/01 19:19:40 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 192.168.0.180 +2026/10/01 19:19:41 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 198.51.100.66 +2026/10/01 19:19:42 refused GET gm-spike.enkisfelhom.hu/api/v1/healthcheck from 192.168.0.180 +2026/10/01 19:19:43 refused GET gm-spike.enkisfelhom.hu/api/v1/healthcheck from 198.51.100.66 +2026/10/01 19:19:43 refused GET gm-spike.enkisfelhom.hu/api/v1/public-settings from 192.168.0.180 +2026/10/01 19:19:44 refused GET gm-spike.enkisfelhom.hu/api/v1/public-settings from 198.51.100.66 +2026/10/01 19:19:45 refused POST gm-spike.enkisfelhom.hu/api/v1/setup from 192.168.0.180 +2026/10/01 19:19:46 refused POST gm-spike.enkisfelhom.hu/api/v1/setup from 198.51.100.66 +2026/10/01 19:19:46 refused POST gm-spike.enkisfelhom.hu/api/v1/auth/login from 192.168.0.180 +2026/10/01 19:19:48 refused POST gm-spike.enkisfelhom.hu/api/v1/auth/login from 198.51.100.66 +2026/10/01 19:19:48 refused GET gm-spike.enkisfelhom.hu/ws/websocket from 192.168.0.180 +2026/10/01 19:19:49 refused GET gm-spike.enkisfelhom.hu/ws/websocket from 198.51.100.66 +2026/10/01 19:19:49 refused GET gm-spike.enkisfelhom.hu/assets/index.js from 192.168.0.180 +2026/10/01 19:19:51 refused GET gm-spike.enkisfelhom.hu/assets/index.js from 198.51.100.66 +2026/10/01 19:19:54 refused GET mt-spike.enkisfelhom.hu/ from 192.168.0.180 +2026/10/01 19:19:55 refused GET mt-spike.enkisfelhom.hu/ from 198.51.100.66 +2026/10/01 19:19:55 refused GET mt-spike.enkisfelhom.hu/history from 192.168.0.180 +2026/10/01 19:19:57 refused GET mt-spike.enkisfelhom.hu/history from 198.51.100.66 +2026/10/01 19:19:57 refused POST mt-spike.enkisfelhom.hu/add from 192.168.0.180 +2026/10/01 19:19:58 refused POST mt-spike.enkisfelhom.hu/add from 198.51.100.66 +2026/10/01 19:19:58 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=polling from 192.168.0.180 +2026/10/01 19:20:00 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket from 192.168.0.180 +2026/10/01 19:20:01 refused GET mt-spike.enkisfelhom.hu/download/x.mp4 from 192.168.0.180 +2026/10/01 19:20:03 refused GET mt-spike.enkisfelhom.hu/download/x.mp4 from 198.51.100.66 +2026/10/01 19:20:03 refused GET mt-spike.enkisfelhom.hu/version from 192.168.0.180 +2026/10/01 19:20:04 refused GET mt-spike.enkisfelhom.hu/version from 198.51.100.66 +2026/10/01 19:20:05 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:20:05 signed in: bela on mt-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:20:06 familygate: 2 members, 2 sessions +2026/10/01 19:20:10 wrong password from visitor 192.168.0.180 +2026/10/01 19:20:10 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:20:16 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 192.168.0.180 +2026/10/01 19:20:22 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 192.168.0.180 +2026/10/01 19:20:33 refused GET gm-spike.enkisfelhom.hu/ from 192.168.0.180 +2026/10/01 19:20:34 refused GET gm-spike.enkisfelhom.hu/ from 198.51.100.66 +2026/10/01 19:20:34 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 192.168.0.180 +2026/10/01 19:20:36 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 198.51.100.66 +2026/10/01 19:20:36 refused GET gm-spike.enkisfelhom.hu/api/v1/healthcheck from 192.168.0.180 +2026/10/01 19:20:37 refused GET gm-spike.enkisfelhom.hu/api/v1/healthcheck from 198.51.100.66 +2026/10/01 19:20:37 refused GET gm-spike.enkisfelhom.hu/api/v1/public-settings from 192.168.0.180 +2026/10/01 19:20:39 refused GET gm-spike.enkisfelhom.hu/api/v1/public-settings from 198.51.100.66 +2026/10/01 19:20:39 refused POST gm-spike.enkisfelhom.hu/api/v1/setup from 192.168.0.180 +2026/10/01 19:20:40 refused POST gm-spike.enkisfelhom.hu/api/v1/setup from 198.51.100.66 +2026/10/01 19:20:41 refused POST gm-spike.enkisfelhom.hu/api/v1/auth/login from 192.168.0.180 +2026/10/01 19:20:42 refused POST gm-spike.enkisfelhom.hu/api/v1/auth/login from 198.51.100.66 +2026/10/01 19:20:42 refused GET gm-spike.enkisfelhom.hu/ws/websocket from 192.168.0.180 +2026/10/01 19:20:43 refused GET gm-spike.enkisfelhom.hu/ws/websocket from 198.51.100.66 +2026/10/01 19:20:44 refused GET gm-spike.enkisfelhom.hu/assets/index.js from 192.168.0.180 +2026/10/01 19:20:45 refused GET gm-spike.enkisfelhom.hu/assets/index.js from 198.51.100.66 +2026/10/01 19:20:48 refused GET mt-spike.enkisfelhom.hu/ from 192.168.0.180 +2026/10/01 19:20:50 refused GET mt-spike.enkisfelhom.hu/ from 198.51.100.66 +2026/10/01 19:20:50 refused GET mt-spike.enkisfelhom.hu/history from 192.168.0.180 +2026/10/01 19:20:51 refused GET mt-spike.enkisfelhom.hu/history from 198.51.100.66 +2026/10/01 19:20:51 refused POST mt-spike.enkisfelhom.hu/add from 192.168.0.180 +2026/10/01 19:20:53 refused POST mt-spike.enkisfelhom.hu/add from 198.51.100.66 +2026/10/01 19:20:53 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=polling from 192.168.0.180 +2026/10/01 19:20:54 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=polling from 198.51.100.66 +2026/10/01 19:20:54 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket from 192.168.0.180 +2026/10/01 19:20:56 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket from 198.51.100.66 +2026/10/01 19:20:56 refused GET mt-spike.enkisfelhom.hu/download/x.mp4 from 192.168.0.180 +2026/10/01 19:20:57 refused GET mt-spike.enkisfelhom.hu/download/x.mp4 from 198.51.100.66 +2026/10/01 19:20:57 refused GET mt-spike.enkisfelhom.hu/version from 192.168.0.180 +2026/10/01 19:20:59 refused GET mt-spike.enkisfelhom.hu/version from 198.51.100.66 +2026/10/01 19:20:59 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:20:59 signed in: bela on mt-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:21:01 familygate: 2 members, 4 sessions +2026/10/01 19:21:04 wrong password from visitor 192.168.0.180 +2026/10/01 19:21:04 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:21:19 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket from 192.168.0.180 +2026/10/01 19:21:33 wrong password from visitor 198.51.100.66 +2026/10/01 19:21:34 wrong password from visitor 198.51.100.66 +2026/10/01 19:21:36 wrong password from visitor 198.51.100.66 +2026/10/01 19:21:38 wrong password from visitor 198.51.100.66 +2026/10/01 19:21:39 wrong password from visitor 198.51.100.66 +2026/10/01 19:21:41 locked: visitor 198.51.100.66 (5 wrong in 1m0s) +2026/10/01 19:21:42 locked: visitor 198.51.100.66 (5 wrong in 1m0s) +2026/10/01 19:21:44 locked: visitor 198.51.100.66 (5 wrong in 1m0s) +2026/10/01 19:21:45 signed in: anna on gm-spike.enkisfelhom.hu from visitor 203.0.113.10 +2026/10/01 19:21:46 signed in: bela on gm-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:22:04 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:22:35 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:23:20 refused GET gm-spike.enkisfelhom.hu/api/api/v1/books from 198.51.100.66 +2026/10/01 19:23:21 refused GET gm-spike.enkisfelhom.hu/api/api/v1/books from 198.51.100.66 +2026/10/01 19:23:23 refused GET gm-spike.enkisfelhom.hu/api/v1/books from 198.51.100.66 +2026/10/01 19:24:09 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:24:15 familygate: 2 members, 9 sessions diff --git a/documentation/audits/permanent-gate-2026-10-01/item-2-websocket.txt b/documentation/audits/permanent-gate-2026-10-01/item-2-websocket.txt new file mode 100644 index 00000000..584dc647 --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/item-2-websocket.txt @@ -0,0 +1,3 @@ +# item 2 (websocket): MeTube's socket.io websocket upgrade over HTTP/1.1, through traefik (curl stops after the handshake) + bela (family cookie): 101 APP ['Upgrade: websocket'] + stranger (no cookie): 401 GATE [] diff --git a/documentation/audits/permanent-gate-2026-10-01/item-3.txt b/documentation/audits/permanent-gate-2026-10-01/item-3.txt new file mode 100644 index 00000000..f7b964ee --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/item-3.txt @@ -0,0 +1,23 @@ +# item 3, 2026-10-01T19:21:31Z: the stranger 198.51.100.66 through the SIMULATED tunnel, guessing anna's password, a new forged leftmost address each try + try 1: 401 wrong + try 2: 401 wrong + try 3: 401 wrong + try 4: 401 wrong + try 5: 401 wrong + try 6: 429 LOCKED + try 7: 429 LOCKED + the stranger with anna's RIGHT password while locked: 429 + anna herself from 203.0.113.10 (tunnel), at once: 302, cookie set + bela from the LAN (192.168.0.180), at once: 302, cookie set +2026/10/01 19:21:04 signed in: anna on gm-spike.enkisfelhom.hu from visitor 192.168.0.180 +2026/10/01 19:21:19 refused GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket from 192.168.0.180 +2026/10/01 19:21:33 wrong password from visitor 198.51.100.66 +2026/10/01 19:21:34 wrong password from visitor 198.51.100.66 +2026/10/01 19:21:36 wrong password from visitor 198.51.100.66 +2026/10/01 19:21:38 wrong password from visitor 198.51.100.66 +2026/10/01 19:21:39 wrong password from visitor 198.51.100.66 +2026/10/01 19:21:41 locked: visitor 198.51.100.66 (5 wrong in 1m0s) +2026/10/01 19:21:42 locked: visitor 198.51.100.66 (5 wrong in 1m0s) +2026/10/01 19:21:44 locked: visitor 198.51.100.66 (5 wrong in 1m0s) +2026/10/01 19:21:45 signed in: anna on gm-spike.enkisfelhom.hu from visitor 203.0.113.10 +2026/10/01 19:21:46 signed in: bela on gm-spike.enkisfelhom.hu from visitor 192.168.0.180 diff --git a/documentation/audits/permanent-gate-2026-10-01/item-4-setup.txt b/documentation/audits/permanent-gate-2026-10-01/item-4-setup.txt new file mode 100644 index 00000000..b906a7a9 --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/item-4-setup.txt @@ -0,0 +1,12 @@ +# item 4 setup — as anna (through the family gate): Grimmory's first admin, OPDS on + an OPDS user, a Kobo token, a KOReader user + POST /api/v1/setup -> 200 + app login -> 200 token + OPDS on -> 200 + OPDS user -> 200 + GET /api/v1/kobo-settings -> 500 {"message":"An unexpected error occurred.","status":500,"timestamp":"2026-10-01T21:22:05.673392212"} + PUT /api/v1/kobo-settings/token -> 200 {"autoAddToShelf":false,"hardcoverApiKey":null,"hardcoverSyncEnabled":false,"id":1,"progressMarkAsFinishedThreshold":99.0,"progressMarkAsReadingThreshold":1.0," + GET /api/v1/kobo-settings -> 200 {"autoAddToShelf":false,"hardcoverApiKey":null,"hardcoverSyncEnabled":false,"id":1,"progressMarkAsFinishedThreshold":99.0,"progressMarkAsReadingThreshold":1.0," + GET /api/v1/koreader-users/me -> 404 {"message":"Koreader user not found for BookLore user ID: 1","status":404,"timestamp":"2026-10-01T21:22:05.907223998"} + PUT /api/v1/koreader-users/me -> 200 {"id":1,"username":"korolvaso","password":"","passwordMD5":"","syncEnabled":false,"syncWithWebReader":false} + POST /api/v1/koreader-users/me -> 405 {"message":"Method 'POST' is not supported.","status":405,"timestamp":"2026-10-01T21:22:06.024563401"} + GET /api/v1/koreader-users/me -> 200 {"id":1,"username":"korolvaso","password":"","passwordMD5":"","syncEnabled":false,"syncWithWebReader":false} diff --git a/documentation/audits/permanent-gate-2026-10-01/item-4.txt b/documentation/audits/permanent-gate-2026-10-01/item-4.txt new file mode 100644 index 00000000..b5be9df9 --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/item-4.txt @@ -0,0 +1,25 @@ +(setup, as anna) PUT /api/v1/koreader-users/me/sync?enabled=true -> 405 +(setup, as anna) PATCH /api/v1/koreader-users/me/sync?enabled=true -> 204 +# item 4, 2026-10-01T19:22:35Z: e-reader clients on the PATH EXCEPTION — no family cookie; the app's own login decides + OPDS catalog, the OPDS user's own login LAN -> 200 app '\n\n\n\n\n \n 401 app 'HTTP Status 401 - Bad credentials' + OPDS, no credentials (a stranger) tunnel -> 401 app 'HTTP Status 401 - Full authentication is required to access this resou' + Kobo sync initialization, the device's token LAN -> 000 app '' + Kobo library sync, the device's token tunnel -> 000 app '' + Kobo, a made-up token (a stranger) tunnel -> 401 app '{"timestamp":"2026-10-01T19:23:12.405Z","status":401,"error":"Unauthor' + KOReader sign-in, its own user + md5 key LAN -> 200 app '{"username":"korolvaso"}' + KOReader progress, its own user + md5 key tunnel -> 404 app '{"message":"Book not found for hash 0000","status":404,"timestamp":"20' + KOReader sign-in, WRONG key (a stranger) tunnel -> 401 app '{"timestamp":"2026-10-01T19:23:15.609Z","status":401,"error":"Unauthor' + KOReader create-user (registration, a stranger) tunnel -> 401 app '{"timestamp":"2026-10-01T19:23:17.132Z","status":401,"error":"Unauthor' + Komga API (Mihon/Tachiyomi), the OPDS user LAN -> 403 app '{"timestamp":"2026-10-01T19:23:17.408Z","status":403,"error":"Forbidde' + Komga API, no credentials (a stranger) tunnel -> 401 app 'HTTP Status 401 - Full authentication is required to access this resou' + -- the exception must not leak the rest of the app: + path trick ../ out of the exception (raw) tunnel -> 401 GATE '{"error":"sign in with your family login"}' + path trick %2e%2e out of the exception tunnel -> 401 GATE '{"error":"sign in with your family login"}' + an app API path that is NOT excepted tunnel -> 401 GATE '{"error":"sign in with your family login"}' + prefix look-alike /api/v1/opdsx tunnel -> 401 app '{"timestamp":"2026-10-01T19:23:24.761Z","status":401,"error":"Unauthor' +## item 4 (Kobo, again with a 90 s wait), 2026-10-01T19:23:39Z + Kobo /v1/initialization, the device's token (LAN) -> 200 in 0.2s app '{"Resources":{"user_tasteprofile_genre":"https://storeapi.kobo.com/v2/user/tasteprofile/ge' + Kobo /v1/library/sync, the device's token (LAN) -> 200 in 0.3s app '[{"DeletedTag":{"Tag":{"Id":"BL-S-1"}}}]' diff --git a/documentation/audits/permanent-gate-2026-10-01/item-5-6.txt b/documentation/audits/permanent-gate-2026-10-01/item-5-6.txt new file mode 100644 index 00000000..1b3ecf61 --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/item-5-6.txt @@ -0,0 +1,13 @@ +# item 5, 2026-10-01T19:24:09Z: the family login and the box dashboard (felhom.enkisfelhom.hu) + anna's family cookie sent to the dashboard (a browser would not even send it — host-only): 302 -> ['location: /login'] + ... to the dashboard API: 401 '{"ok":false,"error":"authentication required"}' + anna's family password at the dashboard login: 200, session cookie none, says wrong password: True +# item 6, cost: the same request (GET /api/v1/healthcheck) through the gated name and an ungated name, 60 each, alternating + status gated 200 / ungated 200; pairs kept 60 + median gated 14.6 ms, ungated 14.2 ms -> the gate adds 0.4 ms (p90 16.4 vs 16.2) + gate's answerer STOPPED: gm-spike/ (with anna's cookie) -> 500 '' + gate's answerer STOPPED: gm-spike/api/v1/books (with anna's cookie) -> 500 '' + gate's answerer STOPPED: mt-spike/ (with anna's cookie) -> 500 '' + gate's answerer STOPPED: gm-spike/__family/login (with anna's cookie) -> 502 'Bad Gateway' + gate STOPPED: the e-reader exception /api/v1/opds -> 401 (not behind the gate) + gate back: anna -> 200 diff --git a/documentation/audits/permanent-gate-2026-10-01/items-1-2.txt b/documentation/audits/permanent-gate-2026-10-01/items-1-2.txt new file mode 100644 index 00000000..7b268f68 --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/items-1-2.txt @@ -0,0 +1,36 @@ +# items 1–2, 2026-10-01T19:20:31Z — 9202, throwaway familygate (forwardAuth), Grimmory v3.4.1 + MeTube 2026.09.29 +## item 1 — a STRANGER (no cookie), from the LAN and through the simulated tunnel; GATE = the gate answered, APP = the app did + gm-spike GET / LAN 302 GATE | tunnel 302 GATE + gm-spike GET / LAN 401 GATE | tunnel 401 GATE + gm-spike GET /api/v1/books LAN 401 GATE | tunnel 401 GATE + gm-spike GET /api/v1/healthcheck LAN 401 GATE | tunnel 401 GATE + gm-spike GET /api/v1/public-settings LAN 401 GATE | tunnel 401 GATE + gm-spike POST /api/v1/setup LAN 401 GATE | tunnel 401 GATE + gm-spike POST /api/v1/auth/login LAN 401 GATE | tunnel 401 GATE + gm-spike GET /ws/websocket LAN 401 GATE | tunnel 401 GATE + gm-spike GET /assets/index.js LAN 401 GATE | tunnel 401 GATE + gm-spike GET /no-such-page LAN 302 GATE | tunnel 302 GATE + mt-spike GET / LAN 302 GATE | tunnel 302 GATE + mt-spike GET / LAN 401 GATE | tunnel 401 GATE + mt-spike GET /history LAN 401 GATE | tunnel 401 GATE + mt-spike POST /add LAN 401 GATE | tunnel 401 GATE + mt-spike GET /socket.io/?EIO=4&transport=polling LAN 401 GATE | tunnel 401 GATE + mt-spike GET /socket.io/?EIO=4&transport=websocket LAN 401 GATE | tunnel 401 GATE + mt-spike GET /download/x.mp4 LAN 401 GATE | tunnel 401 GATE + mt-spike GET /version LAN 401 GATE | tunnel 401 GATE +item 1: 0 app answers of 36 stranger requests -> PASS +## item 2 — family members with their OWN logins (not the dashboard password) + anna signs in on gm-spike.enkisfelhom.hu: 302; cookie set; Max-Age 2592000 s = 30 days; flags: HttpOnly, Secure, SameSite=Lax; Domain attr: none (host-only) + anna GET gm-spike.enkisfelhom.hu/ -> 200 APP + anna GET gm-spike.enkisfelhom.hu/api/v1/healthcheck -> 406 APP + bela signs in on mt-spike.enkisfelhom.hu: 302; cookie set + bela GET mt-spike.enkisfelhom.hu/ -> 200 APP + bela GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=polling -> 200 APP + bela GET mt-spike.enkisfelhom.hu/socket.io/?EIO=4&transport=websocket -> 400 APP + bela GET mt-spike.enkisfelhom.hu/history -> 200 APP + bela's MeTube cookie sent to Grimmory -> 302 GATE (a session is per app host) + a made-up session id -> 302 GATE + after the gate RESTARTED, anna's cookie -> 200 APP (session survived) + anna logs out -> 302 /__family/login + anna's OLD cookie after logout -> 302 GATE (refused) + anna with a WRONG password -> 401, cookie none diff --git a/documentation/audits/permanent-gate-2026-10-01/tools/items12.py b/documentation/audits/permanent-gate-2026-10-01/tools/items12.py new file mode 100644 index 00000000..0bbf505a --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/tools/items12.py @@ -0,0 +1,59 @@ +"""Exit items 1 and 2 of EXIT-TEST.md.""" +import json, re, subprocess, sys, time +from sp import * +out = open(sys.argv[1], "w", buffering=1) +def say(*a): + s = " ".join(map(str, a)); print(s); out.write(s + "\n") + +say(f"# items 1–2, {time.strftime('%FT%TZ', time.gmtime())} — 9202, throwaway familygate (forwardAuth), Grimmory v3.4.1 + MeTube 2026.09.29") +WS = ("Connection: Upgrade", "Upgrade: websocket", "Sec-WebSocket-Version: 13", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==") +cases = [ + (GM, "GET", "/", ("Accept: text/html",), None), (GM, "GET", "/", (), None), (GM, "GET", "/api/v1/books", (), None), + (GM, "GET", "/api/v1/healthcheck", (), None), (GM, "GET", "/api/v1/public-settings", (), None), + (GM, "POST", "/api/v1/setup", ("Content-Type: application/json",), '{"username":"x","password":"Stranger-1234","email":"x@x.hu","name":"x"}'), + (GM, "POST", "/api/v1/auth/login", ("Content-Type: application/json",), '{"username":"admin","password":"guess"}'), + (GM, "GET", "/ws/websocket", WS, None), (GM, "GET", "/assets/index.js", (), None), (GM, "GET", "/no-such-page", ("Accept: text/html",), None), + (MT, "GET", "/", ("Accept: text/html",), None), (MT, "GET", "/", (), None), (MT, "GET", "/history", (), None), + (MT, "POST", "/add", ("Content-Type: application/json",), '{"url":"https://example.com/v","quality":"best"}'), + (MT, "GET", "/socket.io/?EIO=4&transport=polling", (), None), (MT, "GET", "/socket.io/?EIO=4&transport=websocket", WS, None), + (MT, "GET", "/download/x.mp4", (), None), (MT, "GET", "/version", (), None), +] +say("## item 1 — a STRANGER (no cookie), from the LAN and through the simulated tunnel; GATE = the gate answered, APP = the app did") +app_answers = 0 +for host, m, p, h, d in cases: + c, head, body = curl(host, p, method=m, data=d, hdrs=h) + hl = list(h) + tc, thead, tbody = tunnel(host, p, "198.51.100.66", None, "-X", m, *sum([["-H", x] for x in hl], []), *(["--data", d] if d else [])) + g1, g2 = is_gate(c, head, body), is_gate(tc, thead, tbody) + app_answers += (not g1) + (not g2) + say(f" {host.split('.')[0]:9s} {m:4s} {p:42s} LAN {c} {'GATE' if g1 else 'APP!'} | tunnel {tc} {'GATE' if g2 else 'APP!'}") +say(f"item 1: {app_answers} app answers of {2*len(cases)} stranger requests -> {'PASS' if app_answers == 0 else 'FAIL'}") + +say("## item 2 — family members with their OWN logins (not the dashboard password)") +c, ck, head = family_login(GM, "anna") +ma = re.search(r"(?i)max-age=(\d+)", head) +say(f" anna signs in on {GM}: {c}; cookie {'set' if ck else 'NONE'}; Max-Age {ma.group(1) if ma else '?'} s = {int(ma.group(1))/86400 if ma else 0:.0f} days; flags: " + f"{', '.join(f for f in ('HttpOnly','Secure','SameSite=Lax') if f.lower() in head.lower())}; Domain attr: {'YES' if re.search(r'(?i)set-cookie: felhom_family=[^\n]*domain=', head) else 'none (host-only)'}") +for p in ("/", "/api/v1/healthcheck"): + cc, hh, bb = curl(GM, p, cookie=ck, hdrs=("Accept: text/html",)) + say(f" anna GET {GM}{p} -> {cc} {'GATE' if is_gate(cc, hh, bb) else 'APP'}") +c2, ck2, head2 = family_login(MT, "bela") +say(f" bela signs in on {MT}: {c2}; cookie {'set' if ck2 else 'NONE'}") +for host, m, p, h, d in [(MT, "GET", "/", ("Accept: text/html",), None), (MT, "GET", "/socket.io/?EIO=4&transport=polling", (), None), + (MT, "GET", "/socket.io/?EIO=4&transport=websocket", WS, None), (MT, "GET", "/history", (), None)]: + cc, hh, bb = curl(host, p, method=m, cookie=ck2, hdrs=h, timeout=5) + say(f" bela {m} {host}{p} -> {cc} {'GATE' if is_gate(cc, hh, bb) else 'APP'}") +cc, hh, bb = curl(GM, "/", cookie=ck2.replace("felhom_family", "felhom_family") if ck2 else None, hdrs=("Accept: text/html",)) +say(f" bela's MeTube cookie sent to Grimmory -> {cc} {'GATE' if is_gate(cc, hh, bb) else 'APP!'} (a session is per app host)") +cc, hh, bb = curl(GM, "/", cookie="felhom_family=" + "0"*64, hdrs=("Accept: text/html",)) +say(f" a made-up session id -> {cc} {'GATE' if is_gate(cc, hh, bb) else 'APP!'}") +subprocess.run([GSH, "9202", "docker restart spike-gate >/dev/null; sleep 3"], capture_output=True) +cc, hh, bb = curl(GM, "/", cookie=ck, hdrs=("Accept: text/html",)) +say(f" after the gate RESTARTED, anna's cookie -> {cc} {'GATE' if is_gate(cc, hh, bb) else 'APP (session survived)'}") +lc, lh, lb = curl(GM, "/__family/logout", cookie=ck) +say(f" anna logs out -> {lc} {re.search(r'(?im)^location: (.*)$', lh).group(1).strip() if re.search(r'(?im)^location:', lh) else ''}") +cc, hh, bb = curl(GM, "/", cookie=ck, hdrs=("Accept: text/html",)) +say(f" anna's OLD cookie after logout -> {cc} {'GATE (refused)' if is_gate(cc, hh, bb) else 'APP! (still in)'}") +c3, ck3, _ = family_login(GM, "anna", "wrong-password-x") +say(f" anna with a WRONG password -> {c3}, cookie {'set!' if ck3 else 'none'}") +json.dump({"anna_gm": family_login(GM, "anna")[1], "bela_mt": ck2}, open(f"{SPIKE}/cookies.json", "w")); import os; os.chmod(f"{SPIKE}/cookies.json", 0o600) diff --git a/documentation/audits/permanent-gate-2026-10-01/tools/sp.py b/documentation/audits/permanent-gate-2026-10-01/tools/sp.py new file mode 100644 index 00000000..fbe5bed9 --- /dev/null +++ b/documentation/audits/permanent-gate-2026-10-01/tools/sp.py @@ -0,0 +1,55 @@ +"""sp.py — the permanent-gate spike's client (2026-10-01, 9202). curl through 9202's traefik over the LAN, or through the +SIMULATED tunnel (a curl container at 172.16.253.2 on 9202, sending what Cloudflare sends). Secrets live in the 0600 +scratch files named by $SPIKE; never printed.""" +import json, os, re, subprocess, time +SPIKE = os.environ["SPIKE"] # scratch dir: family-pw.json, secrets.json +BASE = "https://192.168.0.114" +DOM = "enkisfelhom.hu" +GM, MT = f"gm-spike.{DOM}", f"mt-spike.{DOM}" +FAM = json.load(open(f"{SPIKE}/family-pw.json")) +SEC = json.load(open(f"{SPIKE}/secrets.json")) +GSH = "/tmp/claude-1000/-mnt-5-hdd-felhom-eu-git/05c3d295-b388-481a-8c36-44a2a80b7d7d/scratchpad/g.sh" + +def curl(host, path, *a, method=None, data=None, cookie=None, hdrs=(), out_body=True, timeout=15): + """LAN request. Returns (code, headers-text, body).""" + args = ["curl", "-sk", "--max-time", str(timeout), "-D", "-", "-H", f"Host: {host}"] + for h in hdrs: args += ["-H", h] + if cookie: args += ["-H", f"Cookie: {cookie}"] + if method: args += ["-X", method] + if data is not None: args += ["--data", data] + args += list(a) + [f"{BASE}{path}"] + r = subprocess.run(args, capture_output=True, text=True, errors="replace") + raw = r.stdout + raw = raw.replace("\r\n", "\n") + head, _, body = raw.partition("\n\n") + while head.startswith("HTTP/") and (" 100 " in head.split("\n")[0]) and body: + head, _, body = body.partition("\n\n") + m = re.match(r"HTTP/\S+ (\d+)", head) + return (m.group(1) if m else "000"), head, body + +def tunnel(host, path, visitor, forged_left=None, *a): + """Through the SIMULATED tunnel: a curl container at 172.16.253.2 with Cloudflare's headers. Returns (code, headers, body).""" + xff = f"{forged_left}, {visitor}" if forged_left else visitor + extra = " ".join("'" + x.replace("'", "'\\''") + "'" for x in a) + cmd = (f"docker run --rm --network felhom-tunnel --ip 172.16.253.2 curlimages/curl:8.11.1 -sk --max-time 15 -D - " + f"'https://traefik{path}' -H 'Host: {host}' -H 'X-Forwarded-For: {xff}' -H 'CF-Connecting-IP: {visitor}' {extra}") + r = subprocess.run([GSH, "9202", cmd], capture_output=True, text=True, errors="replace") + head, _, body = r.stdout.replace("\r\n", "\n").partition("\n\n") + m = re.match(r"HTTP/\S+ (\d+)", head) + return (m.group(1) if m else "000"), head, body + +def cookie_from(head, name="felhom_family"): + m = re.search(rf"(?im)^set-cookie: {name}=([^;]*)", head) + return f"{name}={m.group(1)}" if m and m.group(1) else None + +def family_login(host, member, password=None): + from urllib.parse import urlencode + code, head, body = curl(host, "/__family/login", method="POST", + data=urlencode({"user": member, "password": password or FAM[member], "rd": "/"}), + hdrs=("Content-Type: application/x-www-form-urlencoded",)) + return code, cookie_from(head), head + +def is_gate(code, head, body): + """True when the answer is the GATE's (302 to /__family/login, or its 401 JSON) — never the app's.""" + if code == "302" and re.search(r"(?im)^location: https://[^/]+/__family/login", head): return True + return code == "401" and "sign in with your family login" in body diff --git a/documentation/audits/visitors-2026-10-01/A/DESIGN.md b/documentation/audits/visitors-2026-10-01/A/DESIGN.md new file mode 100644 index 00000000..0adcb1ee --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/DESIGN.md @@ -0,0 +1,96 @@ +# Part A — the box tells visitors apart (R-753): design, measurements, decision + +Written 2026-10-01 evening, before the release was built (the build followed the measurements below; one bug the hand +prototype found is folded in). Rule that binds every choice: **never believe an address a client can write.** + +## 1. The paths, measured + +Two outside addresses were available: DooPlex's public IPv4 `37.191.56.193` (no IPv6), and ep0 (one request, used for the +live proof in §6, not here). Venue: demo-hp's REAL tunnel (`*.enkisfelhom.hu → https://traefik`) and an echo app +(`traefik/whoami:v1.11`) on demo-hp, removed afterwards. + +| file | what | +|---|---| +| `M1-status-quo.txt` | through the tunnel, today's traefik (trusts nothing): XFF and X-Real-Ip = cloudflared `172.18.0.5` for EVERY visitor; `CF-Connecting-IP` = the visitor; a client's `Forwarded`, `True-Client-Ip` pass traefik untouched; **a client-sent `CF-Connecting-IP` is refused by Cloudflare's edge with 403** | +| `M2-what-cloudflared-delivers.txt` | traefik `insecure` for one minute (shows what arrives): `X-Forwarded-For: 6.6.6.6,37.191.56.193, 172.18.0.5` — **Cloudflare APPENDS the visitor to a client-written chain**; a client's `X-Real-IP` does NOT arrive (stripped); a client's **`X-Forwarded-Host: evil.example` and `X-Forwarded-Port: 8443` DO arrive**; `X-Forwarded-Proto` is overwritten (`https`) | +| `M3-restored.txt` | traefik back as it was | +| `M4-lan-path.txt` | LAN, forged headers: XFF / X-Real-Ip = the real LAN address (traefik drops the forged chain); **a forged `CF-Connecting-IP: 7.7.7.7` arrives** | + +| path | TCP peer at traefik | XFF traefik forwards today | the real visitor is in | forgeable by the visitor | +|---|---|---|---|---| +| tunnel | cloudflared, docker-assigned (`172.18.0.5`) | cloudflared's address, for everyone | `CF-Connecting-IP`; Cloudflare's XFF (rightmost of its part) | XFF leftmost: yes (once trusted); CF-Connecting-IP: no (edge 403) | +| LAN | the LAN client | the LAN client | XFF / X-Real-Ip | no (traefik drops a forged chain); CF-Connecting-IP: YES | + +## 2. Options, and the one taken + +**Question:** how do the box and its apps learn each visitor's own address, without believing anything a client writes? + +- **(a) Controller only.** No traefik change; the controller believes `CF-Connecting-IP` only when the hop traefik saw is + cloudflared's address. Cost: apps keep "one address" for every tunnel visitor (R-775 Grimmory, Home Assistant's + `local_only` hole, Kimai/zipline/vikunja lockouts stay); cloudflared's address must be fixed anyway. +- **(b) traefik trusts cloudflared's fixed address** (the reviewer's sketch). Apps that read X-Forwarded-For from the RIGHT + get the real visitor; the controller the same. Cost: (1) every app that reads the LEFTMOST entry would believe a + stranger's address (the sweep found 19); (2) traefik then keeps a client's `X-Forwarded-Host`/`-Port` (M2) — host-header + poisoning for apps that build links from it. +- **(c) A traefik plugin or our controller as `forwardAuth` for every request** to rewrite the chain to one address. + Cost: a new external dependency (plugin), or the controller in every request path (an outage takes every app down). + +**Taken: (b), with both of its costs paid in the same rollout.** It is the only one that gives the APPS the visitor +(decision 63's purpose), needs no new dependency, and keeps the controller out of the request path. + +- Cost (2): an entrypoint middleware `felhom-forwarded@file` removes every header a client could write a host, path or + address into (`X-Forwarded-Host/-Uri/-Method/-Prefix/-Tls-Client-Cert(-Info)`, `Forwarded`, `True-Client-Ip`, + `X-Client-Ip`, `X-Cluster-Client-Ip`, `Client-Ip`, `X-Original-Forwarded-For`) and fixes `X-Forwarded-Port: 443`. + An app that falls back from X-Forwarded-Host reads `Host`, which names the same app. +- Cost (1): the 19 leftmost readers carry a router middleware that removes the chain (`-xff`); measured (P1) that + such an app then receives NO X-Forwarded-For and reads X-Real-Ip (traefik-set) or its peer — exactly as unforgeable as + today. Shipped in the catalog BEFORE the controller release (harmless without the trust). + +**Docs quoted.** traefik (v3.6, `doc.traefik.io/traefik/reference/install-configuration/entrypoints`): *"forwardedHeaders. +trustedIPs — Trust only forwarded headers from selected IPs"*; the forwardAuth reference: *"trustForwardHeader is deprecated +… configure trusted IPs at the EntryPoint level using forwardedHeaders.trustedIPs"*. traefik source v3.6.7 +(`pkg/middlewares/forwardedheaders/forwarded_header.go`): an untrusted peer's `X-Forwarded-*`/`X-Real-Ip` are DELETED; +a trusted peer's are KEPT and `X-Real-Ip` is set only when absent. Cloudflare's HTTP-headers page: X-Forwarded-For — *"If +an X-Forwarded-For header was already present in the request to Cloudflare, Cloudflare appends the IP address of the HTTP +proxy connecting to Cloudflare"* — measured in M2. + +## 3. The shape built (controller v0.286.x, `internal/infra` + `internal/stacks/infra.go`) + +- Network `felhom-tunnel` `172.16.253.0/29`, gateway `.1`, docker's allocation confined to `--ip-range 172.16.253.4/30`; + cloudflared ALONE on it at `.2`, traefik at `.3` (and on `traefik-public`). Why 172.16.x: private (apps' default proxy + lists — Tomcat, Rack, remote_ip — skip it) and outside docker's default pools (172.17–172.31, 192.168). **Found by the + hand prototype on 9202 (P1): without the ip-range and traefik's own fixed address, traefik joining first was given `.2`.** +- traefik `websecure`: `forwardedHeaders.trustedIPs: ["172.16.253.2/32"]` and `http.middlewares: [felhom-forwarded@file]`. +- `EnsureBaseStack` reconciles a RUNNING traefik/cloudflared when the rendered files differ (recreate; refuses a rewrite that + would drop the running certificate resolver); writes the middleware file before `traefik.yml`; moves cloudflared only + once traefik is on the tunnel network. If the network cannot be made, nothing is trusted and cloudflared stays put. +- **One rule for the controller** (`internal/web/clientaddr.go`): believed only when the TCP peer is traefik (docker DNS); + the RIGHTMOST X-Forwarded-For entry is the hop traefik saw; that hop being `172.16.253.2` → `CF-Connecting-IP`. It holds + for the dashboard (the whole chain) and the setup gate's forwardAuth request (only traefik's hop), and with or without + the trust. Readers in apps: from the RIGHT, skipping trusted proxies — **a fixed count from the right is wrong for one of + the two paths** (tunnel: 2nd from the right; LAN: 1st), so count-based readers (calibre-web, tandoor, wger) are left as + they are. + +## 4. What it gives the apps (sweep, READ in source — `sweep/sweep-1..4.md`) + +- **Real visitor with no change:** actualbudget, immich, dawarich, claper (tunnel), termix, **Home Assistant** (it treated + every internet visitor as "local" — a `local_only` user could sign in from the internet; fixed by this), **Grimmory** + (R-775: its IP lock becomes per visitor; the per-NAME lock stays). +- **Need one setting to see it** (catalog, after the release): bookstack `APP_PROXIES`, kimai `TRUSTED_PROXIES`, zipline + `CORE_TRUST_PROXY`/`CORE_TRUSTED_PROXIES`, vikunja `VIKUNJA_SERVICE_IPEXTRACTIONMETHOD=xff`, nextcloud `TRUSTED_PROXIES`; + Jellyfin `KnownProxies` (no env — `network.xml`). +- **Chain removed on their router (19):** adventurelog, audiobookshelf, code-server, docmost, emby, ghost, gokapi, komga, + mealie, opengist, outline, paperless-ngx, papra, plant-it, rallly, romm, seerr, sparkyfitness, uptime-kuma. +- **Stay "one address" on the tunnel, unforgeable:** X-Real-Ip readers (vaultwarden, grafana, gitea, crafty, homebox), + count readers (calibre-web, tandoor, wger), peer readers (gramps-web, navidrome, radicale, wanderer, privatebin). +- **Settings that must never be turned on** (they read the leftmost): glance `proxied`, karakeep `RATE_LIMITING_ENABLED`, + onlyoffice ipfilter, vaultwarden `IP_HEADER=X-Forwarded-For`, PocketBase `UseLeftmostIP`, navidrome's reverse-proxy + whitelist (header login), Plex `ALLOWED_NETWORKS`. + +## 5. Risks stated + +- A box that rolls back to ≤ 0.285 keeps the new traefik (an old controller never rewrites a running traefik); its + `clientIP` takes the LEFTMOST entry, which a stranger then writes — the dashboard's counter becomes dodgeable until the box + moves forward. The floor never moves back; the self-update's crash roll-back is the window. Row filed. +- A NEW catalog app that reads the leftmost entry is forgeable unless its onboarding finds it — checklist row added. +- Emby: every tunnel visitor is "LAN" today and stays so (its chain is removed); Jellyfin likewise until `KnownProxies`. diff --git a/documentation/audits/visitors-2026-10-01/A/L1-9202-controller-log.txt b/documentation/audits/visitors-2026-10-01/A/L1-9202-controller-log.txt new file mode 100644 index 00000000..e79464d8 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/L1-9202-controller-log.txt @@ -0,0 +1,12 @@ +2026/10/01 19:05:37 infra.go:338: [INFO] [infra] connected felhom-controller to traefik-public +2026/10/01 19:05:37 infra.go:91: [INFO] [infra] cloudflared skipped — no cf_tunnel_token configured (LAN-only node) +2026/10/01 19:06:42 auth.go:184: [WARN] [web] Failed login from 198.51.100.66 +2026/10/01 19:06:43 auth.go:184: [WARN] [web] Failed login from 198.51.100.66 +2026/10/01 19:06:43 auth.go:184: [WARN] [web] Failed login from 198.51.100.66 +2026/10/01 19:06:44 auth.go:184: [WARN] [web] Failed login from 198.51.100.66 +2026/10/01 19:06:44 auth.go:184: [WARN] [web] Failed login from 198.51.100.66 +2026/10/01 19:06:45 auth.go:176: [WARN] [web] Login rate limited for 198.51.100.66 (5 attempts) +2026/10/01 19:06:45 auth.go:176: [WARN] [web] Login rate limited for 198.51.100.66 (5 attempts) +2026/10/01 19:06:45 auth.go:222: [INFO] [web] Login from 203.0.113.10 +2026/10/01 19:06:55 auth.go:184: [WARN] [web] Failed login from 192.168.0.180 +2026/10/01 19:06:56 auth.go:184: [WARN] [web] Failed login from 172.18.0.8 diff --git a/documentation/audits/visitors-2026-10-01/A/L1-9202-live.txt b/documentation/audits/visitors-2026-10-01/A/L1-9202-live.txt new file mode 100644 index 00000000..5d778200 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/L1-9202-live.txt @@ -0,0 +1,15 @@ +# L1 — controller 0.286.0 on scratch 9202 (hand-set image, no floor), 2026-10-01 19:06 UTC. Method: the exact endpoint the +# login form posts (POST /login on felhom.enkisfelhom.hu) through traefik. 9202 has no tunnel: the tunnel hop is SIMULATED +# by a curl container AT 172.16.253.2 on felhom-tunnel (cloudflared's fixed address) sending what Cloudflare sends. +# On start the release found traefik's files equal to its render (the hand prototype, P1) and did NOT recreate traefik. + +stranger 198.51.100.66, 7 wrong passwords, a NEW forged leftmost address each time (XFF "10.0.0., 198.51.100.66"): +try 1..5 -> Hibás jelszó. +try 6,7 -> Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva. +household 203.0.113.10, the right password, at once (seconds later): +HTTP/2 302, location: /, set-cookie: felhom_session= +LAN (DooPlex 192.168.0.180 straight to 9202:443) forging X-Forwarded-For / CF-Connecting-IP / X-Real-IP = 198.51.100.77: + counted as 192.168.0.180 +impostor container on traefik-public (NOT the tunnel address) sending CF-Connecting-IP 198.51.100.88: + counted as 172.18.0.8 (its own address) +Controller log lines: L1-9202-controller-log.txt diff --git a/documentation/audits/visitors-2026-10-01/A/M1-status-quo.txt b/documentation/audits/visitors-2026-10-01/A/M1-status-quo.txt new file mode 100644 index 00000000..71832704 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/M1-status-quo.txt @@ -0,0 +1,21 @@ +## M1 status-quo traefik (trusts nothing), from DooPlex public 37.191.56.193 — plain, 2026-10-01T18:25:46Z +RemoteAddr: 172.18.0.3:60646 +Cf-Connecting-Ip: 37.191.56.193 +X-Forwarded-For: 172.18.0.5 +X-Forwarded-Host: a1-echo.enkisfelhom.hu +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 499d523532f2 +X-Real-Ip: 172.18.0.5 +## M1 status-quo traefik (trusts nothing), from DooPlex public 37.191.56.193 — forged: XFF 6.6.6.6, X-Real-IP 8.8.4.4, True-Client-IP 9.9.9.9, X-Forwarded-Host evil.example, X-Forwarded-Port 8443, X-Forwarded-Proto http, Forwarded for=5.5.5.5 +RemoteAddr: 172.18.0.3:60646 +Cf-Connecting-Ip: 37.191.56.193 +Forwarded: for=5.5.5.5 +True-Client-Ip: 9.9.9.9 +X-Forwarded-For: 172.18.0.5 +X-Forwarded-Host: a1-echo.enkisfelhom.hu +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 499d523532f2 +X-Real-Ip: 172.18.0.5 +## M1 status-quo traefik (trusts nothing), from DooPlex public 37.191.56.193 — forged CF-Connecting-IP 7.7.7.7 alone: HTTP 403 (Cloudflare's edge answers; the request never reaches the box) diff --git a/documentation/audits/visitors-2026-10-01/A/M2-what-cloudflared-delivers.txt b/documentation/audits/visitors-2026-10-01/A/M2-what-cloudflared-delivers.txt new file mode 100644 index 00000000..cb68da4e --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/M2-what-cloudflared-delivers.txt @@ -0,0 +1,21 @@ +## M2 TEMPORARY traefik forwardedHeaders.insecure (shows what cloudflared delivers), from DooPlex public 37.191.56.193 — plain, 2026-10-01T18:26:09Z +RemoteAddr: 172.18.0.3:42728 +Cf-Connecting-Ip: 37.191.56.193 +X-Forwarded-For: 37.191.56.193, 172.18.0.5 +X-Forwarded-Host: a1-echo.enkisfelhom.hu +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 499d523532f2 +X-Real-Ip: 172.18.0.5 +## M2 TEMPORARY traefik forwardedHeaders.insecure (shows what cloudflared delivers), from DooPlex public 37.191.56.193 — forged: XFF 6.6.6.6, X-Real-IP 8.8.4.4, True-Client-IP 9.9.9.9, X-Forwarded-Host evil.example, X-Forwarded-Port 8443, X-Forwarded-Proto http, Forwarded for=5.5.5.5 +RemoteAddr: 172.18.0.3:42728 +Cf-Connecting-Ip: 37.191.56.193 +Forwarded: for=5.5.5.5 +True-Client-Ip: 9.9.9.9 +X-Forwarded-For: 6.6.6.6,37.191.56.193, 172.18.0.5 +X-Forwarded-Host: evil.example +X-Forwarded-Port: 8443 +X-Forwarded-Proto: https +X-Forwarded-Server: 499d523532f2 +X-Real-Ip: 172.18.0.5 +## M2 TEMPORARY traefik forwardedHeaders.insecure (shows what cloudflared delivers), from DooPlex public 37.191.56.193 — forged CF-Connecting-IP 7.7.7.7 alone: HTTP 403 (Cloudflare's edge answers; the request never reaches the box) diff --git a/documentation/audits/visitors-2026-10-01/A/M3-restored.txt b/documentation/audits/visitors-2026-10-01/A/M3-restored.txt new file mode 100644 index 00000000..5c0fabbc --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/M3-restored.txt @@ -0,0 +1,12 @@ +## M3 traefik restored to status quo — plain, 2026-10-01T18:27:31Z +RemoteAddr: 172.18.0.3:45692 +Cf-Connecting-Ip: 37.191.56.193 +X-Forwarded-For: 172.18.0.5 +X-Forwarded-Host: a1-echo.enkisfelhom.hu +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 499d523532f2 +X-Real-Ip: 172.18.0.5 +## M3 traefik restored to status quo — forged: XFF 6.6.6.6, X-Real-IP 8.8.4.4, True-Client-IP 9.9.9.9, X-Forwarded-Host evil.example, X-Forwarded-Port 8443, X-Forwarded-Proto http, Forwarded for=5.5.5.5 +RemoteAddr: 172.18.0.3:45692 +Cf-Connecting-Ip: 37.191.56.193 diff --git a/documentation/audits/visitors-2026-10-01/A/M4-lan-path.txt b/documentation/audits/visitors-2026-10-01/A/M4-lan-path.txt new file mode 100644 index 00000000..e7ded7e7 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/M4-lan-path.txt @@ -0,0 +1,11 @@ +## M4 LAN path: DooPlex 192.168.0.180 straight to demo-hp guest 192.168.0.155:443 (no tunnel), status-quo traefik, forged headers +RemoteAddr: 172.18.0.3:45692 +Cf-Connecting-Ip: 7.7.7.7 +Forwarded: for=5.5.5.5 +True-Client-Ip: 9.9.9.9 +X-Forwarded-For: 192.168.0.180 +X-Forwarded-Host: a1-echo.enkisfelhom.hu +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 499d523532f2 +X-Real-Ip: 192.168.0.180 diff --git a/documentation/audits/visitors-2026-10-01/A/P1-9202-prototype.txt b/documentation/audits/visitors-2026-10-01/A/P1-9202-prototype.txt new file mode 100644 index 00000000..6610a687 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/P1-9202-prototype.txt @@ -0,0 +1,46 @@ +# 9202 prototype of the Part A design (hand-made, traefik static + forwarded.yml rendered by the new code), 2026-10-01T18:44:33Z +### T (tunnel simulated): a container AT 172.16.253.2 on felhom-tunnel sends what cloudflared sends (Cloudflare's chain 6.6.6.6 client-written, 203.0.113.9 real) -> p-echo +RemoteAddr: 172.18.0.5:42224 +Cf-Connecting-Ip: 203.0.113.9 +X-Forwarded-For: 6.6.6.6,203.0.113.9, 172.16.253.2 +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 364d78f29dbd +X-Real-Ip: 172.16.253.2 +### P (impostor): a container on traefik-public (NOT the tunnel address) sends the same -> p-echo +RemoteAddr: 172.18.0.5:42224 +Cf-Connecting-Ip: 203.0.113.9 +X-Forwarded-For: 172.18.0.8 +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 364d78f29dbd +X-Real-Ip: 172.18.0.8 +### T (tunnel simulated): a container AT 172.16.253.2 on felhom-tunnel sends what cloudflared sends (Cloudflare's chain 6.6.6.6 client-written, 203.0.113.9 real) -> p-echo-reset +RemoteAddr: 172.18.0.5:43836 +Cf-Connecting-Ip: 203.0.113.9 +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 364d78f29dbd +X-Real-Ip: 172.16.253.2 +### P (impostor): a container on traefik-public (NOT the tunnel address) sends the same -> p-echo-reset +RemoteAddr: 172.18.0.5:43836 +Cf-Connecting-Ip: 203.0.113.9 +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 364d78f29dbd +X-Real-Ip: 172.18.0.8 +### L (LAN): DooPlex 192.168.0.180 straight to 9202:443, forged headers -> p-echo +RemoteAddr: 172.18.0.5:42224 +Cf-Connecting-Ip: 7.7.7.7 +X-Forwarded-For: 192.168.0.180 +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 364d78f29dbd +X-Real-Ip: 192.168.0.180 +### L (LAN): DooPlex 192.168.0.180 straight to 9202:443, forged headers -> p-echo-reset +RemoteAddr: 172.18.0.5:43836 +Cf-Connecting-Ip: 7.7.7.7 +X-Forwarded-Port: 443 +X-Forwarded-Proto: https +X-Forwarded-Server: 364d78f29dbd +X-Real-Ip: 192.168.0.180 diff --git a/documentation/audits/visitors-2026-10-01/A/RP-A1-leftmost-mutant.txt b/documentation/audits/visitors-2026-10-01/A/RP-A1-leftmost-mutant.txt new file mode 100644 index 00000000..7390f61e --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/RP-A1-leftmost-mutant.txt @@ -0,0 +1,17 @@ +## RP-A1 mutant: clientIP = the pre-R-753 LEFTMOST X-Forwarded-For hop (2026-10-01T18:37:48Z) +=== RUN TestClientIP_Paths + clientaddr_test.go:76: tunnel, forged leftmost: clientIP(remote="172.18.0.3:5000" xff="6.6.6.6,37.191.56.193, 172.16.253.2" cf="37.191.56.193") = "6.6.6.6", want "37.191.56.193" + clientaddr_test.go:76: gate request through the tunnel: clientIP(remote="172.18.0.3:5000" xff="172.16.253.2" cf="203.0.113.50") = "172.16.253.2", want "203.0.113.50" + clientaddr_test.go:76: direct, forged headers: clientIP(remote="192.168.0.50:4000" xff="1.2.3.4" cf="5.6.7.8") = "1.2.3.4", want "192.168.0.50" + clientaddr_test.go:76: old cloudflared address: clientIP(remote="172.18.0.3:5000" xff="6.6.6.6, 172.18.0.5" cf="9.9.9.9") = "6.6.6.6", want "172.18.0.5" + clientaddr_test.go:76: traefik, garbage hop: clientIP(remote="172.18.0.3:5000" xff="1.2.3.4, garbage" cf="") = "1.2.3.4", want "172.18.0.3" +--- FAIL: TestClientIP_Paths (0.00s) +=== RUN TestLogin_StrangerThroughTheTunnelLocksOnlyHimself + clientaddr_test.go:156: the stranger rotating a forged leftmost address must be locked after 5 tries; got: +--- FAIL: TestLogin_StrangerThroughTheTunnelLocksOnlyHimself (0.11s) +=== RUN TestLoginRateLimit_RotatingXFF_Limited + ratelimit_ip_test.go:90: a rotating X-Forwarded-For from a direct peer must NOT evade the counter; got: +--- FAIL: TestLoginRateLimit_RotatingXFF_Limited (0.11s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-controller/internal/web 0.268s +FAIL diff --git a/documentation/audits/visitors-2026-10-01/A/RP-A2-shared-key-mutant.txt b/documentation/audits/visitors-2026-10-01/A/RP-A2-shared-key-mutant.txt new file mode 100644 index 00000000..f8d9d763 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/RP-A2-shared-key-mutant.txt @@ -0,0 +1,7 @@ +## RP-A2 mutant: the tunnel hop (cloudflared) is the key — every tunnel visitor shares it (2026-10-01T18:38:03Z) +=== RUN TestLogin_StrangerThroughTheTunnelLocksOnlyHimself + clientaddr_test.go:160: the household must sign in at once from its own address; got 200 +--- FAIL: TestLogin_StrangerThroughTheTunnelLocksOnlyHimself (0.08s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-controller/internal/web 0.089s +FAIL diff --git a/documentation/audits/visitors-2026-10-01/A/RP-A3-no-reconcile-mutant.txt b/documentation/audits/visitors-2026-10-01/A/RP-A3-no-reconcile-mutant.txt new file mode 100644 index 00000000..62d5c839 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/RP-A3-no-reconcile-mutant.txt @@ -0,0 +1,11 @@ +## RP-A3 mutant: ensureTraefik returns early when traefik runs (pre-R-753) (2026-10-01T18:38:17Z) +=== RUN TestEnsureTraefik_ReconcilesARunningTraefik + infra_tunnel_test.go:148: traefik.yml was not rewritten with the tunnel trust: +--- FAIL: TestEnsureTraefik_ReconcilesARunningTraefik (0.00s) +=== RUN TestEnsureBaseStack_TunnelOrder +=== RUN TestEnsureBaseStack_TunnelOrder/network_made + infra_tunnel_test.go:246: network made → traefik trusts the tunnel and cloudflared moved; trust false moved true +=== RUN TestEnsureBaseStack_TunnelOrder/network_refused +--- FAIL: TestEnsureBaseStack_TunnelOrder (0.02s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-controller/internal/stacks 0.042s diff --git a/documentation/audits/visitors-2026-10-01/A/demo-hp-traefik.yml.before b/documentation/audits/visitors-2026-10-01/A/demo-hp-traefik.yml.before new file mode 100644 index 00000000..3279e97f --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/demo-hp-traefik.yml.before @@ -0,0 +1,45 @@ +# Traefik Static Configuration +# Generated by felhom-controller (base-infra bring-up). Do not edit — regenerated on bring-up. + +api: + dashboard: true + insecure: false + +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + websecure: + address: ":443" + http: + tls: + certResolver: letsencrypt + +providers: + docker: + endpoint: "unix:///var/run/docker.sock" + exposedByDefault: false + network: traefik-public + file: + directory: /etc/traefik/dynamic + watch: true + +log: + level: INFO + +accessLog: {} + +certificatesResolvers: + letsencrypt: + acme: + email: doodoo21@freemail.hu + storage: /etc/traefik/acme.json + dnsChallenge: + provider: cloudflare + resolvers: + - "1.1.1.1:53" + - "8.8.8.8:53" diff --git a/documentation/audits/visitors-2026-10-01/A/sweep/sweep-1.md b/documentation/audits/visitors-2026-10-01/A/sweep/sweep-1.md new file mode 100644 index 00000000..eb62804a --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/sweep/sweep-1.md @@ -0,0 +1,20 @@ +# Catalog sweep 1/4 — READ in source at each pinned tag (not measured live). Subagent report 2026-10-01, condensed. +Apps: actualbudget adventurelog audiobookshelf bentopdf bookstack calcom calibre-web claper code-server crafty-controller dawarich docmost emby ghost. +NEW chain = tunnel "forged…, real, 172.16.253.2"; LAN "lanclient". + +| App (tag) | How it reads the visitor | Used for | Verdict | Evidence | +|---|---|---|---|---| +| actualbudget 26.9.0 | Express trust proxy, CIDR list from the RIGHT (`ACTUAL_TRUSTED_PROXIES` default private ranges) | login limiter 5/15 min by IP | SAFE; real client on both paths after Part A | packages/sync-server/src/app.ts:31; load-config.js:133-143; app-account.js:26-33 | +| adventurelog v0.13.0 | django-allauth 0.63.3 LEFTMOST XFF | allauth defaults: login_failed 10/m/ip + 5/300s/username | RISK (per-IP part forgeable) → router reset | allauth account/adapter.py:704-710; app settings.py:326,375-386 | +| audiobookshelf 2.37.1 | request-ip: x-client-ip, LEFTMOST XFF, cf-connecting-ip, x-real-ip | auth limiter 40/10 min by IP ONLY | RISK (unlimited guessing) → router reset | server/utils/rateLimiterFactory.js:9-10,53-61; libs/requestIp/index.js:16-68 | +| bentopdf v2.8.6 | — static | nothing | SAFE | Dockerfile:80,105 | +| bookstack 26.09.1 | Laravel TrustProxies from the RIGHT, only with `APP_PROXIES` (empty) | login 5/min `username\|ip`; MFA limiter by IP; audit IP | SAFE as is (sees traefik); `APP_PROXIES=172.16.0.0/12` → real client on both paths | TrustProxies.php; ThrottlesLogins.php:63-66; MfaVerificationLimiter.php:60 | +| calcom v6.2.0 | cf-connecting-ip, true-client-ip, LEFTMOST XFF, x-real-ip | limits are no-ops without UNKEY_ROOT_KEY; IP_BANLIST unset | SAFE as is (revisit if those are set) | packages/lib/getIP.ts:22-33; rateLimit.ts:33-41 | +| calibre-web CWA v4.0.8 | werkzeug ProxyFix count from the right (`TRUSTED_PROXY_COUNT`=1) | login limit by USERNAME; register/kobo by IP; session bound to IP | SAFE as is (count 1 → cloudflared on the tunnel, LAN client on the LAN); count 2 breaks the LAN path and proto/host — keep 1 | cps/__init__.py:89-92; cps/web.py:2056-2057,2218-2219 | +| claper v2.5.0 | remote_ip from the RIGHT skipping private ranges | auth limiter 10/min by IP | SAFE; real client on the tunnel | lib/claper_web/endpoint.ex:63-65; remote_ip lib/remote_ip.ex:252-278 | +| code-server 4.129.0 | logs the raw XFF | global login limiter | SAFE (log text client-written) → router reset for the log | src/node/routes/login.ts:12-26,105-111 | +| crafty-controller 4.11.0 | X-Real-IP first, then leftmost XFF | logs; lockout keys never match (inert, inferred) | SAFE (X-Real-Ip is traefik-set) | base_handler.py:71-101 | +| dawarich 1.15.3 | Rack Request#ip from the RIGHT, default trusted private | Rack::Attack logins/ip 20/min, logins/email 5/min; Devise lockable | SAFE; real client after Part A | config/initializers/rack_attack.rb:269-292 | +| docmost 0.96.0 | Fastify trustProxy true = LEFTMOST | AUTH throttler 10/min by IP ONLY | RISK → router reset | apps/server/src/main.ts:25,97; user-throttler.guard.ts | +| emby 4.11.0.4 (decompiled) | LEFTMOST XFF, else X-Real-IP; wizard forces AllAddresses | LAN PRIVILEGES (remote access off-users, IP filter bypass, forgot-password PIN) | RISK → router reset; and a risk TODAY: cloudflared/traefik are private → every tunnel visitor is "LAN" (row) | BaseRequest.cs InitRemoteConnectionInfo; NetworkManager.cs:397-437 | +| ghost 6.67.0 | Express trust proxy true = LEFTMOST | brute: userLogin IP+username, globalBlock per IP | RISK → router reset | ghost/core/core/shared/express.js:21-25; brute.js | diff --git a/documentation/audits/visitors-2026-10-01/A/sweep/sweep-2.md b/documentation/audits/visitors-2026-10-01/A/sweep/sweep-2.md new file mode 100644 index 00000000..559109a3 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/sweep/sweep-2.md @@ -0,0 +1,27 @@ +# Catalog sweep 2/4 — who reads the visitor's address (READ in source at each pinned tag; not measured live) + +Subagent report, 2026-10-01 evening, copied verbatim in substance. Apps: gitea glance gokapi grafana gramps-web +home-assistant homebox homepage immich jellyfin karakeep kimai komga mealie. + +| App (tag) | Reads forwarded headers? | How / setting (default) | Used for | Verdict for the new chain | Evidence | +|---|---|---|---|---|---| +| gitea 1.27.3 | Only from a trusted peer; default trust = loopback, so traefik is not trusted | chi proxy: X-Real-IP first, then XFF count-from-right (`REVERSE_PROXY_LIMIT`=1); `REVERSE_PROXY_TRUSTED_PROXIES` (default `127.0.0.0/8,::1/128`) | logs ("Failed authentication attempt … from"), `InitialIP`; no IP lockout | SAFE AS IS (sees traefik). Trusting 172.16.0.0/12 would show the LAN client, but cloudflared on the tunnel (X-Real-IP wins). Never forgeable | modules/setting/security.go:133-137; routers/common/middleware.go:33,123-133; chi-middleware/proxy v1.1.1 middleware.go:50-73; routers/web/auth/auth.go:310 | +| glance v0.8.5 | Only with `server.proxied: true` (off; not in our seed) | leftmost XFF when on | login limit by IP, only with `auth:` (our seed has none) | SAFE AS IS. **Never set `proxied: true`** (leftmost) | internal/glance/glance.go:364-389; auth.go:24-25,142-160 | +| gokapi v1.9.6 | always: first parseable XFF, X-Real-IP, peer | leftmost, no setting | download log only when `SaveIp` (seed: false); no login limit | SAFE AS IS (unused) | internal/logging/Logging.go:43-48,65-93 | +| grafana 13.2.3 | always | X-Real-IP first, then leftmost XFF; no trust setting | lockout by USERNAME (5/5 min); IP lockout OFF by default; logs, session client-IP | SAFE AS IS (X-Real-Ip is traefik-set: cloudflared on the tunnel, LAN client on the LAN). **Do not turn on IP lockout** | pkg/web/context.go:71-96; conf/defaults.ini:498-507; loginattemptimpl/login_attempt.go:65-99 | +| gramps-web v25.6.0 | no (flask_limiter `get_remote_address` = TCP peer) | peer | `1/second` on token/login/register, keyed on the peer = ONE bucket for all | SAFE AS IS, no change from Part A | gramps-web-api v3.3.0 ratelimiter.py:5-9; token.py:73,104,128,143 (API version INFERRED from `FROM dmstraub/gramps-webapi:latest`) | +| home-assistant 2026.9.4 | yes; template sets `use_x_forwarded_for: true`, `trusted_proxies: [172.16.0.0/12]` | walks from the RIGHT skipping trusted; all trusted → leftmost; non-IP entry → 400 | `ip_ban` (threshold -1 = off); **LAN privilege**: `local_only` users, remember-me preselect | SAFE AS IS and **FIXED by Part A**: today every tunnel visitor arrives as cloudflared's PRIVATE address → "local" → a `local_only` user can sign in from the internet. After: skips 172.16.253.2, takes the real public client | components/http/forwarded.py:83-144; http/auth_util.py:15-43; util/network.py:51-53 | +| homebox 0.26.2 | only with `HBOX_OPTIONS_TRUST_PROXY=true` (default false, not set) | X-Real-IP first, then leftmost XFF | login/forgot/reset limiter keyed `IP\|path` (5/min) | SAFE AS IS (not forgeable; but one bucket = a stranger can lock everyone out — as today). Turning trust on helps the LAN only (X-Real-Ip = cloudflared on the tunnel); also makes it trust X-Forwarded-Host — optional, small gain | backend/app/api/middleware.go:454-490,556-575; internal/sys/config/conf.go:74,179-184 | +| homepage v1.13.2 | no | — | no auth; Host check on `/api/*` | SAFE AS IS | src/middleware.js:3-18 | +| immich v3.2.4 | yes (Express trust proxy) | walks from the RIGHT (proxy-addr); `IMMICH_TRUSTED_PROXIES` default linklocal,uniquelocal (+loopback) | logs only; no IP lockout | SAFE AS IS and **sees the real client** after Part A | server/src/app.common.ts:49; config.repository.ts:327; auth.service.ts:71 | +| jellyfin 10.11.11 | only when `KnownProxies` set (default empty) | ASP.NET ForwardedHeaders from the RIGHT | **LAN privileges** (remote access per user, remote bitrate, public user list, restart for non-admins, ForgotPassword); lockout per user | **NEEDS A SETTING — and a RISK TODAY that Part A alone does not change**: the TCP peer is traefik (private) → every internet visitor is "LAN". Fix: KnownProxies `172.16.0.0/12` in `network.xml` (no env var) | ApiServiceCollectionExtensions.cs:169-190,282-326; NetworkManager.cs:309-340,942-960; UserManager.cs:595-596 | +| karakeep 0.33.2 | always (`request-ip`) | X-Client-IP, then LEFTMOST XFF, then CF-Connecting-IP, … | login + tRPC limits keyed by IP — **only with `RATE_LIMITING_ENABLED=true`** (default false, not set) | SAFE AS IS (limiter off). **Do not turn the limiter on** — after Part A its key would be the client-written leftmost | apps/web/server/auth.ts:129-137; packages/trpc/lib/rateLimit.ts:21-39; request-ip src/index.js:39-41,59-97 | +| kimai 2.67.0 | only from `TRUSTED_PROXIES` (image default `nginx,localhost,127.0.0.1`) | Symfony: from the RIGHT, dropping trusted | login throttling 5/5 min (Symfony default username+IP plus a per-IP limit — from Symfony docs, not Kimai code); IP-keyed limiters (session-ID guard, password reset, old API tokens) | **NEEDS A SETTING**: today every key is traefik → one attacker trips the IP limiters for all. `TRUSTED_PROXIES=127.0.0.1,172.16.0.0/12` → tunnel real client, LAN client | Dockerfile:256; config/packages/security.yaml:70-72; rate_limiter.yaml | +| komga 1.28.0 | always (`forward-headers-strategy: framework`) | LEFTMOST | the authentication-activity audit IP only | SAFE AS IS for security; the audit IP becomes client-written on the tunnel (today: cloudflared). `SERVER_FORWARDHEADERSSTRATEGY=native` would fix it — not without a live test | application.yml:63; LoginListener.kt:30-96 | +| mealie v3.28.0 | yes; and `/api/auth/token` reads raw XFF | LEFTMOST | log lines; lockout per ACCOUNT | SAFE AS IS; the logged IP becomes client-written on the tunnel. No setting fixes it | routes/auth/auth.py:141-147; credentials_provider.py:41-54 | + +Notes from the report: leftmost readers (glance if `proxied`, gokapi, karakeep, komga, mealie) use the address for nothing +or for logs/audit only as configured — two switches must stay off (karakeep `RATE_LIMITING_ENABLED`, glance `proxied`). +Not checked live: ASP.NET (Jellyfin) and HA with a 3-entry XFF (HA refuses when X-Forwarded-Proto has neither 1 entry nor +as many as XFF — traefik sends 1); Kimai's exact throttling keys. Side observations: glance's seed has no `auth:` (public +dashboard); homepage `/api/*` refuses a Host not in `HOMEPAGE_ALLOWED_HOSTS` (inferred, not set by the template). diff --git a/documentation/audits/visitors-2026-10-01/A/sweep/sweep-3.md b/documentation/audits/visitors-2026-10-01/A/sweep/sweep-3.md new file mode 100644 index 00000000..d474364e --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/sweep/sweep-3.md @@ -0,0 +1,19 @@ +# Catalog sweep 3/4 — READ in source at each pinned tag (not measured live). Subagent report 2026-10-01, condensed. +Apps: n8n navidrome nextcloud onlyoffice opengist outline paperless-ngx papra plant-it plex privatebin radarr radicale rallly. + +| App (pin) | How | Used for | Verdict | Evidence | +|---|---|---|---|---| +| n8n 2.42.1 | Express trust proxy hop count (`N8N_PROXY_HOPS`=0) | IP limit 1000/5min + per email | SAFE; optional `N8N_PROXY_HOPS=2` | packages/@n8n/config/src/index.ts:266-268 | +| navidrome 0.64.2 | peer unless ExtAuth.TrustedSources set (not set) | login 5/20s per IP | SAFE. **Never set the whitelist** (opens Remote-User login) | server/middlewares.go:171-201 | +| nextcloud 34.0.4 | Apache mod_remoteip X-Real-IP from private; core trusted_proxies from the right | brute-force throttle by IP | SAFE as is (tunnel = one bucket); `TRUSTED_PROXIES=172.16.0.0/12` → real client | Request.php:591-631; Throttler.php:50-59 | +| onlyoffice 9.4.0 | leftmost XFF only with ipfilter on (off) | — | SAFE; never enable ipfilter | Common/sources/utils.js:1052-1066 | +| opengist 1.15 | echo RealIP LEFTMOST | logs only | log text forgeable → router reset | echo context.go:309-331 | +| outline 1.10.1 | Koa proxy LEFTMOST (`PROXY_IP_HEADER` X-Forwarded-For) | per-IP limits; sign-in link bound to IP | RISK → router reset (or `PROXY_IP_HEADER=X-Real-IP`) | server/services/web.ts:31-39; rateLimiter.ts:31-52 | +| paperless-ngx 2.20.15 | allauth 65.12.1 LEFTMOST | login_failed 10/m/ip + 5/300s/username | RISK (per-IP part) → router reset | allauth account/adapter.py:774-780 | +| papra 26.6.2 | better-auth LEFTMOST; invalid → rate limit SKIPPED | sign-in/up limit per IP | RISK (junk value turns the limit off) → router reset (or `AUTH_IP_ADDRESS_HEADERS=x-real-ip`) | auth.config.ts:70-82; better-auth rate-limiter/index.ts:167-171 | +| plant-it 0.10.0 | LEFTMOST | per-IP limiter in an unbounded map | RISK (evasion + memory growth) → router reset | RateLimitFilter.java:36-60 | +| plex 1.41.4 | closed source; XFF "in most places" (inferred) | allowedNetworks auth bypass (unset) | SAFE as templated; never set ALLOWED_NETWORKS | forum links (inferred) | +| privatebin 2.0.6 | only with `[traffic] header` (unset) | paste flood limit | SAFE; never `X_FORWARDED_FOR` | TrafficLimiter.php:52-154 | +| radarr 6.4.4 | ASP.NET from the right, TrustedNetworks (loopback) | local-address auth bypass (needs no leftover XFF) | SAFE; optional TrustedNetworks 172.16.0.0/12 | ForwardedHeadersConfigurator.cs:15-38 | +| radicale 3.8.1 | REMOTE_ADDR | logs | SAFE | radicale/app/__init__.py:449-458 | +| rallly 4.15.3 | better-auth (multi-hop unresolvable → shared); /api/event LEFTMOST | sign-in limits; /api/event limit | auth SAFE; /api/event forgeable → router reset | better-auth utils/ip.ts:283-372; api/event route.ts:72-79 | diff --git a/documentation/audits/visitors-2026-10-01/A/sweep/sweep-4.md b/documentation/audits/visitors-2026-10-01/A/sweep/sweep-4.md new file mode 100644 index 00000000..c59445e1 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/A/sweep/sweep-4.md @@ -0,0 +1,21 @@ +# Catalog sweep 4/4 — READ in source at each pinned tag (not measured live). Subagent report 2026-10-01, condensed. +Apps: recipe-importer romm seerr sonarr sparkyfitness tandoor termix uptime-kuma vaultwarden vikunja wanderer wger wishlist zipline grimmory metube. + +| App (pin) | How | Used for | Verdict | Evidence | +|---|---|---|---|---| +| recipe-importer v0.9.11 | gunicorn peer | logs | SAFE | app/main.py:31-53 | +| romm 5.3.1 | `--forwarded-allow-ips=*` uvicorn LEFTMOST (hard-coded) | per-IP pair-code / device-auth limits | RISK (low: codes unguessable) → router reset | docker/init_scripts/init:141; backend/utils/rate_limit.py:9 | +| seerr 2.7.3 | trustProxy setting (UI, default off) → LEFTMOST | logs; forwards XFF to Jellyfin | SAFE while off → router reset (household-switchable) | server/index.ts:140-141 | +| sonarr 4.0.20 | ASP.NET from the right, TrustedNetworks | local bypass (refused while XFF left over) | SAFE; optional | ForwardedHeadersConfigurator.cs:15-44 | +| sparkyfitness v0.17.3 | better-auth LEFTMOST; Express trust proxy 1 | sign-in 3/10s per IP; nginx global 5 r/s | RISK → router reset | better-auth get-request-ip.ts:18-26; docker/nginx.conf | +| tandoor 2.6.15 | allauth `ALLAUTH_TRUSTED_PROXY_COUNT`=1 (count from the right) | login 5/m/ip etc. | SAFE as is (one bucket); no count fits both paths — leave | recipes/settings.py:712-719; allauth httpkit.py:197-220 | +| termix 2.8.0 | bundled nginx real_ip recursive from the right | login limiter per IP and per username | SAFE and better: real client after Part A | docker/nginx.conf:44-49 | +| uptime-kuma 2.5.5 | trustProxy setting (DB, default off) → LEFTMOST | logs only (limiters global) | SAFE → router reset (household-switchable) | server/uptime-kuma-server.js:160-195 | +| vaultwarden 1.36.0 | `IP_HEADER` X-Real-IP (default) | login/admin limits per IP | SAFE (X-Real-Ip traefik-set; tunnel = one bucket). **Never** `IP_HEADER=X-Forwarded-For` | src/config.rs:669-671; src/auth.rs:1053-1066 | +| vikunja 2.6.0 | `VIKUNJA_SERVICE_IPEXTRACTIONMETHOD` direct (peer); xff walks from the right | login floor 10/min per IP | needs a setting: `xff` → real client on both paths | pkg/routes/ip.go:39-52; echo ip.go:242-266 | +| wanderer v0.20.0 | PocketBase peer (TrustedProxy empty) | PocketBase limiter off | SAFE; never UseLeftmostIP | core/event_request.go:40-74 | +| wger 2.7 | axes/ipware REMOTE_ADDR, proxy count 0 | lockout per USERNAME (decision 58) | SAFE; leave (no count fits both paths) | settings/main.py:267-274 | +| wishlist v0.67.1 | — | — | SAFE (HEADER_USERNAME must stay unset) | src/hooks.server.ts:45 | +| zipline 4.8.0 | Fastify trustProxy with CIDR list (off) | login 7/10s by IP (limiter on) | needs a setting: `CORE_TRUST_PROXY=true`, `CORE_TRUSTED_PROXIES=172.16.0.0/12` | src/server/index.ts:55-67 | +| grimmory v3.4.1 (wip) | Tomcat RemoteIpValve, internalProxies incl. 172.16.0.0/12 — from the right | `login:ip:` 5/15 min (+ `login:user:`) | SAFE and FIXED by Part A (R-775's IP lock becomes per visitor); the per-NAME lock remains | application.yaml:63; Spring Boot TomcatServerProperties.java:756-757; AuthRateLimitService.java:18-57 | +| metube 2026.09.29 | — | — (no auth) | SAFE | app/main.py:400-420 | diff --git a/documentation/audits/visitors-2026-10-01/D/L-D1-r772-live.txt b/documentation/audits/visitors-2026-10-01/D/L-D1-r772-live.txt new file mode 100644 index 00000000..4f744844 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/D/L-D1-r772-live.txt @@ -0,0 +1,15 @@ +# R-772 live on 9202 (controller 0.286.0): stop paperless-webserver (the probe's container), poll GET /api/stacks/paperless-ngx +before: running healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +stopped at 19:07:48 +19:07:53 running healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +19:07:58 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +19:08:03 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +19:08:08 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +19:08:13 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +19:08:18 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +19:08:23 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +19:08:28 degraded healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +started at 19:08:30 +19:08:40 starting healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +19:08:50 starting healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z +19:09:00 running healthy= True not_checked= None last_check= 2026-10-01T19:05:47.565376241Z diff --git a/documentation/audits/visitors-2026-10-01/D/RP-D1-r772-mutant.txt b/documentation/audits/visitors-2026-10-01/D/RP-D1-r772-mutant.txt new file mode 100644 index 00000000..567f440a --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/D/RP-D1-r772-mutant.txt @@ -0,0 +1,10 @@ +## RP-D1 mutant: the no-container record says healthy: true (pre-R-772) (2026-10-01T18:46:26Z) +=== RUN TestRunHealthProbes_NoContainerIsNotHealthy + r772_not_checked_test.go:48: a check that did not run must read NOT healthy + not_checked, got &{Healthy:true LastCheck:2026-10-01 20:46:30.111045773 +0200 CEST m=+0.010995017 Details:[{Type:none Target:karakeep Healthy:true Status:0 Latency: Error:Nem futott egészségellenőrzés: nincs hozzá tartozó konténer. MessageKey:health.no_probe_container}] NotChecked:true} +--- FAIL: TestRunHealthProbes_NoContainerIsNotHealthy (0.00s) +=== RUN TestRunHealthProbes_NotCheckedIsLookedAtAgainSoon + r772_not_checked_test.go:65: a not-checked app must be looked at again within the 10-second cycle; last check still 2026-10-01 20:46:10.111364238 +0200 CEST m=-19.988686522 +--- FAIL: TestRunHealthProbes_NotCheckedIsLookedAtAgainSoon (0.00s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-controller/internal/stacks 0.021s +FAIL diff --git a/documentation/audits/visitors-2026-10-01/D/RP-D1b-r772-order-mutant.txt b/documentation/audits/visitors-2026-10-01/D/RP-D1b-r772-order-mutant.txt new file mode 100644 index 00000000..ac08d916 --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/D/RP-D1b-r772-order-mutant.txt @@ -0,0 +1,7 @@ +## RP-D1b mutant: the interval of the last HEALTHY record is checked before the container (the 0.286.0 order, found live) (2026-10-01T19:09:45Z) +=== RUN TestRunHealthProbes_AStoppedContainerIsSeenAtOnce + r772_not_checked_test.go:91: a stopped probe container must be recorded not checked on the next tick, got &{Healthy:true LastCheck:2026-10-01 21:07:48.756949461 +0200 CEST m=-119.998618869 Details:[] NotChecked:false} +--- FAIL: TestRunHealthProbes_AStoppedContainerIsSeenAtOnce (0.00s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-controller/internal/stacks 0.006s +FAIL diff --git a/documentation/audits/visitors-2026-10-01/D/RP-D2-r773-mutant.txt b/documentation/audits/visitors-2026-10-01/D/RP-D2-r773-mutant.txt new file mode 100644 index 00000000..725e013c --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/D/RP-D2-r773-mutant.txt @@ -0,0 +1,11 @@ +## RP-D2 mutant: the restore does not re-apply the sign-up lock (pre-R-773) (2026-10-01T18:49:03Z) +=== RUN TestR773_ARemovedAppComesBackWithSignupClosed + r773_restore_signup_lock_test.go:25: the restore must record the sign-up lock (an OPEN gate record, by restore), got &{Deployed:true DeployedAt:2026-10-01T18:49:06Z Env:map[DOMAIN:example.hu SUBDOMAIN:gapp] LockedFields:[DOMAIN SUBDOMAIN] EmailEnabled:false DesiredState: InstalledImages:map[] PinnedImages:map[] LastUpdateUndone: FailedStep: LastAutoUpdate: ConversionCopy: EarlierConversionCopies:[] RestoredLogins:[] AfterInstall: SetupGate: InstallHold: PreviousImages:map[] DefaultLogin: AfterSetup:} +--- FAIL: TestR773_ARemovedAppComesBackWithSignupClosed (0.00s) +=== RUN TestR773_AnInstalledAppWithoutALockGetsNone +--- PASS: TestR773_AnInstalledAppWithoutALockGetsNone (0.00s) +=== RUN TestR773_NoLockInTheTemplateNoRecord +--- PASS: TestR773_NoLockInTheTemplateNoRecord (0.00s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-controller/internal/stacks 0.013s +FAIL diff --git a/documentation/audits/visitors-2026-10-01/D/r773-live.txt b/documentation/audits/visitors-2026-10-01/D/r773-live.txt new file mode 100644 index 00000000..7f39858e --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/D/r773-live.txt @@ -0,0 +1,9 @@ +##### R-773 live on 9202 — controller gitea.dooplex.hu/admin/felhom-controller:0.286.0 (2026-10-01T19:11:19Z) +deploy -> True + karakeep: users.create (the first account) http=200 role=admin + karakeep: POST /api/v1/bookmarks http=201 +seed (first account) -> ok +BEFORE remove — lock record + files: setup_gate: | state: open | since: "2026-10-01T19:11:19Z" | hosts: | - bookmarks.enkisfelhom.hu | opened_at: "2026-10-01T19:15:19Z" | opened_by: household | native_lock: applied | after_setup: | --- | signup-block-karakeep.yml +BEFORE remove — a stranger: {'GET /signup': '403', 'POST users.create': '403'} +night chain (debug action) -> 202 {'data': {'legs': ['db-dump', 'tier2', 'update-leg']}, 'message': 'started', 'ok': True} +restore points offered: [(None, '2026-10-01T19:15:29Z')] diff --git a/documentation/audits/visitors-2026-10-01/probe.sh b/documentation/audits/visitors-2026-10-01/probe.sh new file mode 100755 index 00000000..9e28116b --- /dev/null +++ b/documentation/audits/visitors-2026-10-01/probe.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# probe.sh