diff --git a/db.py b/db.py index c9ea195..945760e 100644 --- a/db.py +++ b/db.py @@ -56,6 +56,7 @@ def clean_url(url): def get_db(): db = sqlite3.connect(DATABASE) + db.execute("PRAGMA foreign_keys = ON") db.row_factory = sqlite3.Row return db diff --git a/gateway.py b/gateway.py index 78516b3..a13816f 100644 --- a/gateway.py +++ b/gateway.py @@ -75,11 +75,22 @@ class GatewayHandler(BaseHTTPRequestHandler): raw = self.rfile.read(length).decode() body = parse_qs(raw) + # Parse cookies + cookies = {} + cookie_header = self.headers.get("Cookie", "") + if cookie_header: + for part in cookie_header.split(";"): + part = part.strip() + if "=" in part: + k, v = part.split("=", 1) + cookies[k.strip()] = v.strip() + request_data = { "method": method, "path": parsed.path, "query": query, "body": body, + "cookies": cookies, "gateway_host": self.headers.get("Host", f"localhost:{GATEWAY_PORT}"), } diff --git a/handlers.py b/handlers.py index 0045767..a16dce9 100644 --- a/handlers.py +++ b/handlers.py @@ -1,21 +1,30 @@ import json import secrets +import threading from datetime import datetime +from urllib.parse import unquote from db import get_db, get_setting, set_setting, get_site_name, index_url, clean_url from templates import esc, snippet, wrap_page, DEFAULT_TEMPLATE from rns_client import fetch_remote_sites -_csrf_token = secrets.token_hex(32) +_request_local = threading.local() + + +def _get_csrf_token(): + return getattr(_request_local, 'csrf_token', '') def _csrf_field(): - return f'' + return f'' def _check_csrf(body): token = body.get("_csrf", [""])[0] - return secrets.compare_digest(token, _csrf_token) + expected = _get_csrf_token() + if not expected or not token: + return False + return secrets.compare_digest(token, expected) def _sanitize_fts_query(query): @@ -24,6 +33,14 @@ def _sanitize_fts_query(query): return f'"{escaped}"' +def _get_bookmark_token(): + token = get_setting("bookmark_token") + if not token: + token = secrets.token_hex(16) + set_setting("bookmark_token", token) + return token + + def _respond(body_html, status=200, use_default=False): return { "status": status, @@ -34,6 +51,8 @@ def _respond(body_html, status=200, use_default=False): def _redirect(location): + if not location.startswith("/") or location.startswith("//"): + location = "/" return { "status": 302, "content_type": "text/html; charset=utf-8", @@ -337,6 +356,7 @@ def handle_delete_confirm(page_id): def handle_delete(page_id): db = get_db() + db.execute("DELETE FROM page_tags WHERE page_id = ?", (page_id,)) db.execute("DELETE FROM links WHERE page_id = ?", (page_id,)) db.execute("DELETE FROM pages WHERE id = ?", (page_id,)) db.commit() @@ -345,6 +365,10 @@ def handle_delete(page_id): def handle_bookmark(query): + token = query.get("token", [""])[0] + expected = _get_bookmark_token() + if not token or not secrets.compare_digest(token, expected): + return _text_response("error: invalid or missing token", status=403, headers={"Access-Control-Allow-Origin": "*"}) url = clean_url(query.get("url", [""])[0].strip()) if not url or not url.startswith(("http://", "https://")): return _text_response("error: invalid url", headers={"Access-Control-Allow-Origin": "*"}) @@ -389,6 +413,10 @@ def handle_import_submit(body): if not isinstance(data, list): return handle_import_form("Expected a JSON array.") + MAX_IMPORT = 100 + if len(data) > MAX_IMPORT: + return handle_import_form(f"Too many entries. Maximum is {MAX_IMPORT}.") + imported = 0 errors = 0 for entry in data: @@ -405,10 +433,7 @@ def handle_import_submit(body): return handle_import_form(f"Imported {imported} page(s). {errors} error(s).") -def handle_style_form(msg="", query=None): - if query and "reset" in query: - set_setting("custom_template", "") - msg = "Template reset to default." +def handle_style_form(msg=""): template = get_setting("custom_template") or DEFAULT_TEMPLATE name = get_site_name() sharing = get_setting("sharing_enabled", "0") @@ -430,7 +455,12 @@ def handle_style_form(msg="", query=None): f"" f"

bookmarklet

" f"

Drag this link to your bookmarks bar. Click it on any page to index it instantly.

" - f'

+ save to {esc(name)}

' + f'

+ save to {esc(name)}

' + f"

reset

" + f'
' + f'{_csrf_field()}' + f'' + f"
" f"

{msg}

" f'back', use_default=True, @@ -834,7 +864,7 @@ def handle_subscription_syncall(): # --- Dispatcher --- -def dispatch_request(data): +def _dispatch_inner(data): method = data.get("method", "GET") path = data.get("path", "/") query = data.get("query", {}) @@ -863,7 +893,7 @@ def dispatch_request(data): elif path == "/bookmark": return handle_bookmark(query) elif path == "/style": - return handle_style_form(query=query) + return handle_style_form() elif path == "/about": return handle_about() elif path == "/export": @@ -873,7 +903,7 @@ def dispatch_request(data): elif path == "/tags": return handle_tags() elif path.startswith("/tags/"): - tag_name = path[len("/tags/"):] + tag_name = unquote(path[len("/tags/"):]) return handle_tag_browse(tag_name) if tag_name else _error(400) elif path == "/api/sites": return handle_api_sites() @@ -895,6 +925,9 @@ def dispatch_request(data): return handle_delete(pid) if pid is not None else _error(400) elif path == "/style": return handle_style_submit(body) + elif path == "/style/reset": + set_setting("custom_template", "") + return handle_style_form("Template reset to default.") elif path == "/import": return handle_import_submit(body) elif path == "/subscriptions/add": @@ -914,3 +947,30 @@ def dispatch_request(data): return handle_subscription_syncall() return _error(404) + + +def dispatch_request(data): + cookies = data.get("cookies", {}) + csrf_token = cookies.get("_csrf", "") + if not csrf_token: + csrf_token = secrets.token_hex(32) + _request_local.csrf_token = csrf_token + + resp = _dispatch_inner(data) + + resp.setdefault("headers", {}) + resp["headers"]["Set-Cookie"] = f"_csrf={csrf_token}; SameSite=Strict; HttpOnly; Path=/" + resp["headers"]["X-Frame-Options"] = "DENY" + resp["headers"]["X-Content-Type-Options"] = "nosniff" + if resp.get("content_type", "").startswith("text/html"): + resp["headers"]["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src * data:; " + "frame-ancestors 'none'; " + "form-action 'self'; " + "base-uri 'self'" + ) + return resp