Sikkerhet: tett path traversal i post-id og herd innlogging/upload

Funn fra sikkerhetsgjennomgang:
- HØY: post_id ble ikke validert, så en gjest kunne nå andres innlegg
  via gjest/../../posts/<slug> (lese utkast, laste opp/slette bilder,
  publisere eierens utkast). Slug og gjestenavn valideres nå mot samme
  alfabet de lages med, og stien sjekkes etter oppslag.
- Bilder: pikselantall sjekkes før dekoding (maks 40 MP), og
  DecompressionBombError håndteres.
- Innlogging: CSRF-token kreves også på login (login-CSRF), og
  innloggingsbrems etter 5 feil per brukernavn.
- Passordbytte ugyldiggjør alle andre sesjoner (stempel i cookien).
- Content-Security-Policy og X-Frame-Options på alle sider; admin har
  ingen inline script lenger (admin.js).
- Gjestenavn og eier-slugs kan ikke lenger kollidere på /navn/.
- Healthcheck respekterer SOAPBOX_BASE_PATH; login-redirect beholder
  base path; run.sh dropper capabilities og setter minnegrense; uv
  pinnet til 0.11.

Rettet også en reell feil: existing_slugs() listet feil katalog
(Path /  er identitet), så slug-unikhet virket ikke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcEy43fNYpwg6K6oTKakWR
This commit is contained in:
Ole-Morten Duesund 2026-08-26 14:07:14 +02:00
commit d47558c6c1
14 changed files with 284 additions and 31 deletions

View file

@ -32,7 +32,11 @@ def client(app):
def login(client, username="eier", password="hemmelig123", prefix=""):
return client.post(f"{prefix}/admin/login", data={"username": username, "password": password})
client.get(f"{prefix}/admin/login") # gir sesjonen et CSRF-token
return client.post(
f"{prefix}/admin/login",
data={"username": username, "password": password, "csrf": csrf(client, prefix)},
)
def csrf(client, prefix=""):

View file

@ -114,15 +114,14 @@ def test_guest_flow_and_isolation(client, cfg):
def test_login_next_no_open_redirect(client):
def attempt(nxt):
client.get("/admin/login") # ferskt CSRF-token (roteres ved innlogging)
creds = {"username": "eier", "password": "hemmelig123", "csrf": csrf(client)}
return client.post("/admin/login", data={**creds, "next": nxt})
for bad in ("//evil.com", "/\\evil.com", "https://evil.com"):
r = client.post(
"/admin/login", data={"username": "eier", "password": "hemmelig123", "next": bad}
)
assert r.headers["Location"] == "/admin/", bad
r = client.post(
"/admin/login", data={"username": "eier", "password": "hemmelig123", "next": "/admin/users"}
)
assert r.headers["Location"] == "/admin/users"
assert attempt(bad).headers["Location"] == "/admin/", bad
assert attempt("/admin/users").headers["Location"] == "/admin/users"
def test_csrf_enforced(client):
@ -233,3 +232,18 @@ def test_rebuild_on_startup(cfg):
(cfg.public_dir / "index.html").write_text("GAMMEL")
create_app(cfg)
assert "GAMMEL" not in (cfg.public_dir / "index.html").read_text()
def test_slug_uniqueness(client, cfg):
login(client)
token = csrf(client)
ids = []
for _ in range(2):
r = client.post("/admin/new", data={"csrf": token})
pid = r.headers["Location"].split("/admin/edit/")[1]
r = client.post(
f"/admin/edit/{pid}",
data={"csrf": token, "title": "Samme", "body": "x", "action": "publish"},
)
ids.append(r.headers["Location"].split("/admin/edit/")[1])
assert ids == ["_owner/samme", "_owner/samme-2"]

124
tests/test_security.py Normal file
View file

@ -0,0 +1,124 @@
"""Regresjonstester for funn fra sikkerhetsgjennomgangen."""
import io
import pytest
from PIL import Image
from soapbox import db
from soapbox.images import ImageError, process_image
from tests.conftest import csrf, login
def _owner_draft(client, cfg):
login(client)
r = client.post("/admin/new", data={"csrf": csrf(client)})
pid = r.headers["Location"].split("/admin/edit/")[1]
client.post(
f"/admin/edit/{pid}",
data={"csrf": csrf(client), "title": "Hemmelig", "body": "TOPPHEMMELIG", "action": "save"},
)
(cfg.content_dir / "posts/hemmelig/priv.png").write_bytes(b"x")
client.post("/admin/logout", data={"csrf": csrf(client)})
return "_owner/hemmelig"
def test_guest_cannot_traverse_to_owner_post(client, cfg):
_owner_draft(client, cfg)
login(client, "gjest1")
client.post("/admin/new", data={"csrf": csrf(client)}) # så guests/gjest1/ finnes
token = csrf(client)
for bad in (
"gjest1/../../posts/hemmelig",
"gjest1/%2e%2e/%2e%2e/posts/hemmelig",
"_owner/hemmelig",
"../posts/hemmelig",
):
assert client.get(f"/admin/edit/{bad}").status_code in (403, 404), bad
assert client.get(f"/admin/history/{bad}").status_code in (403, 404), bad
assert client.get(f"/admin/media/{bad}/priv.png").status_code in (403, 404), bad
r = client.post(
f"/admin/upload/{bad}",
data={"file": (io.BytesIO(b"x"), "e.png")},
headers={"X-CSRF": token},
content_type="multipart/form-data",
)
assert r.status_code in (403, 404), bad
assert "TOPPHEMMELIG" not in client.get("/admin/edit/gjest1/../../posts/hemmelig").text
assert not (cfg.content_dir / "posts/hemmelig/e.png").exists()
def test_login_requires_csrf(client):
r = client.post("/admin/login", data={"username": "eier", "password": "hemmelig123"})
assert r.status_code == 403
def test_login_backoff(client):
client.get("/admin/login")
token = csrf(client)
for _ in range(5):
client.post("/admin/login", data={"username": "eier", "password": "feil", "csrf": token})
r = client.post(
"/admin/login", data={"username": "eier", "password": "hemmelig123", "csrf": token}
)
assert r.status_code == 429
from soapbox import admin
admin._login_failures.clear()
def test_password_change_invalidates_other_sessions(app, client, cfg):
login(client)
other = app.test_client()
login(other)
assert other.get("/admin/").status_code == 200
conn = db.connect(cfg.db_path)
db.set_password(conn, db.get_user_by_name(conn, "eier").id, "nyttpassord1")
conn.close()
assert other.get("/admin/").status_code == 302 # logget ut
@pytest.mark.filterwarnings("ignore::PIL.Image.DecompressionBombWarning")
def test_decompression_bomb_rejected():
# Liten PNG som deklarerer 8000x8000 = 64 MP > grensen
buf = io.BytesIO()
Image.new("1", (8000, 8000)).save(buf, "PNG")
try:
process_image(buf.getvalue(), 2000)
except ImageError as e:
assert "for stort" in str(e)
else:
raise AssertionError("skulle vært avvist")
def test_security_headers(client):
r = client.get("/")
assert "frame-ancestors 'none'" in r.headers["Content-Security-Policy"]
assert r.headers["X-Frame-Options"] == "DENY"
login(client)
assert "unsafe-inline" not in client.get("/admin/").headers["Content-Security-Policy"]
def test_guest_username_cannot_shadow_owner_post(client, cfg):
login(client)
token = csrf(client)
r = client.post("/admin/new", data={"csrf": token})
pid = r.headers["Location"].split("/admin/edit/")[1]
client.post(
f"/admin/edit/{pid}", data={"csrf": token, "title": "foo", "body": "x", "action": "publish"}
)
client.post(
"/admin/users",
data={"csrf": token, "action": "create", "username": "foo", "password": "passord123"},
)
conn = db.connect(cfg.db_path)
assert db.get_user_by_name(conn, "foo") is None
conn.close()
# og motsatt: eierens innlegg kan ikke få slug lik et gjestenavn
r = client.post("/admin/new", data={"csrf": token})
pid = r.headers["Location"].split("/admin/edit/")[1]
r = client.post(
f"/admin/edit/{pid}",
data={"csrf": token, "title": "gjest1", "body": "x", "action": "publish"},
)
assert r.headers["Location"].endswith("/admin/edit/_owner/gjest1-2")