carlos / todomvc-bakeoff Public
fix(go-rastrillo): review round: strict Origin check, refuse unread forms, keep typing during adds, loopback only
- origin.go compares Origin with the app's origin directly, so Sec-Fetch-Site can no longer vouch for a foreign Origin. - /todos posts are parsed before any handler; an oversized body is 413 and a malformed one 400, so an unread title can't delete an item. - app.js clears new-todo when the add is sent, and keeps anything typed while it's in flight. - main.go refuses a TCP listen address not on 127.0.0.1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+124
−37
apps/go-rastrillo/README.md+13 −7apps/go-rastrillo/cmd/todo/main.go+21 −0apps/go-rastrillo/cmd/todo/main_test.go+17 −0apps/go-rastrillo/internal/todo/app.go+17 −4apps/go-rastrillo/internal/todo/origin.go+15 −19apps/go-rastrillo/internal/todo/static/app.js+14 −7apps/go-rastrillo/internal/todotest/todo_test.go+27 −0
diff --git a/apps/go-rastrillo/README.md b/apps/go-rastrillo/README.md| index 252d675..48a1b82 100644 |
| --- a/apps/go-rastrillo/README.md |
| +++ b/apps/go-rastrillo/README.md |
| @@ -40,14 +40,20 @@ that browser gets a fresh, empty list. The cookie is |
| `__Host-` prefix. Lists live 400 days from the first visit instead of |
| sessions' default of 30. |
| -### Cross-site protection: the Origin check, not `csrf.Protect` alone |
| +### Cross-site protection: the contract's Origin check, not `csrf.Protect` |
| Rastrillo's `csrf.Protect` refuses any POST that carries none of |
| `Sec-Fetch-Site`, `Origin` or `Referer`. The bakeoff's shared tests and |
| -load tests post exactly like that, and the contract allows it, so |
| -`origin.go` wraps it: a POST with no `Origin` header goes straight |
| -through, and every POST that has one goes through `csrf.Protect`, so a |
| -foreign origin gets 403. There's no CSRF token, as the contract says. |
| +load tests post exactly like that, and the contract allows it. Protect |
| +also trusts `Sec-Fetch-Site` ahead of `Origin`, so it would let through a |
| +request claiming `same-origin` that carries a foreign `Origin`. So |
| +`origin.go` does the contract's check instead: a POST whose `Origin` |
| +header is not exactly this app's origin gets 403, and a POST with no |
| +`Origin` header is allowed. There's no CSRF token, as the contract says. |
| + |
| +Form posts are capped at 1 MiB and parsed before any handler runs. A |
| +body that's too big gets 413 and a malformed one gets 400, and neither |
| +changes anything. |
| ### JavaScript on and off |
| @@ -75,8 +81,8 @@ make test # vet, gofmt, go test with Rastrillo's time budgets, migrati |
| make perf # every GET screen under 150ms to first byte, cold boot under 500ms |
| ``` |
| -Run it locally (it only ever binds what you give it, and defaults to |
| -`127.0.0.1:8080`): |
| +Run it locally. It refuses any TCP address not on `127.0.0.1`, and |
| +defaults to `127.0.0.1:8080`: |
| ```sh |
| dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/go-rastrillo.db" |
diff --git a/apps/go-rastrillo/cmd/todo/main.go b/apps/go-rastrillo/cmd/todo/main.go| index b74e291..bf9b165 100644 |
| --- a/apps/go-rastrillo/cmd/todo/main.go |
| +++ b/apps/go-rastrillo/cmd/todo/main.go |
| @@ -6,6 +6,7 @@ |
| package main |
| import ( |
| + "fmt" |
| "log/slog" |
| "net" |
| "os" |
| @@ -42,6 +43,12 @@ func main() { |
| if opts.Socket == "" && opts.Addr == "" && os.Getenv("LISTEN_FDS") == "" { |
| opts.Addr = localAddr |
| } |
| + if opts.Addr != "" { |
| + if err := loopbackOnly(opts.Addr); err != nil { |
| + logger.Error("listen address", "err", err) |
| + os.Exit(1) |
| + } |
| + } |
| d, err := db.Open(opts.DBPath, logger) |
| if err != nil { |
| @@ -66,6 +73,20 @@ func main() { |
| } |
| } |
| +// loopbackOnly refuses a TCP address that isn't on 127.0.0.1: the app |
| +// never listens on another interface ("0.0.0.0:8080" or ":8080" would |
| +// expose it to the whole network). |
| +func loopbackOnly(addr string) error { |
| + host, _, err := net.SplitHostPort(addr) |
| + if err != nil { |
| + return fmt.Errorf("%q: %w", addr, err) |
| + } |
| + if host != "127.0.0.1" { |
| + return fmt.Errorf("%q: only 127.0.0.1 may be listened on", addr) |
| + } |
| + return nil |
| +} |
| + |
| // origin is the app's own origin: https://$CARLOS_HOST on Carloku, and |
| // http://<listen address> when run locally. |
| func origin(addr string) string { |
diff --git a/apps/go-rastrillo/cmd/todo/main_test.go b/apps/go-rastrillo/cmd/todo/main_test.go| index e5e7a15..aacebac 100644 |
| --- a/apps/go-rastrillo/cmd/todo/main_test.go |
| +++ b/apps/go-rastrillo/cmd/todo/main_test.go |
| @@ -29,3 +29,20 @@ func TestOrigin(t *testing.T) { |
| t.Errorf("carloku origin %q", got) |
| } |
| } |
| + |
| +func TestLoopbackOnly(t *testing.T) { |
| + t.Parallel() |
| + for addr, ok := range map[string]bool{ |
| + "127.0.0.1:8080": true, |
| + "0.0.0.0:8080": false, |
| + ":8080": false, |
| + "[::]:8080": false, |
| + "localhost:8080": false, |
| + "10.0.0.5:8080": false, |
| + "127.0.0.1": false, |
| + } { |
| + if err := loopbackOnly(addr); (err == nil) != ok { |
| + t.Errorf("loopbackOnly(%q) = %v, want ok=%v", addr, err, ok) |
| + } |
| + } |
| +} |
diff --git a/apps/go-rastrillo/internal/todo/app.go b/apps/go-rastrillo/internal/todo/app.go| index 29b8e8d..cd1e244 100644 |
| --- a/apps/go-rastrillo/internal/todo/app.go |
| +++ b/apps/go-rastrillo/internal/todo/app.go |
| @@ -2,6 +2,7 @@ package todo |
| import ( |
| "context" |
| + "errors" |
| "log/slog" |
| "net/http" |
| "time" |
| @@ -60,7 +61,7 @@ func Router(d *db.DB, origin string, logger *slog.Logger) (chi.Router, error) { |
| r.Get("/active", a.page(filterActive)) |
| r.Get("/completed", a.page(filterCompleted)) |
| r.Route("/todos", func(r chi.Router) { |
| - r.Use(limitBody) |
| + r.Use(parseForm) |
| r.Post("/", a.create) |
| r.Post("/toggle-all", a.toggleAll) |
| r.Post("/clear-completed", a.clearCompleted) |
| @@ -93,12 +94,24 @@ func Configure(opts *rastrillo.Options, mux *http.ServeMux, started time.Time) { |
| opts.Wrap = perf.Middleware(&rec, perf.Options{Started: started, Logger: opts.Logger}) |
| } |
| -// limitBody caps a form post at 1 MiB: room for a title far past the |
| +// parseForm caps a form post at 1 MiB (room for a title far past the |
| // contract's 10,000 characters, and no room for a body meant to fill |
| -// the disk. |
| -func limitBody(next http.Handler) http.Handler { |
| +// the disk) and parses it before any handler runs, as form.Parse's own |
| +// docs ask. A body that is too big or malformed is refused here, so a |
| +// handler never mistakes an unread field for an empty one and changes |
| +// data on it: an over-long edit must not read as "delete". |
| +func parseForm(next http.Handler) http.Handler { |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| r.Body = http.MaxBytesReader(w, r.Body, 1<<20) |
| + if err := r.ParseForm(); err != nil { |
| + var tooBig *http.MaxBytesError |
| + if errors.As(err, &tooBig) { |
| + errorPage(w, http.StatusRequestEntityTooLarge, "That is too long to save") |
| + return |
| + } |
| + errorPage(w, http.StatusBadRequest, "That form could not be read") |
| + return |
| + } |
| next.ServeHTTP(w, r) |
| }) |
| } |
diff --git a/apps/go-rastrillo/internal/todo/origin.go b/apps/go-rastrillo/internal/todo/origin.go| index 8c9abea..9a525d9 100644 |
| --- a/apps/go-rastrillo/internal/todo/origin.go |
| +++ b/apps/go-rastrillo/internal/todo/origin.go |
| @@ -1,31 +1,27 @@ |
| package todo |
| -import ( |
| - "net/http" |
| - |
| - "amadan.net/rastrillo/rastrillo/csrf" |
| -) |
| +import "net/http" |
| // originCheck is the contract's cross-site protection: a POST carrying |
| -// an Origin header that isn't this app's own answers 403, and a POST |
| -// with no Origin header is allowed. |
| +// an Origin header that isn't exactly this app's own answers 403, and a |
| +// POST with no Origin header is allowed. |
| // |
| -// It is csrf.Protect with one exception. Protect refuses a POST that |
| -// carries none of Sec-Fetch-Site, Origin and Referer, because no current |
| -// browser sends a form that way. The bakeoff's shared tests and load |
| -// tests do post that way, and the contract allows it, so those requests |
| -// pass. Everything that does carry an Origin goes through Rastrillo's |
| -// own check (csrf.SameOrigin), Sec-Fetch-Site first. |
| +// It is deliberately not csrf.Protect. Protect refuses a POST that |
| +// carries none of Sec-Fetch-Site, Origin and Referer, which the |
| +// bakeoff's shared tests and load tests send and the contract allows; |
| +// and it trusts Sec-Fetch-Site ahead of Origin, so a request claiming |
| +// "same-origin" would pass with a foreign Origin. Here the Origin |
| +// header alone decides. |
| func originCheck(origin string) func(http.Handler) http.Handler { |
| - protect := csrf.Protect(origin) |
| return func(next http.Handler) http.Handler { |
| - protected := protect(next) |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - if r.Header.Get("Origin") == "" { |
| - next.ServeHTTP(w, r) |
| - return |
| + if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions { |
| + if o := r.Header.Get("Origin"); o != "" && o != origin { |
| + http.Error(w, "cross-origin form submission refused", http.StatusForbidden) |
| + return |
| + } |
| } |
| - protected.ServeHTTP(w, r) |
| + next.ServeHTTP(w, r) |
| }) |
| } |
| } |
diff --git a/apps/go-rastrillo/internal/todo/static/app.js b/apps/go-rastrillo/internal/todo/static/app.js| index 912db0a..824f41b 100644 |
| --- a/apps/go-rastrillo/internal/todo/static/app.js |
| +++ b/apps/go-rastrillo/internal/todo/static/app.js |
| @@ -12,7 +12,10 @@ let queue = Promise.resolve(); |
| function send(form) { |
| const body = new URLSearchParams(new FormData(form)); |
| - const addsTodo = form.querySelector('.new-todo') !== null; |
| + const newTodo = form.querySelector('.new-todo'); |
| + // TodoMVC clears the field once a todo is added. Clear it now, as the |
| + // post goes, so anything typed while it is in flight is kept. |
| + if (newTodo) newTodo.value = ''; |
| queue = queue |
| .then(() => fetch(form.action, { |
| method: 'POST', |
| @@ -24,21 +27,25 @@ function send(form) { |
| if (!res.ok) throw new Error(`${res.status} from ${form.action}`); |
| return res.text(); |
| }) |
| - .then((html) => swap(html, addsTodo)) |
| + .then((html) => swap(html, newTodo !== null)) |
| .catch(() => location.reload()); |
| } |
| -// swap replaces the list with the server's markup, keeping whatever the |
| -// visitor has half-typed into the new-todo field (unless that field was |
| -// what just posted, in which case it's cleared, as TodoMVC does). |
| +// swap replaces the list with the server's markup, keeping whatever is |
| +// in the new-todo field and its focus. After an add the field keeps |
| +// focus, as TodoMVC does. |
| function swap(html, addsTodo) { |
| const input = app.querySelector('.new-todo'); |
| - const typing = !addsTodo && input.value; |
| + const typing = input.value; |
| const focused = addsTodo || document.activeElement === input; |
| + const [start, end] = [input.selectionStart, input.selectionEnd]; |
| app.innerHTML = html; |
| const next = app.querySelector('.new-todo'); |
| if (typing) next.value = typing; |
| - if (focused) next.focus(); |
| + if (focused) { |
| + next.focus(); |
| + if (typing) next.setSelectionRange(start, end); |
| + } |
| } |
| app.addEventListener('submit', (e) => { |
diff --git a/apps/go-rastrillo/internal/todotest/todo_test.go b/apps/go-rastrillo/internal/todotest/todo_test.go| index b82f737..323847a 100644 |
| --- a/apps/go-rastrillo/internal/todotest/todo_test.go |
| +++ b/apps/go-rastrillo/internal/todotest/todo_test.go |
| @@ -240,6 +240,15 @@ func TestOriginCheck(t *testing.T) { |
| if got := titles(b.get("/").Body.String()); len(got) != 0 { |
| t.Fatalf("refused posts added %q", got) |
| } |
| + // Sec-Fetch-Site can't vouch for a foreign Origin. |
| + for _, site := range []string{"same-origin", "none"} { |
| + if rec := b.post("/todos", form, "Origin", "https://evil.example", "Sec-Fetch-Site", site); rec.Code != http.StatusForbidden { |
| + t.Errorf("foreign Origin with Sec-Fetch-Site %s: %d, want 403", site, rec.Code) |
| + } |
| + } |
| + if got := titles(b.get("/").Body.String()); len(got) != 0 { |
| + t.Fatalf("refused posts added %q", got) |
| + } |
| if rec := b.post("/todos", form, "Origin", testOrigin); rec.Code != http.StatusSeeOther { |
| t.Errorf("own Origin: %d, want 303", rec.Code) |
| } |
| @@ -278,3 +287,21 @@ func TestStylesheetsServedByteForByte(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestOversizedEditChangesNothing(t *testing.T) { |
| + t.Parallel() |
| + b := newBrowser(t, newApp(t)) |
| + b.add("keep me") |
| + id := items(b.get("/").Body.String())[0].ID |
| + huge := strings.Repeat("x", 2<<20) |
| + rec := b.post("/todos/"+id+"/edit", url.Values{"title": {huge}, "filter": {"all"}}) |
| + if rec.Code != http.StatusRequestEntityTooLarge { |
| + t.Errorf("oversized edit: %d, want 413", rec.Code) |
| + } |
| + if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"keep me"}) { |
| + t.Errorf("after oversized edit: %q", got) |
| + } |
| + if rec := b.post("/todos", url.Values{"title": {huge}}); rec.Code != http.StatusRequestEntityTooLarge { |
| + t.Errorf("oversized add: %d, want 413", rec.Code) |
| + } |
| +} |