server: anonymous LIST of users returns 403, not 200 with an empty array
Found while re-verifying the security posture against the public hostname now that the server is internet-exposed. pb_hooks/main.pb.js covered bookcases, shelves and books but never users, so an anonymous GET of the accounts collection answered 200 with an empty array. Nothing leaked: two real accounts exist and the declarative listRule filtered both out, so no account, email or id was ever visible to an anonymous caller. But "200 with []" is the exact wrong signal this hook exists to remove — some clients read it as an allowed request — and the accounts collection is the last place to leave it. Defensible while the server was localhost-only; not now. Re-verified over the internet after restarting the service: books, shelves, bookcases and users all 403 anonymous, self-registration 403, health 200. Also re-verified that login still works, since this hook now runs on a collection the app authenticates against: auth-with-password returns 200 with a token, and an authenticated LIST of all four collections still returns 200. The hook rejects unauthenticated list/search only, and auth-with-password is not a list request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPpdG8VnRfS3KkisR3HUAE
This commit is contained in:
1 parent
93546ed724
commit
0455794603
3 files changed
+42
-17
No files matched your search
+16
-9
@@ -107,16 +107,23 @@ Consequences that were not true when the app was designed:
|
|||||||
| `GET /api/collections/bookcases/records` | **403** |
|
| `GET /api/collections/bookcases/records` | **403** |
|
||||||
| `POST /api/collections/users/records` (self-registration) | **403** |
|
| `POST /api/collections/users/records` (self-registration) | **403** |
|
||||||
| `GET /api/health` | 200 (intended — it is a health check and leaks nothing) |
|
| `GET /api/health` | 200 (intended — it is a health check and leaks nothing) |
|
||||||
| `GET /api/collections/users/records` | **200 `{"items":[]}`** — see below |
|
| `GET /api/collections/users/records` | **403** (was 200 `{"items":[]}` — fixed same day, below) |
|
||||||
|
|
||||||
**Known gap, not a leak:** `users` LIST answers **200 with an empty array** instead of
|
**The `users` gap, found and closed on 2026-09-12.** `users` LIST was answering **200
|
||||||
403. No data escapes — there are two real accounts in the DB and an anonymous caller
|
with an empty array** instead of 403. No data escaped — there are two real accounts in
|
||||||
sees neither, because `listRule` filters the rows out — but the status code is wrong
|
the DB and an anonymous caller saw neither, because `listRule` filters the rows out —
|
||||||
and it is the exact PocketBase quirk `pb_hooks/main.pb.js` exists to paper over. That
|
but the status code was wrong, and it was the exact PocketBase quirk
|
||||||
hook lists only `bookcases`, `shelves`, `books`; `users` was never added to it. One
|
`pb_hooks/main.pb.js` exists to paper over. That hook listed only `bookcases`,
|
||||||
word of a fix, and worth doing now that the collection is world-reachable: some
|
`shelves`, `books`; `users` was never added, which was defensible while the server was
|
||||||
clients read "200 with []" as an allowed request. **Not yet done — do not record it as
|
localhost-only and is not now that the collection is world-reachable.
|
||||||
done until the curl above returns 403.**
|
|
||||||
|
Fixed by adding `"users"` to the hook's collection list and restarting the service.
|
||||||
|
Re-verified over the internet **after** the restart: all four collections 403 anonymous,
|
||||||
|
self-registration 403, health 200. Login was re-verified too, because this hook runs on
|
||||||
|
a collection the app authenticates against: `POST /api/collections/users/auth-with-password`
|
||||||
|
still returns 200 with a token, and an authenticated LIST of all four collections still
|
||||||
|
returns 200. The hook only rejects UNAUTHENTICATED list/search, and auth-with-password
|
||||||
|
is not a list request.
|
||||||
|
|
||||||
## STATE: what is DONE
|
## STATE: what is DONE
|
||||||
### Wave 1A — server: COMPLETE and verified by the orchestrator (not just self-reported)
|
### Wave 1A — server: COMPLETE and verified by the orchestrator (not just self-reported)
|
||||||
|
|||||||
+12
-6
@@ -90,12 +90,18 @@ A `200` with a non-empty `items` array from any of those is a breach, and a
|
|||||||
`200` with an empty one means the `pb_hooks` guard is not loaded — see the
|
`200` with an empty one means the `pb_hooks` guard is not loaded — see the
|
||||||
quirk described above.
|
quirk described above.
|
||||||
|
|
||||||
> **Known gap:** `GET /api/collections/users/records` currently answers
|
Check the accounts collection too — it is covered by the same hook:
|
||||||
> **200 with an empty array** rather than 403. Nothing is disclosed — the
|
|
||||||
> `listRule` filters every row out, so an anonymous caller sees no accounts,
|
```sh
|
||||||
> no emails, no ids — but it is the same wrong-signal quirk as above, and
|
curl -s -o /dev/null -w '%{http_code}\n' "$BASE/api/collections/users/records"
|
||||||
> `pb_hooks/main.pb.js` does not yet cover the `users` collection. Adding it
|
# -> 403
|
||||||
> to that hook's collection list closes it.
|
|
||||||
|
# ...but logging in must still work, which is the thing to re-check after any
|
||||||
|
# change to pb_hooks (the hook rejects anonymous LIST, not authentication):
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/api/collections/users/auth-with-password" \
|
||||||
|
-H 'Content-Type: application/json' -d '{"identity":"you@example.com","password":"..."}'
|
||||||
|
# -> 200
|
||||||
|
```
|
||||||
|
|
||||||
## Where the live server runs
|
## Where the live server runs
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,24 @@
|
|||||||
// ever get a 200 back from these endpoints, even an empty one, since some
|
// ever get a 200 back from these endpoints, even an empty one, since some
|
||||||
// HTTP/JS clients treat "200 with []" as a successful, allowed request.
|
// HTTP/JS clients treat "200 with []" as a successful, allowed request.
|
||||||
// This hook makes that explicit: anonymous list/search requests against the
|
// This hook makes that explicit: anonymous list/search requests against the
|
||||||
// three app collections are rejected with 403, matching create/update/
|
// app collections are rejected with 403, matching create/update/
|
||||||
// view/delete (which already 400/404 for unauthenticated callers via the
|
// view/delete (which already 400/404 for unauthenticated callers via the
|
||||||
// declarative rules alone).
|
// declarative rules alone).
|
||||||
|
//
|
||||||
|
// "users" is in this list as of 2026-09-12. It was omitted originally, when
|
||||||
|
// the server was only reachable on localhost; the server is now exposed to
|
||||||
|
// the internet, so an anonymous GET of /api/collections/users/records was
|
||||||
|
// answering 200 with an empty array. Nothing leaked — the declarative
|
||||||
|
// listRule filters every row out, so no account, email or id was ever
|
||||||
|
// visible — but it is the same wrong signal this hook exists to remove, and
|
||||||
|
// the accounts collection is the last place to leave it.
|
||||||
|
//
|
||||||
|
// This blocks only UNAUTHENTICATED list/search. Logging in is unaffected:
|
||||||
|
// auth-with-password is not a list request, and it is the only users
|
||||||
|
// endpoint the app calls at all.
|
||||||
onRecordsListRequest((e) => {
|
onRecordsListRequest((e) => {
|
||||||
if (!e.auth) {
|
if (!e.auth) {
|
||||||
throw new ForbiddenError("Authentication required.");
|
throw new ForbiddenError("Authentication required.");
|
||||||
}
|
}
|
||||||
e.next();
|
e.next();
|
||||||
}, "bookcases", "shelves", "books");
|
}, "bookcases", "shelves", "books", "users");
|
||||||
Reference in new issue
Block a user