diff --git a/.env.example b/.env.example
index a700a8a..abd93dc 100644
--- a/.env.example
+++ b/.env.example
@@ -29,3 +29,5 @@ SOAPBOX_OWNER_PASSWORD=bytt-meg-naa
SOAPBOX_PORT=8080
# Podman-volum (navn) eller katalog (absolutt sti) for /data
SOAPBOX_VOLUME=soapbox-data
+# Minnegrense for containeren (bildebehandling er det som bruker mest)
+SOAPBOX_MEMORY=512m
diff --git a/Containerfile b/Containerfile
index 77cb08f..08c90ab 100644
--- a/Containerfile
+++ b/Containerfile
@@ -5,7 +5,7 @@
# --build-arg GIT_REVISION="$(git describe --always --dirty)" \
# -t soapbox .
FROM python:3.13-alpine AS build
-COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
+COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
COPY pyproject.toml uv.lock README.md ./
@@ -33,5 +33,5 @@ VOLUME /data
EXPOSE 8080
# waitress avslutter rent på SIGINT; SIGTERM ignoreres når den kjører som PID 1.
STOPSIGNAL SIGINT
-HEALTHCHECK --interval=30s --timeout=3s CMD curl -fsS http://localhost:8080/ >/dev/null || exit 1
+HEALTHCHECK --interval=30s --timeout=3s CMD curl -fsS "http://localhost:8080${SOAPBOX_BASE_PATH}/" >/dev/null || exit 1
CMD ["soapbox", "serve"]
diff --git a/run.sh b/run.sh
index 74e6d00..53ae1b9 100755
--- a/run.sh
+++ b/run.sh
@@ -13,12 +13,16 @@ fi
# Verdier som bare gjelder for podman-kjøringen (ikke appen)
PORT="$(grep -E '^SOAPBOX_PORT=' "$ENV_FILE" | cut -d= -f2- || true)"
VOLUME="$(grep -E '^SOAPBOX_VOLUME=' "$ENV_FILE" | cut -d= -f2- || true)"
+MEMORY="$(grep -E '^SOAPBOX_MEMORY=' "$ENV_FILE" | cut -d= -f2- || true)"
PORT="${PORT:-8080}"
VOLUME="${VOLUME:-soapbox-data}"
+MEMORY="${MEMORY:-512m}"
IMAGE="${1:-localhost/soapbox:latest}"
podman run -d --replace --name soapbox \
--restart unless-stopped \
+ --cap-drop=all --security-opt=no-new-privileges \
+ --memory "$MEMORY" \
-p "127.0.0.1:${PORT}:8080" \
-v "${VOLUME}:/data:Z" \
--env-file "$ENV_FILE" \
diff --git a/soapbox/admin.py b/soapbox/admin.py
index 9edb6f9..7ed5d38 100644
--- a/soapbox/admin.py
+++ b/soapbox/admin.py
@@ -15,6 +15,8 @@ import json
import re
import secrets
import sqlite3
+import threading
+import time
from datetime import UTC, datetime
from functools import wraps
@@ -76,7 +78,40 @@ def _now() -> datetime:
def current_user() -> db.User | None:
uid = session.get("uid")
- return db.get_user(g.db, uid) if uid else None
+ if not uid:
+ return None
+ user = db.get_user(g.db, uid)
+ # Sesjonen bærer et stempel av passord-hashen; bytter passordet, logges
+ # alle eksisterende sesjoner (også stjålne cookies) ut.
+ if user is None or session.get("pw") != db.password_stamp(g.db, uid):
+ return None
+ return user
+
+
+# Enkel innloggingsbrems i minnet: etter 5 feil må brukernavnet vente
+# 30 s, deretter dobles ventetiden per feil. Nullstilles ved vellykket login.
+_login_failures: dict[str, tuple[int, float]] = {}
+_login_lock = threading.Lock()
+
+
+def _login_blocked(username: str) -> int:
+ """Sekunder brukeren må vente, 0 hvis innlogging er tillatt."""
+ with _login_lock:
+ _, until = _login_failures.get(username, (0, 0.0))
+ return max(0, int(until - time.monotonic()))
+
+
+def _login_failed(username: str) -> None:
+ with _login_lock:
+ count, _ = _login_failures.get(username, (0, 0.0))
+ count += 1
+ wait = 30 * 2 ** (count - 5) if count >= 5 else 0
+ _login_failures[username] = (count, time.monotonic() + wait)
+
+
+def _login_ok(username: str) -> None:
+ with _login_lock:
+ _login_failures.pop(username, None)
def csrf_token() -> str:
@@ -90,7 +125,7 @@ def login_required(view):
def wrapped(*a, **kw):
user = current_user()
if user is None:
- return redirect(url_for("admin.login", next=request.path))
+ return redirect(url_for("admin.login", next=request.script_root + request.path))
g.user = user
if request.method == "POST":
token = request.form.get("csrf") or request.headers.get("X-CSRF")
@@ -120,13 +155,23 @@ def _inject():
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
- user = db.authenticate(
- g.db, request.form.get("username", ""), request.form.get("password", "")
- )
+ # CSRF sjekkes også her: ellers kunne en angriper logge offeret inn i
+ # angriperens gjestekonto, og alt offeret skriver havner der.
+ token = request.form.get("csrf")
+ if not token or token != session.get("csrf"):
+ abort(403)
+ username = request.form.get("username", "")
+ wait = _login_blocked(username)
+ if wait:
+ flash(f"For mange forsøk. Prøv igjen om {wait} sekunder.", "error")
+ return render_template("admin/login.html", next=request.args.get("next", "")), 429
+ user = db.authenticate(g.db, username, request.form.get("password", ""))
if user:
+ _login_ok(username)
session.clear()
session.permanent = True
session["uid"] = user.id
+ session["pw"] = db.password_stamp(g.db, user.id)
csrf_token()
nxt = request.form.get("next") or url_for("admin.index")
# Kun stier på dette nettstedet: "//evil.com" og "/\\evil.com"
@@ -134,6 +179,7 @@ def login():
if not nxt.startswith("/") or nxt.startswith("//") or "\\" in nxt:
nxt = url_for("admin.index")
return redirect(nxt)
+ _login_failed(username)
flash("Feil brukernavn eller passord.", "error")
return render_template("admin/login.html", next=request.args.get("next", ""))
@@ -149,12 +195,20 @@ def logout():
def _load_or_404(post_id: str) -> Post:
- guest, slug = split_id(post_id)
- post = get_post(_cfg(), guest, slug)
+ cfg = _cfg()
+ try:
+ guest, slug = split_id(post_id)
+ except ValueError:
+ abort(404)
+ post = get_post(cfg, guest, slug)
if post is None:
abort(404)
if not g.user.is_owner and guest != g.user.username:
abort(403)
+ # Forsvar i dybden: stien skal ligge nøyaktig der id-en sier.
+ expected = post_dir(cfg, guest, slug).resolve()
+ if post.path.resolve().parent != expected:
+ abort(404)
return post
@@ -217,6 +271,9 @@ def edit(post_id: str):
wanted = slugify(title)
if wanted != post.slug:
taken = (existing_slugs(cfg, post.guest) - {post.slug}) | RESERVED
+ if post.guest is None:
+ # Eierens /slug/ må ikke kollidere med en gjests /brukernavn/
+ taken |= {u.username for u in db.list_users(g.db)}
new_slug = unique_slug(wanted, taken)
post_dir(cfg, post.guest, post.slug).rename(post_dir(cfg, post.guest, new_slug))
post.slug = new_slug
@@ -339,6 +396,8 @@ def users():
password = request.form.get("password", "")
if not USERNAME_RE.match(username) or username in RESERVED:
flash("Ugyldig brukernavn (a–z, 0–9, bindestrek, 2–31 tegn).", "error")
+ elif username in existing_slugs(_cfg(), None):
+ flash("Brukernavnet kolliderer med et innlegg på /" + username + "/.", "error")
elif len(password) < 8:
flash("Passordet må være minst 8 tegn.", "error")
else:
@@ -378,6 +437,7 @@ def password():
flash("Passordet må være minst 8 tegn.", "error")
else:
db.set_password(g.db, g.user.id, request.form["password"])
+ session["pw"] = db.password_stamp(g.db, g.user.id)
flash("Passordet er endret.", "ok")
return redirect(url_for("admin.index"))
return render_template("admin/password.html")
diff --git a/soapbox/app.py b/soapbox/app.py
index 7d3805e..ea0c2fb 100644
--- a/soapbox/app.py
+++ b/soapbox/app.py
@@ -23,6 +23,16 @@ from .admin import bp as admin_bp
from .build import build_site
from .config import Config, load_config
+# Admin har ingen inline script/style; alt ligger i egne filer.
+ADMIN_CSP = (
+ "default-src 'self'; img-src 'self' data: blob:; frame-ancestors 'none'; form-action 'self'"
+)
+# Den offentlige siden tillater inline CSS så egne temaer kan bruke style-attributter.
+PUBLIC_CSP = (
+ "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
+ "script-src 'self'; frame-ancestors 'none'"
+)
+
def create_app(cfg: Config | None = None) -> Flask:
cfg = cfg or load_config()
@@ -59,9 +69,12 @@ def create_app(cfg: Config | None = None) -> Flask:
def _headers(resp):
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
+ resp.headers.setdefault("X-Frame-Options", "DENY")
if request.path.startswith("/admin"):
resp.headers["Cache-Control"] = "no-store"
- resp.headers["X-Frame-Options"] = "DENY"
+ resp.headers.setdefault("Content-Security-Policy", ADMIN_CSP)
+ else:
+ resp.headers.setdefault("Content-Security-Policy", PUBLIC_CSP)
return resp
app.register_blueprint(admin_bp, url_prefix="/admin")
@@ -87,7 +100,7 @@ def _serve_public(cfg: Config, path: str):
if not root.exists():
abort(404)
target = (root / path).resolve() if path else root
- if not str(target).startswith(str(root.resolve())):
+ if not target.is_relative_to(root.resolve()):
abort(404)
if target.is_dir():
# Katalog-URL-er må ha avsluttende skråstrek, ellers brekker relative
diff --git a/soapbox/cli.py b/soapbox/cli.py
index 81aedd4..dd6ce4a 100644
--- a/soapbox/cli.py
+++ b/soapbox/cli.py
@@ -18,7 +18,11 @@ def main(argv: list[str] | None = None) -> int:
serve = sub.add_parser("serve", help="Start webserveren")
serve.add_argument("--host", default="0.0.0.0")
serve.add_argument("--port", type=int, default=8080)
- serve.add_argument("--debug", action="store_true", help="Flask dev-server med reload")
+ serve.add_argument(
+ "--debug",
+ action="store_true",
+ help="Flask dev-server med reload og debugger. KUN lokalt – debuggeren gir kodekjøring.",
+ )
sub.add_parser("build", help="Generer statiske sider på nytt")
diff --git a/soapbox/content.py b/soapbox/content.py
index 1e99ed8..bbbc0b8 100644
--- a/soapbox/content.py
+++ b/soapbox/content.py
@@ -139,7 +139,7 @@ def get_post(cfg: Config, guest: str | None, slug: str) -> Post | None:
def existing_slugs(cfg: Config, guest: str | None) -> set[str]:
- base = post_dir(cfg, guest, "").parent
+ base = post_dir(cfg, guest, "x").parent # Path / "" er identitet, derfor "x"
return {p.name for p in base.iterdir() if p.is_dir()} if base.exists() else set()
@@ -167,7 +167,20 @@ def delete_post(cfg: Config, post: Post) -> None:
shutil.rmtree(d)
+SLUG_RE = re.compile(r"^[\w-]+$") # samme alfabet som slugify() produserer
+GUEST_RE = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
+
+
def split_id(post_id: str) -> tuple[str | None, str]:
- """``"_owner/slug"`` → (None, slug); ``"gjest/slug"`` → ("gjest", slug)."""
+ """``"_owner/slug"`` → (None, slug); ``"gjest/slug"`` → ("gjest", slug).
+
+ Begge delene valideres mot alfabetet de kan ha blitt laget med. Uten dette
+ ville ``gjest/../../posts/hemmelig`` truffet en annen brukers mappe, siden
+ delene brukes direkte som katalognavn.
+ """
guest, _, slug = post_id.partition("/")
+ if not SLUG_RE.match(slug) or "." in slug:
+ raise ValueError("ugyldig slug")
+ if guest != OWNER and not GUEST_RE.match(guest):
+ raise ValueError("ugyldig gjest")
return (None if guest == OWNER else guest), slug
diff --git a/soapbox/db.py b/soapbox/db.py
index a24d313..b890d3b 100644
--- a/soapbox/db.py
+++ b/soapbox/db.py
@@ -124,6 +124,13 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> User
return None
+def password_stamp(conn: sqlite3.Connection, user_id: int) -> str | None:
+ """Kort, ikke-hemmelig fingeravtrykk av passord-hashen, lagret i sesjonen
+ slik at et passordbytte ugyldiggjør alle eksisterende sesjoner."""
+ row = conn.execute("SELECT password_hash FROM users WHERE id=?", (user_id,)).fetchone()
+ return row["password_hash"][-16:] if row else None
+
+
def set_password(conn: sqlite3.Connection, user_id: int, password: str) -> None:
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), user_id))
conn.commit()
diff --git a/soapbox/images.py b/soapbox/images.py
index 81f725e..88b546f 100644
--- a/soapbox/images.py
+++ b/soapbox/images.py
@@ -16,6 +16,8 @@ from PIL import Image, ImageOps, UnidentifiedImageError
from .slug import slugify
ALLOWED = {"JPEG": ".jpg", "PNG": ".png", "GIF": ".gif", "WEBP": ".webp"}
+MAX_PIXELS = 40_000_000 # ~7000×5700; mobilkameraer ligger på 12–50 MP nedskalert
+Image.MAX_IMAGE_PIXELS = MAX_PIXELS
class ImageError(ValueError):
@@ -36,7 +38,13 @@ def process_image(data: bytes, max_px: int) -> tuple[bytes, str]:
"""Returnerer (bytes, filendelse) for et nedskalert bilde uten EXIF."""
try:
img = Image.open(io.BytesIO(data))
+ # Sjekk oppgitt størrelse FØR pikslene dekodes: en liten PNG kan
+ # deklarere 30000×30000 og spise gigabytes med minne.
+ if img.width * img.height > MAX_PIXELS:
+ raise ImageError("Bildet er for stort (maks 40 megapiksler)")
img.load()
+ except Image.DecompressionBombError as e:
+ raise ImageError("Bildet er for stort") from e
except (UnidentifiedImageError, OSError) as e:
raise ImageError("Filen er ikke et gyldig bilde") from e
fmt = img.format
diff --git a/soapbox/static/admin/admin.js b/soapbox/static/admin/admin.js
new file mode 100644
index 0000000..098c2fd
--- /dev/null
+++ b/soapbox/static/admin/admin.js
@@ -0,0 +1,7 @@
+/* Felles admin-JS. Bekreftelsesdialoger via data-confirm i stedet for inline
+ * onsubmit, slik at tekst fra innhold (titler, brukernavn) aldri havner i en
+ * JS-kontekst – og slik at CSP kan forby inline script. */
+document.addEventListener("submit", function (ev) {
+ var msg = ev.target.dataset && ev.target.dataset.confirm;
+ if (msg && !confirm(msg)) ev.preventDefault();
+});
diff --git a/soapbox/templates/admin/base.html b/soapbox/templates/admin/base.html
index 40e40d9..4080891 100644
--- a/soapbox/templates/admin/base.html
+++ b/soapbox/templates/admin/base.html
@@ -33,13 +33,6 @@
{% endwith %}
{% block content %}{% endblock %}
-
+