fixed SSRF bypass, tightened error handling

- SSRF: disable automatic redirects, manually follow up to 5 hops with
  IP re-validation at each step to prevent redirect-to-localhost bypass
- Identity file: enforce 0600 permissions on tinyweb_identity at load
  and creation to prevent other users from reading the private key
- Error messages: replace raw exception strings with generic messages
  to avoid leaking internal paths/hostnames to the UI
- DB connections: wrap all get_db() usage in try/finally to guarantee
  close() even when handlers throw mid-operation
This commit is contained in:
lichenblankie 2026-03-26 11:18:47 -07:00
parent 4899819597
commit 449174b0ca
3 changed files with 310 additions and 256 deletions

5
app.py
View file

@ -15,9 +15,14 @@ IDENTITY_FILE = "tinyweb_identity"
def load_or_create_identity():
if os.path.isfile(IDENTITY_FILE):
# Ensure identity file is only readable by owner
current = os.stat(IDENTITY_FILE).st_mode & 0o777
if current != 0o600:
os.chmod(IDENTITY_FILE, 0o600)
return RNS.Identity.from_file(IDENTITY_FILE)
identity = RNS.Identity()
identity.to_file(IDENTITY_FILE)
os.chmod(IDENTITY_FILE, 0o600)
return identity

13
db.py
View file

@ -201,7 +201,18 @@ def get_site_name():
def fetch_page(url):
_validate_url_target(url)
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"})
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, allow_redirects=False)
# Follow redirects manually, re-validating each target
max_redirects = 5
while resp.is_redirect and max_redirects > 0:
redirect_url = resp.headers.get("Location")
if not redirect_url:
break
redirect_url = urljoin(url, redirect_url)
_validate_url_target(redirect_url)
url = redirect_url
resp = requests.get(url, timeout=10, headers={"User-Agent": "TinyWeb/1.0"}, allow_redirects=False)
max_redirects -= 1
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")

View file

@ -121,6 +121,7 @@ def _set_page_tags(page_id, tag_string, db=None):
def handle_search(query):
q = query.get("q", [""])[0].strip()
db = get_db()
try:
count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
name = get_site_name()
@ -223,7 +224,7 @@ def handle_search(query):
f'<ul>{source_items}</ul>'
f'</details>'
)
finally:
db.close()
sub_count = ""
if q and remote_rows:
@ -266,18 +267,23 @@ def handle_add_submit(body):
title = index_url(url, note)
if tags:
db = get_db()
try:
row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
if row:
_set_page_tags(row["id"], tags, db)
db.commit()
finally:
db.close()
return handle_add_form(f'Indexed: <a href="{esc(url)}">{esc(title)}</a>')
except Exception as e:
except ValueError as e:
return handle_add_form(f"Error: {esc(str(e))}")
except Exception:
return handle_add_form("Error: could not fetch or index that URL.")
def handle_pages():
db = get_db()
try:
rows = db.execute("SELECT id, url, title, note FROM pages ORDER BY id DESC").fetchall()
items = ""
for r in rows:
@ -293,6 +299,7 @@ def handle_pages():
f'<a href="/edit/{r["id"]}">edit</a> '
f'<a href="/delete/{r["id"]}">remove</a></li>'
)
finally:
db.close()
return _respond(
f"<h1>indexed pages ({len(rows)})</h1>"
@ -304,11 +311,12 @@ def handle_pages():
def handle_edit_form(page_id, msg=""):
db = get_db()
try:
row = db.execute("SELECT id, url, title, note FROM pages WHERE id = ?", (page_id,)).fetchone()
if not row:
db.close()
return _error(404)
tags = ", ".join(_get_page_tags(page_id, db))
finally:
db.close()
return _respond(
f"<h1>edit page</h1>"
@ -329,16 +337,20 @@ def handle_edit_submit(page_id, body):
note = body.get("note", [""])[0].strip()
tags = body.get("tags", [""])[0].strip()
db = get_db()
try:
db.execute("UPDATE pages SET note = ? WHERE id = ?", (note, page_id))
_set_page_tags(page_id, tags, db)
db.commit()
finally:
db.close()
return _redirect("/pages")
def handle_delete_confirm(page_id):
db = get_db()
try:
row = db.execute("SELECT id, url, title FROM pages WHERE id = ?", (page_id,)).fetchone()
finally:
db.close()
if not row:
return _error(404)
@ -356,10 +368,12 @@ def handle_delete_confirm(page_id):
def handle_delete(page_id):
db = get_db()
try:
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()
finally:
db.close()
return _redirect("/pages")
@ -382,7 +396,9 @@ def handle_bookmark(query):
def handle_export():
db = get_db()
try:
rows = db.execute("SELECT url, title, note FROM pages ORDER BY id").fetchall()
finally:
db.close()
data = [{"url": r["url"], "title": r["title"], "note": r["note"]} for r in rows]
return _json_response(data, headers={"Content-Disposition": "attachment; filename=tinyweb-export.json"})
@ -482,9 +498,11 @@ def handle_about():
dest_hash = get_setting("dest_hash")
sharing = get_setting("sharing_enabled", "0") == "1"
db = get_db()
try:
page_count = db.execute("SELECT count(*) FROM pages").fetchone()[0]
tag_count = db.execute("SELECT count(DISTINCT tag_id) FROM page_tags").fetchone()[0]
sub_count = db.execute("SELECT count(*) FROM subscriptions").fetchone()[0]
finally:
db.close()
sharing_html = (
@ -531,11 +549,13 @@ def handle_about():
def handle_tags():
db = get_db()
try:
rows = db.execute(
"SELECT t.name, COUNT(pt.page_id) AS cnt FROM tags t "
"JOIN page_tags pt ON t.id = pt.tag_id "
"GROUP BY t.id ORDER BY t.name"
).fetchall()
finally:
db.close()
items = ""
for r in rows:
@ -549,6 +569,7 @@ def handle_tags():
def handle_tag_browse(tag_name):
db = get_db()
try:
rows = db.execute(
"SELECT p.id, p.url, p.title, p.note FROM pages p "
"JOIN page_tags pt ON p.id = pt.page_id "
@ -565,6 +586,7 @@ def handle_tag_browse(tag_name):
f'<li>{esc(r["title"])}{note_html} {tag_links} '
f'<small>(<a href="{esc(r["url"])}">{esc(r["url"])}</a>)</small></li>'
)
finally:
db.close()
return _respond(
f'<h1>tag: {esc(tag_name)}</h1>'
@ -582,11 +604,13 @@ def handle_api_sites():
headers={"Access-Control-Allow-Origin": "*"},
)
db = get_db()
try:
rows = db.execute("SELECT id, url, title, note FROM pages ORDER BY id DESC").fetchall()
sites = []
for r in rows:
tags = _get_page_tags(r["id"], db)
sites.append({"url": r["url"], "title": r["title"], "note": r["note"], "tags": tags})
finally:
db.close()
data = {"name": get_site_name(), "sites": sites}
return _json_response(data, headers={"Access-Control-Allow-Origin": "*"})
@ -594,7 +618,9 @@ def handle_api_sites():
def handle_subscriptions(msg=""):
db = get_db()
try:
subs = db.execute("SELECT * FROM subscriptions ORDER BY id DESC").fetchall()
finally:
db.close()
items = ""
for s in subs:
@ -651,8 +677,8 @@ def handle_subscription_add(body):
name = data.get("name", "")
except PermissionError:
return handle_subscriptions("That instance has sharing disabled.")
except Exception as e:
return handle_subscriptions(f"Could not reach that instance: {esc(str(e))}")
except Exception:
return handle_subscriptions("Could not reach that instance.")
db = get_db()
try:
db.execute(
@ -668,9 +694,9 @@ def handle_subscription_add(body):
def handle_subscription_browse(sub_id):
db = get_db()
try:
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
if not sub:
db.close()
return _error(404)
local_urls = set(r["url"] for r in db.execute("SELECT url FROM pages").fetchall())
@ -679,6 +705,7 @@ def handle_subscription_browse(sub_id):
"SELECT url, title, note, tags FROM remote_pages WHERE subscription_id = ?",
(sub_id,),
).fetchall()
finally:
db.close()
if remote_rows:
@ -692,8 +719,8 @@ def handle_subscription_browse(sub_id):
sites = data.get("sites", [])
except PermissionError:
return handle_subscriptions("That instance has sharing disabled.")
except Exception as e:
return handle_subscriptions(f"Could not fetch sites: {esc(str(e))}")
except Exception:
return handle_subscriptions("Could not fetch sites from that instance.")
new_items = ""
existing_items = ""
@ -739,6 +766,7 @@ def handle_subscription_pick(body):
# Build a url->tags map from remote_pages for this subscription
db = get_db()
try:
remote_rows = db.execute(
"SELECT url, tags FROM remote_pages WHERE subscription_id = ?", (sub_id,)
).fetchall()
@ -749,6 +777,7 @@ def handle_subscription_pick(body):
urls = [r["url"] for r in remote_rows if r["url"] not in local_urls]
else:
urls = body.get("urls", [])
finally:
db.close()
if not urls:
@ -763,10 +792,12 @@ def handle_subscription_pick(body):
tags_str = remote_tags.get(url, "")
if tags_str:
db = get_db()
try:
row = db.execute("SELECT id FROM pages WHERE url = ?", (url,)).fetchone()
if row:
_set_page_tags(row["id"], tags_str, db)
db.commit()
finally:
db.close()
imported += 1
except Exception:
@ -776,20 +807,18 @@ def handle_subscription_pick(body):
def handle_subscription_sync(sub_id):
db = get_db()
try:
sub = db.execute("SELECT * FROM subscriptions WHERE id = ?", (sub_id,)).fetchone()
if not sub:
db.close()
return handle_subscriptions("Subscription not found.")
try:
data = fetch_remote_sites(sub["dest_hash"])
sites = data.get("sites", [])
remote_name = data.get("name", sub["name"])
except PermissionError:
db.close()
return handle_subscriptions("That instance has sharing disabled.")
except Exception as e:
db.close()
return handle_subscriptions(f"Could not sync: {esc(str(e))}")
except Exception:
return handle_subscriptions("Could not sync with that instance.")
# Clear old remote pages for this subscription and re-insert
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
@ -807,30 +836,37 @@ def handle_subscription_sync(sub_id):
now = datetime.now().strftime("%Y-%m-%d %H:%M")
db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub_id))
db.commit()
finally:
db.close()
return handle_subscriptions(f"Synced {synced} site(s) from {esc(remote_name)}.")
def handle_subscription_autosync(sub_id):
db = get_db()
try:
db.execute("UPDATE subscriptions SET auto_sync = 1 - auto_sync WHERE id = ?", (sub_id,))
db.commit()
finally:
db.close()
return _redirect("/subscriptions")
def handle_subscription_delete(sub_id):
db = get_db()
try:
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub_id,))
db.execute("DELETE FROM subscriptions WHERE id = ?", (sub_id,))
db.commit()
finally:
db.close()
return _redirect("/subscriptions")
def handle_subscription_syncall():
db = get_db()
try:
subs = db.execute("SELECT * FROM subscriptions WHERE auto_sync = 1").fetchall()
finally:
db.close()
if not subs:
return handle_subscriptions("No subscriptions have auto-sync enabled.")
@ -841,6 +877,7 @@ def handle_subscription_syncall():
sites = data.get("sites", [])
remote_name = data.get("name", sub["name"])
db = get_db()
try:
db.execute("DELETE FROM remote_pages WHERE subscription_id = ?", (sub["id"],))
for s in sites:
try:
@ -854,6 +891,7 @@ def handle_subscription_syncall():
now = datetime.now().strftime("%Y-%m-%d %H:%M")
db.execute("UPDATE subscriptions SET last_sync = ?, name = ? WHERE id = ?", (now, remote_name, sub["id"]))
db.commit()
finally:
db.close()
total += 1
except Exception: