soapbox/tests/test_security.py
Ole-Morten Duesund d47558c6c1 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
2026-08-26 14:07:14 +02:00

124 lines
4.4 KiB
Python

"""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")