Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,19 @@ All notable changes to Playforge are documented here. The format loosely follows

## [1.0.0] — 2026-09-06

The version where the promises start binding. No new features: what changed is
that the API surface, the cross-site posture and the support policy are now
things a user can hold the project to, and each one is enforced by something that
fails a build rather than by a sentence in a document.
Cross-site requests are refused, the `/api` surface is pinned by a snapshot the
suite checks, and the support policy says what happens after this tag. No new
features: the things README already claimed are now checked by the build.


### Security
- **The zip import's traversal guard actually contains.** It compared paths as
strings, and `extracted_evil` starts with `extracted`, so a sibling-named entry
- **The zip import's traversal guard compares paths, not prefixes.** It matched as
text, and `extracted_evil` starts with `extracted`, so a sibling-named entry
passed a check meant to keep members inside the extraction directory. Nothing
escaped: `zipfile` strips `..` from member names itself, which is what made the
weakness invisible. A guard that holds only because of the thing it guards
stops holding the day that thing changes, so it now uses `is_relative_to`. The
test fails against the old comparison, which is how it earns its place.
test fails against the old comparison.

- **CSRF protection, in all three auth modes.** Two layers, both ahead of
authentication.
Expand Down Expand Up @@ -87,7 +86,8 @@ fails a build rather than by a sentence in a document.
import-path and import-zip, including the guard that rejects a zip entry
climbing out of the extraction directory.

`app/api/projects.py` goes 42% -> 77%; the total 77.83% -> 81.99%.
`app/api/projects.py` goes 42% -> 77%; the total 77.83% -> 82.35% over the
release, 736 -> 844 cases.

### Changed
- **Supported-versions policy stated for 1.0.** Fixes go to the newest release as
Expand All @@ -99,8 +99,8 @@ fails a build rather than by a sentence in a document.
`make coverage` target that runs the same gate locally. Coverage was already
measured and uploaded to Codecov, but nothing failed a build when it fell — a
number nobody defends drifts down. The floor is a ratchet: raise it as tests
land, never lower it to go green. Currently 81, just under the real total
(81.99%), because coverage.py compares the unrounded figure.
land, never lower it to go green. Currently 82, just under the real total
(82.35%), because coverage.py compares the unrounded figure.

### Removed
- **`DOCKERHUB.md`.** The overview is edited on Docker Hub itself now. The file
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
- **The WebSocket guards itself.** HTTP middleware doesn't cover WS scope, so a
socket has to repeat all of it: the origin, the session and the role, in the
handler. A handshake cannot carry a CSRF token — `new WebSocket()` sets no
headers — so `Origin` is the only thing there is to judge it on.
headers — so `Origin` is all there is to judge it on.
- **Secrets never leave the API.** Credential material is read from disk at run
time and written to 0600 files in a per-run temp dir. No endpoint returns one.

Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,11 @@ browser could claim the instance.
Failed logins are throttled per client address — 5 attempts, then a lockout
doubling from 30s to 15 minutes.

**Writing a script against your instance?** State-changing requests are checked
for cross-site origin, and browsers additionally have to send a CSRF token. A
client that never loads a page — `curl`, your script, `make lab-regression` —
sends no `Origin` and picks up no cookie, so it is unaffected and needs no token.
See [SECURITY.md](SECURITY.md) for why that is safe rather than a loophole.
State-changing requests are checked for cross-site origin, and a browser must
also send a CSRF token. A client that never loads a page — `curl`, a script,
`make lab-regression` — sends no `Origin` and picks up no cookie, so it needs no
token and is unaffected. [SECURITY.md](SECURITY.md) explains why that is the
coherent rule rather than a way around the check.

### Optional

Expand Down Expand Up @@ -182,7 +182,7 @@ against a newer database logs a warning rather than half-working. To go back:

```bash
docker compose down
scripts/restore.sh backups/playforge-backup-0.1.0-<stamp>.tar.gz
scripts/restore.sh backups/playforge-backup-1.0.0-<stamp>.tar.gz
```

`restore.sh` won't overwrite a populated `./data` without `--force`, and moves the
Expand Down
8 changes: 4 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,10 @@ A request carrying neither header is allowed. `curl`, scripts and
the browser attaches it to every cross-origin request — so absence means no
browser was tricked into sending it.

This runs in all three auth modes, and the default one is why. With no accounts
and no `ANSIBLE_GUI_PASSWORD` there is no session cookie, so there was nothing
for `SameSite` to withhold: an instance on `127.0.0.1` could be driven by any
page open in the same browser. JSON endpoints were shielded by the browser's own
This runs in all three auth modes, and the default one is the reason it has to.
With no accounts and no `ANSIBLE_GUI_PASSWORD` there is no session cookie, so
`SameSite` had nothing to withhold, and an instance on `127.0.0.1` could be
driven by any page open in the same browser. JSON endpoints were shielded by the browser's own
CORS preflight, but `multipart/form-data` needs no preflight, and
`POST /api/projects/import-zip` accepts it. `tests/test_csrf.py` reproduces the
request that used to succeed and now asserts it is refused.
Expand Down
9 changes: 4 additions & 5 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,10 @@ async def csrf_protect(request: Request, call_next):
`SameSite`: such a browser sends the cookie cross-site, and the attacker
still cannot produce the token for it.

A request without the cookie is left to the Origin layer. That is not a hole
to plug but the only coherent rule: with `SameSite=Lax` a forged cross-site
POST arrives with no cookies at all, so demanding a token would reject curl,
scripts and `make lab-regression` without inconveniencing an attacker for a
moment.
A request without the cookie is left to the Origin layer. Under `SameSite=Lax`
a forged cross-site POST arrives with no cookies either, so demanding a token
from cookie-less requests would reject curl, scripts and `make lab-regression`
without stopping an attacker.

Reads are never checked. A cross-site GET cannot read the response.
"""
Expand Down
6 changes: 3 additions & 3 deletions backend/tests/test_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@

make api-contract

A regenerated snapshot in a diff is the signal to ask whether callers were
considered — that is the whole point of it being a file rather than a runtime
check.
It lives in a file rather than being computed at runtime so that a change to the
surface shows up in review, where someone can ask whether callers were
considered.
"""
from __future__ import annotations

Expand Down
7 changes: 3 additions & 4 deletions backend/tests/test_projects_api_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,9 @@ async def test_a_sibling_named_entry_is_rejected():
"""`extracted_evil` starts with `extracted`.

A string-prefix guard accepts this member: as text the resolved path begins
with the extraction directory, while sitting outside it. Nothing ever
escaped, because zipfile strips `..` from member names on its own — but a
check that holds only because of the thing it is checking is not a check.
This pins containment, not the accident.
with the extraction directory while sitting outside it. Nothing ever escaped,
because zipfile strips `..` from member names on its own — so the guard was
never what kept it in. This asserts containment rather than that accident.
"""
data = _zip_bytes({"../extracted_evil/f.txt": "pwned"})
with pytest.raises(HTTPException) as exc:
Expand Down
Loading