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:
parent
9ddecf71db
commit
d5f2d01651
3 changed files with 83 additions and 11 deletions
1
db.py
1
db.py
|
|
@ -56,6 +56,7 @@ def clean_url(url):
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
db = sqlite3.connect(DATABASE)
|
db = sqlite3.connect(DATABASE)
|
||||||
|
db.execute("PRAGMA foreign_keys = ON")
|
||||||
db.row_factory = sqlite3.Row
|
db.row_factory = sqlite3.Row
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
|
||||||
11
gateway.py
11
gateway.py
|
|
@ -75,11 +75,22 @@ class GatewayHandler(BaseHTTPRequestHandler):
|
||||||
raw = self.rfile.read(length).decode()
|
raw = self.rfile.read(length).decode()
|
||||||
body = parse_qs(raw)
|
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 = {
|
request_data = {
|
||||||
"method": method,
|
"method": method,
|
||||||
"path": parsed.path,
|
"path": parsed.path,
|
||||||
"query": query,
|
"query": query,
|
||||||
"body": body,
|
"body": body,
|
||||||
|
"cookies": cookies,
|
||||||
"gateway_host": self.headers.get("Host", f"localhost:{GATEWAY_PORT}"),
|
"gateway_host": self.headers.get("Host", f"localhost:{GATEWAY_PORT}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
82
handlers.py
82
handlers.py
|
|
@ -1,21 +1,30 @@
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
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 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 templates import esc, snippet, wrap_page, DEFAULT_TEMPLATE
|
||||||
from rns_client import fetch_remote_sites
|
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():
|
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):
|
def _check_csrf(body):
|
||||||
token = body.get("_csrf", [""])[0]
|
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):
|
def _sanitize_fts_query(query):
|
||||||
|
|
@ -24,6 +33,14 @@ def _sanitize_fts_query(query):
|
||||||
return f'"{escaped}"'
|
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):
|
def _respond(body_html, status=200, use_default=False):
|
||||||
return {
|
return {
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|
@ -34,6 +51,8 @@ def _respond(body_html, status=200, use_default=False):
|
||||||
|
|
||||||
|
|
||||||
def _redirect(location):
|
def _redirect(location):
|
||||||
|
if not location.startswith("/") or location.startswith("//"):
|
||||||
|
location = "/"
|
||||||
return {
|
return {
|
||||||
"status": 302,
|
"status": 302,
|
||||||
"content_type": "text/html; charset=utf-8",
|
"content_type": "text/html; charset=utf-8",
|
||||||
|
|
@ -337,6 +356,7 @@ def handle_delete_confirm(page_id):
|
||||||
|
|
||||||
def handle_delete(page_id):
|
def handle_delete(page_id):
|
||||||
db = get_db()
|
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 links WHERE page_id = ?", (page_id,))
|
||||||
db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
|
db.execute("DELETE FROM pages WHERE id = ?", (page_id,))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -345,6 +365,10 @@ def handle_delete(page_id):
|
||||||
|
|
||||||
|
|
||||||
def handle_bookmark(query):
|
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())
|
url = clean_url(query.get("url", [""])[0].strip())
|
||||||
if not url or not url.startswith(("http://", "https://")):
|
if not url or not url.startswith(("http://", "https://")):
|
||||||
return _text_response("error: invalid url", headers={"Access-Control-Allow-Origin": "*"})
|
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):
|
if not isinstance(data, list):
|
||||||
return handle_import_form("Expected a JSON array.")
|
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
|
imported = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
for entry in data:
|
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).")
|
return handle_import_form(f"Imported {imported} page(s). {errors} error(s).")
|
||||||
|
|
||||||
|
|
||||||
def handle_style_form(msg="", query=None):
|
def handle_style_form(msg=""):
|
||||||
if query and "reset" in query:
|
|
||||||
set_setting("custom_template", "")
|
|
||||||
msg = "Template reset to default."
|
|
||||||
template = get_setting("custom_template") or DEFAULT_TEMPLATE
|
template = get_setting("custom_template") or DEFAULT_TEMPLATE
|
||||||
name = get_site_name()
|
name = get_site_name()
|
||||||
sharing = get_setting("sharing_enabled", "0")
|
sharing = get_setting("sharing_enabled", "0")
|
||||||
|
|
@ -430,7 +455,12 @@ def handle_style_form(msg="", query=None):
|
||||||
f"</form>"
|
f"</form>"
|
||||||
f"<h2>bookmarklet</h2>"
|
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>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"<p>{msg}</p>"
|
||||||
f'<a href="/">back</a>',
|
f'<a href="/">back</a>',
|
||||||
use_default=True,
|
use_default=True,
|
||||||
|
|
@ -834,7 +864,7 @@ def handle_subscription_syncall():
|
||||||
# --- Dispatcher ---
|
# --- Dispatcher ---
|
||||||
|
|
||||||
|
|
||||||
def dispatch_request(data):
|
def _dispatch_inner(data):
|
||||||
method = data.get("method", "GET")
|
method = data.get("method", "GET")
|
||||||
path = data.get("path", "/")
|
path = data.get("path", "/")
|
||||||
query = data.get("query", {})
|
query = data.get("query", {})
|
||||||
|
|
@ -863,7 +893,7 @@ def dispatch_request(data):
|
||||||
elif path == "/bookmark":
|
elif path == "/bookmark":
|
||||||
return handle_bookmark(query)
|
return handle_bookmark(query)
|
||||||
elif path == "/style":
|
elif path == "/style":
|
||||||
return handle_style_form(query=query)
|
return handle_style_form()
|
||||||
elif path == "/about":
|
elif path == "/about":
|
||||||
return handle_about()
|
return handle_about()
|
||||||
elif path == "/export":
|
elif path == "/export":
|
||||||
|
|
@ -873,7 +903,7 @@ def dispatch_request(data):
|
||||||
elif path == "/tags":
|
elif path == "/tags":
|
||||||
return handle_tags()
|
return handle_tags()
|
||||||
elif path.startswith("/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)
|
return handle_tag_browse(tag_name) if tag_name else _error(400)
|
||||||
elif path == "/api/sites":
|
elif path == "/api/sites":
|
||||||
return handle_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)
|
return handle_delete(pid) if pid is not None else _error(400)
|
||||||
elif path == "/style":
|
elif path == "/style":
|
||||||
return handle_style_submit(body)
|
return handle_style_submit(body)
|
||||||
|
elif path == "/style/reset":
|
||||||
|
set_setting("custom_template", "")
|
||||||
|
return handle_style_form("Template reset to default.")
|
||||||
elif path == "/import":
|
elif path == "/import":
|
||||||
return handle_import_submit(body)
|
return handle_import_submit(body)
|
||||||
elif path == "/subscriptions/add":
|
elif path == "/subscriptions/add":
|
||||||
|
|
@ -914,3 +947,30 @@ def dispatch_request(data):
|
||||||
return handle_subscription_syncall()
|
return handle_subscription_syncall()
|
||||||
|
|
||||||
return _error(404)
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue