Harden network and privacy defaults; fix several bugs

Security:
- Bind HTTP gateway to 127.0.0.1 by default; add --bind for LAN opt-in
- Restrict Reticulum mesh surface to GET /api/sites only (CSRF cannot
  authenticate mesh callers, so gate by whitelist)
- Cap request body size at 16 MiB to prevent memory DoS
- Redact /bookmark query strings from request logs so the bookmark token
  and URLs do not land in stdout / docker / journal logs
- Tighten FTS5 sanitizer: strip colon, drop AND/OR/NOT/NEAR operator words
- Expand .dockerignore; document trust model in README

Features:
- Add sharing mode toggle (share everything except private vs share only
  public-tagged) with /share/preview so users can see what subscribers
  would receive before enabling sharing

Bugs:
- handle_export() crashed on every call (missing query kwarg)
- Dead float16 decompression branch in embeddings.py silently corrupted
  the HNSW index when compress_embeddings was on
- GATEWAY_PORT staleness: --port and find_available_port had no effect
  on the actual bind
- semantic_search default mismatched between db.py ("1") and the rest of
  the app ("0"), causing embeddings to be generated when the UI said off
- Connection pool returned connections with uncommitted transactions to
  the next consumer
- Gateway POST body decode 502'd on non-UTF-8 input
- ensure_rns_config clobbered user-edited ~/.reticulum/config; now only
  rewrites files it authored (sentinel-tagged)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Derick Phan 2026-04-23 15:37:45 -07:00
parent ce50150363
commit 1bc695f508
No known key found for this signature in database
8 changed files with 266 additions and 56 deletions

View file

@ -1,3 +1,4 @@
import re
import sys
import time
import threading
@ -9,6 +10,7 @@ APP_NAME = "tinyweb"
ASPECTS = ["server"]
GATEWAY_PORT = 8080
REQUEST_TIMEOUT = 60
MAX_BODY_SIZE = 16 * 1024 * 1024 # 16 MiB — covers /import and every other form
class GatewayState:
@ -71,8 +73,18 @@ class GatewayHandler(BaseHTTPRequestHandler):
body = {}
if method == "POST":
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode()
try:
length = int(self.headers.get("Content-Length", 0))
except ValueError:
self.send_error(400, "Invalid Content-Length")
return
if length < 0:
self.send_error(400, "Invalid Content-Length")
return
if length > MAX_BODY_SIZE:
self.send_error(413, "Request body too large")
return
raw = self.rfile.read(length).decode("utf-8", errors="replace")
body = parse_qs(raw)
# Parse cookies
@ -152,7 +164,14 @@ class GatewayHandler(BaseHTTPRequestHandler):
self._forward("POST")
def log_message(self, format, *args):
print(f"[Gateway] {args[0]}")
try:
msg = format % args
except TypeError:
msg = format
# /bookmark carries a long-lived token and the URL being indexed —
# redact the query so it doesn't end up in stdout, journald, docker logs, etc.
msg = re.sub(r'(/bookmark)\?\S*', r'\1?[redacted]', msg)
print(f"[Gateway] {msg}")
def main():