Compare commits

..

2 Commits

Author SHA1 Message Date
Space-Banane
791126cdd0 backend: close P1 data model and create-flow issues
All checks were successful
docker / test (push) Successful in 13s
docker / build-and-push (push) Successful in 1m23s
2026-05-20 22:40:32 +02:00
Space-Banane
643785ad1e backend: complete P0 session rotation hardening 2026-05-20 22:39:46 +02:00
2 changed files with 72 additions and 32 deletions

20
TODO.md
View File

@@ -4,10 +4,10 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
## P0 - Security and Reliability ## P0 - Security and Reliability
- [ ] Add session expiry and rotation. - [x] Add session expiry and rotation.
- [x] Add `expires_at` and `last_seen_at` to `sessions`. - [x] Add `expires_at` and `last_seen_at` to `sessions`.
- [x] Reject expired tokens in `current_user`. - [x] Reject expired tokens in `current_user`.
- [ ] Rotate session token on login and periodically on use. - [x] Rotate session token on login and periodically on use.
- [x] Harden auth endpoints. - [x] Harden auth endpoints.
- [x] Add login rate limiting by IP + username pair. - [x] Add login rate limiting by IP + username pair.
- [x] Add brute-force lockout window with clear error message. - [x] Add brute-force lockout window with clear error message.
@@ -26,14 +26,14 @@ Concrete follow-up work for Jellomator, prioritized by implementation risk and u
## P1 - Data Model and Backend Quality ## P1 - Data Model and Backend Quality
- Replace string timestamps with DB-native datetime. - [x] Replace string timestamps with DB-native datetime.
- Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`. - [x] Migrate `created_at`/`updated_at` columns from `varchar` to `datetime`.
- Use UTC consistently for writes and reads. - [x] Use UTC consistently for writes and reads.
- Add display ordering support. - [x] Add display ordering support.
- Add `sort_order` column and stable ordering fallback by `name`. - [x] Add `sort_order` column and stable ordering fallback by `name`.
- Update read query to order by `enabled desc`, `sort_order`, `name`. - [x] Update read query to order by `enabled desc`, `sort_order`, `name`.
- Remove duplicate connection pattern in create flow. - [x] Remove duplicate connection pattern in create flow.
- Use one DB transaction/connection per request path where possible. - [x] Use one DB transaction/connection per request path where possible.
- Add backup and restore flow in admin API/UI. - Add backup and restore flow in admin API/UI.
- Download full export. - Download full export.
- Upload validated import with explicit confirmation. - Upload validated import with explicit confirmation.

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import secrets import secrets
import os import os
import time import time
from datetime import datetime from datetime import datetime, timezone
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -122,14 +122,48 @@ def init_db():
url text not null, url text not null,
description text, description text,
category varchar(255), category varchar(255),
sort_order int not null default 0,
icon_blob longblob, icon_blob longblob,
icon_mime varchar(255), icon_mime varchar(255),
icon_url text, icon_url text,
enabled tinyint(1) not null default 1, enabled tinyint(1) not null default 1,
created_at varchar(64) not null, created_at datetime not null,
updated_at varchar(64) not null updated_at datetime not null
) engine=InnoDB default charset=utf8mb4 ) engine=InnoDB default charset=utf8mb4
""") """)
cur.execute("show columns from links like 'sort_order'")
if cur.fetchone() is None:
cur.execute("alter table links add column sort_order int not null default 0 after category")
cur.execute("show columns from links like 'created_at'")
created_col = cur.fetchone()
cur.execute("show columns from links like 'updated_at'")
updated_col = cur.fetchone()
created_is_varchar = bool(created_col and str(created_col.get("Type", "")).startswith("varchar"))
updated_is_varchar = bool(updated_col and str(updated_col.get("Type", "")).startswith("varchar"))
if created_is_varchar or updated_is_varchar:
cur.execute("alter table links add column created_at_dt datetime null, add column updated_at_dt datetime null")
cur.execute(
"""
update links
set created_at_dt=coalesce(
str_to_date(created_at, '%Y-%m-%dT%H:%i:%s.%f'),
str_to_date(created_at, '%Y-%m-%dT%H:%i:%s'),
str_to_date(created_at, '%Y-%m-%d %H:%i:%s'),
utc_timestamp()
),
updated_at_dt=coalesce(
str_to_date(updated_at, '%Y-%m-%dT%H:%i:%s.%f'),
str_to_date(updated_at, '%Y-%m-%dT%H:%i:%s'),
str_to_date(updated_at, '%Y-%m-%d %H:%i:%s'),
utc_timestamp()
)
"""
)
cur.execute("alter table links drop column created_at, drop column updated_at")
cur.execute(
"alter table links change column created_at_dt created_at datetime not null, "
"change column updated_at_dt updated_at datetime not null"
)
init_db() init_db()
@@ -156,6 +190,10 @@ def utc_now_iso() -> str:
return datetime.utcnow().isoformat() return datetime.utcnow().isoformat()
def utc_now_db() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def expires_at_iso() -> str: def expires_at_iso() -> str:
now = datetime.utcnow().timestamp() now = datetime.utcnow().timestamp()
return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat() return datetime.utcfromtimestamp(now + SESSION_TTL_SECONDS).isoformat()
@@ -194,13 +232,12 @@ def current_user(request: Request, response: Response | None = None):
should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS should_rotate = (now - datetime.fromisoformat(last_seen_at)).total_seconds() >= SESSION_ROTATE_SECONDS
except ValueError: except ValueError:
should_rotate = True should_rotate = True
if should_rotate: if should_rotate and response is not None:
new_token = secrets.token_urlsafe(32) new_token = secrets.token_urlsafe(32)
cur.execute( cur.execute(
"update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s", "update sessions set token=%s,last_seen_at=%s,expires_at=%s where token=%s",
(new_token, now_iso, new_expires_at, token), (new_token, now_iso, new_expires_at, token),
) )
if response is not None:
response.set_cookie( response.set_cookie(
SESSION_COOKIE, SESSION_COOKIE,
new_token, new_token,
@@ -358,6 +395,10 @@ def login(request: Request, inp: LoginIn):
raise HTTPException(401, "Invalid credentials") raise HTTPException(401, "Invalid credentials")
login_attempts.pop(key, None) login_attempts.pop(key, None)
login_lockouts.pop(key, None) login_lockouts.pop(key, None)
old_token = request.cookies.get(SESSION_COOKIE)
if old_token:
with c.cursor() as cur:
cur.execute("delete from sessions where token=%s", (old_token,))
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
with c.cursor() as cur: with c.cursor() as cur:
now = utc_now_iso() now = utc_now_iso()
@@ -395,7 +436,7 @@ def logout(request: Request):
def links(): def links():
with db() as c: with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
cur.execute("select * from links order by enabled desc, category, name") cur.execute("select * from links order by enabled desc, sort_order asc, name asc")
rows = cur.fetchall() rows = cur.fetchall()
out = [] out = []
for r in rows: for r in rows:
@@ -434,17 +475,16 @@ def create_link(
require_admin(request) require_admin(request)
require_csrf(request) require_csrf(request)
validate_link_payload(name, url, description, category, icon_url) validate_link_payload(name, url, description, category, icon_url)
icon_blob, icon_mime = read_icon_blob(icon)
now = utc_now_db()
with db() as c: with db() as c:
if link_name_exists(c, name): if link_name_exists(c, name):
raise HTTPException(409, "Link name already exists") raise HTTPException(409, "Link name already exists")
icon_blob, icon_mime = read_icon_blob(icon)
now = datetime.utcnow().isoformat()
with db() as c:
with c.cursor() as cur: with c.cursor() as cur:
cur.execute( cur.execute(
"""insert into links(name,url,description,category,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at) """insert into links(name,url,description,category,sort_order,icon_blob,icon_mime,icon_url,enabled,created_at,updated_at)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(name, url, description, category, icon_blob, icon_mime, icon_url, int(enabled), now, now), (name, url, description, category, 0, icon_blob, icon_mime, icon_url, int(enabled), now, now),
) )
return {"ok": True} return {"ok": True}
@@ -486,7 +526,7 @@ def update_link(
require_csrf(request) require_csrf(request)
validate_link_payload(name, url, description, category, icon_url) validate_link_payload(name, url, description, category, icon_url)
icon_blob, icon_mime = read_icon_blob(icon) icon_blob, icon_mime = read_icon_blob(icon)
now = datetime.utcnow().isoformat() now = utc_now_db()
with db() as c: with db() as c:
if link_name_exists(c, name, exclude_id=link_id): if link_name_exists(c, name, exclude_id=link_id):
raise HTTPException(409, "Link name already exists") raise HTTPException(409, "Link name already exists")