added bookmark auth, CSP, per-session CSRF
- 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
This commit is contained in:
parent
0981c2e0a9
commit
4899819597
3 changed files with 83 additions and 11 deletions
82
handlers.py
82
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'<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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue