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:
parent
ce50150363
commit
1bc695f508
8 changed files with 266 additions and 56 deletions
62
app.py
62
app.py
|
|
@ -8,7 +8,8 @@ from http.server import HTTPServer
|
|||
|
||||
from db import init_db, get_setting, set_setting
|
||||
from handlers import dispatch_request
|
||||
from gateway import GatewayState, GatewayHandler, GATEWAY_PORT
|
||||
import gateway
|
||||
from gateway import GatewayState, GatewayHandler
|
||||
|
||||
APP_NAME = "tinyweb"
|
||||
ASPECTS = ["server"]
|
||||
|
|
@ -24,13 +25,13 @@ def get_transport_config():
|
|||
return host, int(port)
|
||||
|
||||
|
||||
def find_available_port(start=8080, max_attempts=20):
|
||||
def find_available_port(start=8080, max_attempts=20, host="127.0.0.1"):
|
||||
"""Find an available port starting from start."""
|
||||
import socket
|
||||
for port in range(start, start + max_attempts):
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("0.0.0.0", port))
|
||||
s.bind((host, port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
|
|
@ -71,16 +72,32 @@ def load_or_create_identity():
|
|||
return identity
|
||||
|
||||
|
||||
# Remote peers on the Reticulum mesh can only reach a narrow, read-only surface.
|
||||
# Any other method/path is rejected here — CSRF cannot authenticate mesh callers
|
||||
# (the attacker controls both the "cookie" and the "form" side of the check), so
|
||||
# gating by whitelist is the only safe option.
|
||||
_RNS_ALLOWED = {("GET", "/api/sites")}
|
||||
|
||||
|
||||
def rns_request_handler(path, data, request_id, link_id, remote_identity, requested_at):
|
||||
if data is None:
|
||||
data = {"method": "GET", "path": "/", "query": {}, "body": {}, "gateway_host": ""}
|
||||
method = data.get("method", "GET")
|
||||
req_path = data.get("path", "/")
|
||||
if (method, req_path) not in _RNS_ALLOWED:
|
||||
return {
|
||||
"status": 403,
|
||||
"content_type": "text/plain; charset=utf-8",
|
||||
"body": "Forbidden: this endpoint is not available over Reticulum.",
|
||||
"headers": {},
|
||||
}
|
||||
return dispatch_request(data)
|
||||
|
||||
|
||||
def start_gateway(reticulum):
|
||||
def start_gateway(reticulum, bind_host="127.0.0.1"):
|
||||
GatewayState.reticulum = reticulum
|
||||
GatewayState.local_dispatch = dispatch_request
|
||||
server = HTTPServer(("0.0.0.0", GATEWAY_PORT), GatewayHandler)
|
||||
server = HTTPServer((bind_host, gateway.GATEWAY_PORT), GatewayHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
|
@ -126,7 +143,21 @@ def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
|
|||
if transport_port is None:
|
||||
transport_port = int(get_setting("transport_port", str(DEFAULT_TRANSPORT_PORT)))
|
||||
|
||||
managed_sentinel = "# managed by tinyweb"
|
||||
if os.path.exists(config_file):
|
||||
try:
|
||||
with open(config_file) as f:
|
||||
existing = f.read()
|
||||
except OSError:
|
||||
existing = ""
|
||||
if managed_sentinel not in existing:
|
||||
# User-authored config — don't clobber it.
|
||||
if not _config_settings_match(config_file, transport_host, transport_port):
|
||||
print(
|
||||
f"Warning: {config_file} was not created by tinyweb; "
|
||||
"leaving it alone. Edit it manually to change transport/LoRa settings."
|
||||
)
|
||||
return
|
||||
if _config_settings_match(config_file, transport_host, transport_port):
|
||||
return
|
||||
|
||||
|
|
@ -164,7 +195,8 @@ def ensure_rns_config(config_dir, transport_host=None, transport_port=None):
|
|||
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
with open(config_file, "w") as f:
|
||||
f.write(f"""[reticulum]
|
||||
f.write(f"""{managed_sentinel}
|
||||
[reticulum]
|
||||
enable_transport = False
|
||||
share_instance = No
|
||||
|
||||
|
|
@ -201,15 +233,20 @@ def main():
|
|||
parser = argparse.ArgumentParser(prog="tinyweb", description="Personal decentralized search engine")
|
||||
parser.add_argument("--version", "-v", action="store_true", help="Show version")
|
||||
parser.add_argument("--port", "-p", type=int, default=None, help="HTTP gateway port (default: 8080)")
|
||||
parser.add_argument(
|
||||
"--bind", "-b", default="127.0.0.1",
|
||||
help="Address to bind the HTTP gateway to (default: 127.0.0.1). "
|
||||
"Use 0.0.0.0 to expose to the LAN; note that the web UI has no authentication.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version:
|
||||
print(f"TinyWeb {get_version()}")
|
||||
return
|
||||
|
||||
bind_host = args.bind
|
||||
port = args.port or 8080
|
||||
import gateway
|
||||
gateway.GATEWAY_PORT = find_available_port(port)
|
||||
gateway.GATEWAY_PORT = find_available_port(port, host=bind_host)
|
||||
|
||||
init_db()
|
||||
transport_host = get_setting("transport_host", DEFAULT_TRANSPORT_HOST)
|
||||
|
|
@ -238,10 +275,15 @@ def main():
|
|||
time.sleep(2)
|
||||
destination.announce()
|
||||
set_setting("dest_hash", destination.hash.hex())
|
||||
start_gateway(reticulum)
|
||||
start_gateway(reticulum, bind_host=bind_host)
|
||||
|
||||
print(f"TinyWeb running!")
|
||||
print(f"Open http://localhost:{GATEWAY_PORT} in your browser")
|
||||
if bind_host in ("0.0.0.0", "::"):
|
||||
print(f"Open http://localhost:{gateway.GATEWAY_PORT} in your browser")
|
||||
print(f"WARNING: listening on {bind_host} — the web UI has no authentication. "
|
||||
"Anyone on your network can control this instance.")
|
||||
else:
|
||||
print(f"Open http://{bind_host}:{gateway.GATEWAY_PORT} in your browser")
|
||||
print(f"Destination hash: {RNS.prettyhexrep(destination.hash)} (share this so friends can subscribe)")
|
||||
|
||||
while True:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue