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:
parent
d52dea8227
commit
d47558c6c1
14 changed files with 284 additions and 31 deletions
124
tests/test_security.py
Normal file
124
tests/test_security.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue