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
124 lines
4.4 KiB
Python
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")
|