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
249 lines
9.7 KiB
Python
249 lines
9.7 KiB
Python
import re
|
|
from urllib.parse import unquote
|
|
|
|
from soapbox.app import create_app
|
|
from soapbox.config import load_config
|
|
from tests.conftest import csrf, login, png_bytes
|
|
|
|
|
|
def _create_post(client, prefix=""):
|
|
login(client, prefix=prefix)
|
|
r = client.post(f"{prefix}/admin/new", data={"csrf": csrf(client)})
|
|
assert r.status_code == 302
|
|
post_id = r.headers["Location"].split("/admin/edit/")[1]
|
|
return post_id
|
|
|
|
|
|
def test_login_required(client):
|
|
assert client.get("/admin/").status_code == 302
|
|
assert client.get("/admin/login").status_code == 200
|
|
assert client.get("/admin/static/admin.css").content_type.startswith("text/css")
|
|
assert client.get("/admin/static/editor.js").status_code == 200
|
|
|
|
|
|
def test_full_flow_owner(client, cfg):
|
|
post_id = _create_post(client)
|
|
assert post_id == "_owner/nytt-innlegg"
|
|
token = csrf(client)
|
|
|
|
# Last opp et bilde
|
|
r = client.post(
|
|
f"/admin/upload/{post_id}",
|
|
data={"file": (__import__("io").BytesIO(png_bytes()), "Mitt Bilde.PNG")},
|
|
headers={"X-CSRF": token},
|
|
content_type="multipart/form-data",
|
|
)
|
|
assert r.status_code == 200, r.data
|
|
assert r.json["filename"] == "mitt-bilde.png"
|
|
|
|
# Forhåndsvisning peker bildet til admin-media
|
|
r = client.post(
|
|
f"/admin/preview/{post_id}",
|
|
json={"body": " [x](/annet/)"},
|
|
headers={"X-CSRF": token},
|
|
)
|
|
assert f'src="/admin/media/{post_id}/mitt-bilde.png"' in r.text
|
|
assert 'href="../annet/"' in r.text
|
|
|
|
# Publiser: slug følger tittelen
|
|
body = "Hei **verden**\n\n\n\n[hjem](/)"
|
|
r = client.post(
|
|
f"/admin/edit/{post_id}",
|
|
data={"csrf": token, "title": "Grønt er skjønt", "body": body, "action": "publish"},
|
|
)
|
|
assert unquote(r.headers["Location"]).endswith("/admin/edit/_owner/grønt-er-skjønt")
|
|
assert (cfg.content_dir / "posts/grønt-er-skjønt/index.md").exists()
|
|
assert (cfg.content_dir / "posts/grønt-er-skjønt/mitt-bilde.png").exists()
|
|
|
|
# Statisk output
|
|
r = client.get("/grønt-er-skjønt")
|
|
assert r.status_code == 301 and unquote(r.headers["Location"]).endswith("/grønt-er-skjønt/")
|
|
html = client.get("/grønt-er-skjønt/").text
|
|
assert "<strong>verden</strong>" in html
|
|
assert 'href="../theme/style.css"' in html
|
|
assert 'href="../theme/favicon.svg"' in html
|
|
assert 'href="../"' in html # intern lenke omskrevet til relativ
|
|
assert client.get("/grønt-er-skjønt/mitt-bilde.png").status_code == 200
|
|
front = client.get("/").text
|
|
assert "Grønt er skjønt" in front
|
|
assert 'href="https://kode.naiv.no/olemd/soapbox"' in front # AGPL: lenke til kilden
|
|
feed = client.get("/feed.xml").text
|
|
assert "https://blog.example.no/gr%C3%B8nt-er-skj%C3%B8nt/" in feed
|
|
|
|
# Etter publisering fryses slug selv om tittelen endres
|
|
r = client.post(
|
|
"/admin/edit/_owner/grønt-er-skjønt",
|
|
data={"csrf": token, "title": "Ny tittel", "body": body, "action": "save"},
|
|
)
|
|
assert unquote(r.headers["Location"]).endswith("/admin/edit/_owner/grønt-er-skjønt")
|
|
|
|
# Git-historikk
|
|
from soapbox.gitstore import log
|
|
|
|
lines = log(cfg.content_dir)
|
|
assert any("Publiserte: Grønt er skjønt" in line for line in lines)
|
|
assert all("eier:" in line for line in lines)
|
|
|
|
|
|
def test_guest_flow_and_isolation(client, cfg):
|
|
post_id = _create_post(client) # eierens utkast
|
|
client.post("/admin/logout", data={"csrf": csrf(client)})
|
|
|
|
login(client, "gjest1")
|
|
token = csrf(client)
|
|
# Gjest kan ikke røre eierens innlegg
|
|
assert client.get(f"/admin/edit/{post_id}").status_code == 403
|
|
assert client.get("/admin/users").status_code == 403
|
|
|
|
r = client.post("/admin/new", data={"csrf": token})
|
|
gid = r.headers["Location"].split("/admin/edit/")[1]
|
|
assert gid == "gjest1/nytt-innlegg"
|
|
client.post(
|
|
f"/admin/edit/{gid}",
|
|
data={"csrf": token, "title": "Post 1", "body": "hei", "action": "publish"},
|
|
)
|
|
assert (cfg.content_dir / "guests/gjest1/post-1/index.md").exists()
|
|
|
|
html = client.get("/gjest1/post-1/").text
|
|
assert 'href="../../theme/style.css"' in html
|
|
assert "Gjest En" in html
|
|
assert "Post 1" in client.get("/gjest1/").text
|
|
assert "Post 1" in client.get("/").text # gjesteinnlegg vises også på forsiden
|
|
# eierens utkast er ikke publisert
|
|
assert client.get("/nytt-innlegg/").status_code == 404
|
|
|
|
|
|
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"):
|
|
assert attempt(bad).headers["Location"] == "/admin/", bad
|
|
assert attempt("/admin/users").headers["Location"] == "/admin/users"
|
|
|
|
|
|
def test_csrf_enforced(client):
|
|
login(client)
|
|
assert client.post("/admin/new", data={}).status_code == 403
|
|
|
|
|
|
def test_subpath_mount(tmp_path):
|
|
cfg = load_config({"SOAPBOX_DATA_DIR": str(tmp_path / "d"), "SOAPBOX_BASE_PATH": "/blog/"})
|
|
assert cfg.base_path == "/blog"
|
|
app = create_app(cfg)
|
|
app.config["TESTING"] = True
|
|
from soapbox import db
|
|
|
|
conn = db.connect(cfg.db_path)
|
|
db.create_user(conn, "eier", "hemmelig123", db.ROLE_OWNER)
|
|
conn.close()
|
|
client = app.test_client()
|
|
assert client.get("/admin/login").status_code == 404
|
|
assert client.get("/blog/admin/login").status_code == 200
|
|
login(client, prefix="/blog")
|
|
r = client.post("/blog/admin/new", data={"csrf": csrf(client, "/blog")})
|
|
assert r.headers["Location"].startswith("/blog/admin/edit/")
|
|
html = client.get("/blog/").text
|
|
assert 'href="theme/style.css"' in html
|
|
assert re.search(r'href="admin/"', html)
|
|
assert client.get("/blog/theme/style.css").status_code == 200
|
|
|
|
|
|
def test_no_inline_js_with_content(client, cfg):
|
|
"""Titler med anførselstegn skal ikke havne i en JS-kontekst (XSS)."""
|
|
post_id = _create_post(client)
|
|
token = csrf(client)
|
|
title = 'Ola\'s "test" <b>'
|
|
r = client.post(
|
|
f"/admin/edit/{post_id}",
|
|
data={"csrf": token, "title": title, "body": "x", "action": "save"},
|
|
)
|
|
html = client.get(r.headers["Location"]).text
|
|
assert "onsubmit=" not in html and "onclick=" not in html
|
|
assert "Ola's" in html
|
|
|
|
|
|
def test_post_history(client, cfg):
|
|
post_id = _create_post(client)
|
|
token = csrf(client)
|
|
r = client.post(
|
|
f"/admin/edit/{post_id}",
|
|
data={"csrf": token, "title": "Hist", "body": "første", "action": "publish"},
|
|
)
|
|
pid = r.headers["Location"].split("/admin/edit/")[1]
|
|
client.post(
|
|
f"/admin/edit/{pid}",
|
|
data={"csrf": token, "title": "Hist", "body": "andre", "action": "save"},
|
|
)
|
|
|
|
html = client.get(f"/admin/history/{pid}").text
|
|
assert "Publiserte: Hist" in html and "Lagret: Hist" in html
|
|
sha = re.search(r"sha=([0-9a-f]{40})", html).group(1)
|
|
diff = client.get(f"/admin/history/{pid}?sha={sha}").text
|
|
assert '<span class="add">+andre</span>' in diff and '<span class="del">-første</span>' in diff
|
|
assert client.get(f"/admin/history/{pid}?sha=deadbeef").status_code == 404
|
|
assert client.get(f"/admin/history/{pid}?sha=;rm").status_code == 404
|
|
|
|
# Gjest får ikke se eierens historikk
|
|
client.post("/admin/logout", data={"csrf": token})
|
|
login(client, "gjest1")
|
|
assert client.get(f"/admin/history/{pid}").status_code == 403
|
|
|
|
|
|
def test_user_theme_in_content_repo(client, cfg):
|
|
"""Eget tema i content/themes/ overstyrer delvis og velges via innstillinger."""
|
|
theme = cfg.content_dir / "themes" / "skog"
|
|
(theme / "static").mkdir(parents=True)
|
|
(theme / "static" / "style.css").write_text(":root{--bg:#000}")
|
|
(theme / "templates").mkdir()
|
|
(theme / "templates" / "index.html").write_text("SKOG {{ rel('theme/style.css') }}")
|
|
assert cfg.available_themes() == ["green", "skog"]
|
|
|
|
login(client)
|
|
token = csrf(client)
|
|
assert (
|
|
client.post(
|
|
"/admin/settings", data={"csrf": token, "title": "T", "theme": "../etc"}
|
|
).status_code
|
|
== 400
|
|
)
|
|
r = client.post("/admin/settings", data={"csrf": token, "title": "T", "theme": "skog"})
|
|
assert r.status_code == 302
|
|
assert 'theme = "skog"' in (cfg.content_dir / "site.toml").read_text()
|
|
assert cfg.theme == "skog"
|
|
|
|
assert client.get("/").text.startswith("SKOG theme/style.css")
|
|
assert client.get("/theme/style.css").text == ":root{--bg:#000}"
|
|
assert client.get("/theme/favicon.svg").status_code == 200 # arvet fra green
|
|
# post.html finnes ikke i skog → arves fra green
|
|
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": "P", "body": "x", "action": "publish"}
|
|
)
|
|
assert '<article class="post">' in client.get("/p/").text
|
|
|
|
|
|
def test_rebuild_on_startup(cfg):
|
|
"""Utdatert public/ (f.eks. etter oppgradering) skal erstattes ved oppstart."""
|
|
cfg.public_dir.mkdir(parents=True)
|
|
(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"]
|