Harden security: bookmark auth, CSP headers, per-session CSRF, and more

- Bookmark endpoint now requires a secret token (stored in settings)
- Style reset moved from GET to POST with CSRF protection
- Open redirect prevention in _redirect() helper
- Import capped at 100 URLs to prevent abuse
- page_tags cleaned up on delete + PRAGMA foreign_keys enabled
- CSP, X-Frame-Options, X-Content-Type-Options on all responses
- CSRF tokens now per-session via double-submit cookie pattern
- Tag names URL-decoded for special characters
- Gateway forwards cookies in request data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Derick Phan 2026-03-26 11:10:37 -07:00
parent 9ddecf71db
commit d5f2d01651
No known key found for this signature in database
3 changed files with 83 additions and 11 deletions

1
db.py
View file

@ -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

View file

@ -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}"),
}

View file

@ -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'<input type="hidden" name="_csrf" value="{_csrf_token}">'
return f'<input type="hidden" name="_csrf" value="{_get_csrf_token()}">'
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"</form>"
f"<h2>bookmarklet</h2>"
f"<p>Drag this link to your bookmarks bar. Click it on any page to index it instantly.</p>"
f'<p><a href="javascript:void(fetch(\'http://localhost:8080/bookmark?url=\'+encodeURIComponent(location.href)).then(r=>r.text()).then(t=>alert(t)).catch(()=>alert(\'tinyweb not running\')))">+ save to {esc(name)}</a></p>'
f'<p><a href="javascript:void(fetch(\'http://localhost:8080/bookmark?url=\'+encodeURIComponent(location.href)+\'&token={_get_bookmark_token()}\').then(r=>r.text()).then(t=>alert(t)).catch(()=>alert(\'tinyweb not running\')))">+ save to {esc(name)}</a></p>'
f"<h2>reset</h2>"
f'<form method="post" action="/style/reset">'
f'{_csrf_field()}'
f'<button type="submit">reset template to default</button>'
f"</form>"
f"<p>{msg}</p>"
f'<a href="/">back</a>',
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