Remove cans feature; add Secure flag to pw_/tok_ cookies
Owner decision 2026-10-16: the cans feature (bundles of pastes, #4) was half-integrated and has zero prod usage; removing it eliminates four audit findings by deletion. Removed: - internal/api/cans.go and all can routes (/api/pastes/can, /api/cans/*, /can/{id}) plus handleCanPage/renderCan - is_can from /api/mine and /api/public responses - store layer: CanRow, can store funcs, paste_cans UNION in listings, cans sweep; reserved slugs 'can'/'cans' released - templates/can.html, can CSS, is_can badges in history.js/mine.js - cans tests (cans_test.go, cans_flow_test.go, cans_expiry_test.go, TestErrorCodeCanValidation) - docs/api.md cans section, README/LICENSE/docker-compose mentions Store migration: drops paste_cans only when empty (non-empty table is kept and logged; prod verified clean 2026-10-16) and drops the dormant pastes.can_id column. Covered by new TestMigrateDropsCansSchema. Security hardening (audit findings): pw_<id> (unlock) and tok_<id> (deletion-token) cookies now carry Secure: true in addition to HttpOnly + SameSite=Lax; all deployments are HTTPS-only. cookie_set_test now asserts Secure. go vet ./... and go test ./... pass.
This commit is contained in:
1 parent
651bc0cb86
commit
e54fbc36ef
20 files changed
+176
-1356
No files matched your search
+5
-53
@@ -18,11 +18,11 @@ The examples below use `http://localhost:8080` as a placeholder.
|
||||
|
||||
There is no user account system. Access control works through three mechanisms:
|
||||
|
||||
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly `vwr` cookie. This cookie identifies the browser that created a paste or can, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
|
||||
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly `vwr` cookie. This cookie identifies the browser that created a paste, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
|
||||
- Deletion token. Creating a paste returns a one-time `deletion_token`. Sending it in the `Authorization` header (Bearer or Token prefix, or the bare value) authorizes deletion of that paste. The token is deliberately not accepted as a URL query parameter, because query strings leak into access logs and browser history.
|
||||
- Admin key. The admin settings endpoints require an install-time key sent in the `X-Admin-Key` header. The key comes from `PALETTE_ADMIN_KEY` or is generated and persisted server-side at first start.
|
||||
|
||||
Password-protected pastes and cans are unlocked with the `X-Paste-Password` request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.
|
||||
Password-protected pastes are unlocked with the `X-Paste-Password` request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.
|
||||
|
||||
### Rate limiting
|
||||
|
||||
@@ -41,7 +41,6 @@ Exceeding a limit returns `429` with a `Retry-After` header and the error code `
|
||||
|
||||
- Paste content: `PALETTE_MAX_TEXT` (default 5 MB), admin tunable. Larger content is rejected with `413`.
|
||||
- Attached files: 25 MB per file, one file per paste.
|
||||
- Can items: `PALETTE_MAX_ITEM` (default 25 MB) per item.
|
||||
- The request body itself is capped server side; oversized bodies fail with `413` before handlers run.
|
||||
|
||||
### Error format
|
||||
@@ -130,13 +129,13 @@ Errors: `400` token required, `403` invalid token, `404` not found.
|
||||
|
||||
#### GET /api/mine
|
||||
|
||||
List pastes (and cans) created from this browser, via the `vwr` cookie. Query parameters: `limit` (default 50, max 100), `offset`.
|
||||
List pastes created from this browser, via the `vwr` cookie. Query parameters: `limit` (default 50, max 100), `offset`.
|
||||
|
||||
Response `200`: `{"total": n, "limit": l, "offset": o, "items": [...]}` with each item carrying `id`, `title`, `language`, `created_at`, `view_count`, `size`, `custom_slug`, `visibility`, `is_can`.
|
||||
Response `200`: `{"total": n, "limit": l, "offset": o, "items": [...]}` with each item carrying `id`, `title`, `language`, `created_at`, `view_count`, `size`, `custom_slug`, `visibility`.
|
||||
|
||||
#### GET /api/public
|
||||
|
||||
List public pastes. Query parameters: `limit` (default 25, max 100), `offset`. Same shape as `/api/mine`; items include `is_can` and `custom_slug`.
|
||||
List public pastes. Query parameters: `limit` (default 25, max 100), `offset`. Same shape as `/api/mine`; items include `custom_slug`.
|
||||
|
||||
### Files and raw content
|
||||
|
||||
@@ -162,53 +161,6 @@ Response `200`: `{"language": "go"}`. The result is a best-effort guess and may
|
||||
|
||||
Errors: `400` invalid JSON body, `429` rate limited.
|
||||
|
||||
### Cans
|
||||
|
||||
A can bundles multiple text items and files into one shareable page at `/can/{id}`. Cans appear in `/api/mine` and `/api/public` with `is_can: true`.
|
||||
|
||||
#### POST /api/pastes/can
|
||||
|
||||
Create a can. Multipart form only. Fields:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `title` | string | optional |
|
||||
| `description` | string | optional |
|
||||
| `visibility` | string | `public` or `unlisted`; default `public` |
|
||||
| `expires_in` | string | Go duration, 1 minute to 1 year |
|
||||
| `password` | string | optional |
|
||||
| `custom_slug` | string | optional; becomes the can id |
|
||||
| `json_items` | string | JSON array of `{"title": "...", "content": "...", "language": "..."}` text items |
|
||||
| files | parts | one or more uploaded file parts |
|
||||
|
||||
A can needs at least one item (file or `json_items`); otherwise the can is deleted and `400` (`content_empty`) is returned.
|
||||
|
||||
Response `201`: `{"id": "...", "url": "/can/{id}", "items": n}`.
|
||||
|
||||
Errors: `400` (`invalid_visibility`, `expiry_invalid`, `invalid json_items`, `content_empty`), `409` slug errors (codes `slug_taken`, `slug_invalid`, `slug_reserved`), `413` item over the size cap.
|
||||
|
||||
#### GET /api/cans/{id}
|
||||
|
||||
Can metadata with an item list. Password-protected cans require `X-Paste-Password`.
|
||||
|
||||
Response `200`: `id`, `title`, `visibility`, `created_at`, and `items`, where each item has `id`, `title`, `content_type`, `size`, and `url` (the item's fetch URL).
|
||||
|
||||
Errors: `404` not found or expired, `401` password required.
|
||||
|
||||
#### GET /api/cans/{id}/items/{item}
|
||||
|
||||
Fetch one can item's content. Items inherit the can's password: send `X-Paste-Password`, or the browser's `pw_{can}` unlock cookie. Served with the same safe content-type guard as `/raw`.
|
||||
|
||||
Errors: `404` item not found or not a can item, `401` password required.
|
||||
|
||||
#### DELETE /api/cans/{id}
|
||||
|
||||
Soft delete a can. Creator browser only: the `vwr` cookie must match the can's viewer. Cans carry no deletion token.
|
||||
|
||||
Response `200`: `{"status": "soft-deleted"}`.
|
||||
|
||||
Errors: `403` deletion not authorized, `404` not found.
|
||||
|
||||
### Admin
|
||||
|
||||
#### GET /admin/api/settings and POST /admin/api/settings
|
||||
|
||||
Reference in new issue
Block a user