mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
771ec5bbaa
The query-string half of BUG-2782. A caller-supplied query value reached a
Postgres text comparison, Postgres refused the parameter, and the handler
answered 500 — the honest answer is 400, because the caller asked about
something that cannot exist.
ValidateQuery is a root-router middleware beside ValidatePath, refusing 400
invalid_query when a decoded query key or value is invalid UTF-8 or carries
a NUL. Both share one predicate, bindableText.
Measured on Postgres 17 (server_encoding UTF8) at 19330410: 8 GET endpoints
x 54 parameter names — every name any handler reads — each probe from its
own source IP because the api limiter is keyed on ip:.
invalid-UTF-8 value: 276 x 200, 56 x 400, 100 x 500 -> 432 x 400
NUL value: 276 x 200, 56 x 400, 100 x 500 -> 432 x 400
control: 376 x 200, 56 x 400, 0 x 500 -> unchanged
Zero 500s in the control, so the 100 are attributable to the value. The
error is `invalid byte sequence for encoding "UTF8": 0xff (SQLSTATE 22021)`.
WHY A TRANSPORT RULE, when BUG-2782 planned per-site validators on
BUG-2774's validCursorID model. Reading the mechanism retired that plan:
parseItemListParams folds every parameter it does not recognise into a
field filter, so ?email=, ?type= and ?anything-at-all= reach a text
comparison exactly as ?search= does — 98 of the 100 failures are those two
endpoints. The set of names is unbounded by design, so there is no finite
list of points to validate.
WHY IT IS NOT A NARROWING of what callers may send, which was BUG-2782's
objection. That objection is sound against a charset rule and does not
reach this one: bindableText requires only valid UTF-8 with no NUL, every
legitimate value here is text, and text is valid UTF-8 in any language.
CONTRACT CHANGE: the timeline's before_id answers invalid_query rather than
invalid_cursor, since the transport rule runs first. Same 400, same
client-error contract, less specific code. validCursorID is NOT dead — two
of its three call sites read ids from the item's own fields blob, which no
request middleware sees — and that reasoning is recorded at the function
definition where someone would land before deleting it.
Keys are validated precautionarily: an invalid-UTF-8 parameter NAME did not
reproduce a 500 in the sweep (7 x 200, 1 x 400), and why it survives is
unread, so they are checked rather than assumed safe.
Gates: go test ./... green; full Postgres suite -timeout=45m green (28
packages, own container, not the shared port); -race on internal/server
green; lint 0 issues; vuln clean; gofmt clean. Mutation matrix 14/14,
including the unwiring mutation run against the Postgres leg to confirm it
fails with the ORIGINAL 500 rather than merely failing.
Ten adversarial review rounds. The first two found a vacuous test (one item
in the workspace meant a handler ignoring ?search passed it) and a
self-contradicting proof. Later rounds found false statements, including
one where my own sweep CLAIM was false. Two real defects were found AFTER
the first MERGE verdict, which is why the rounds continued.
Filed not folded: BUG-2803, an escaped NUL in a JSON body reaching the
store on every JSON write path — a different surface needing decode-time
rather than transport-level validation.
Release note: invalid UTF-8 or NUL bytes in query parameters now return 400
instead of 500 on Postgres deployments.
Claude-Session: https://claude.ai/code/session_011T365kP1N9V88y15HxL4YN
419 lines
18 KiB
Go
419 lines
18 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
"github.com/PerpetualSoftware/pad/internal/store/storetest"
|
|
)
|
|
|
|
// badPathSeg is the CANONICALLY escaped form of an invalid-UTF-8 byte.
|
|
//
|
|
// The case of the hex digits is load-bearing and not cosmetic: Go escapes
|
|
// 0xff as uppercase "%FF", so url.Parse leaves RawPath empty for this form
|
|
// and chi routes on the DECODED Path — which is how the raw byte reaches a
|
|
// handler and then the store. The lowercase form "%ff" is NOT canonical, so
|
|
// RawPath is populated, chi routes on it, and URLParam yields the literal
|
|
// text. Tests that used only the lowercase form would exercise the harmless
|
|
// half of the vector and pass against unfixed code.
|
|
const badPathSeg = "bad-%FF-x"
|
|
|
|
// assertPathVectorIntact fails if the request Go builds from target does not
|
|
// actually carry invalid path text — i.e. if the premise of every assertion
|
|
// below has stopped holding (a Go escaping change, a different chi routing
|
|
// choice). A test whose vector has quietly become inert passes for a reason
|
|
// that has nothing to do with the code it names.
|
|
func assertPathVectorIntact(t *testing.T, target string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest("GET", target, nil)
|
|
if utf8.ValidString(req.URL.Path) && !strings.ContainsRune(req.URL.Path, 0) {
|
|
t.Fatalf("premise broken: %q parses to a path that is already valid text (%q); "+
|
|
"this test can no longer exercise BUG-2782's vector", target, req.URL.Path)
|
|
}
|
|
}
|
|
|
|
func pathErrorCode(t *testing.T, rr *httptest.ResponseRecorder) string {
|
|
t.Helper()
|
|
var body struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode error body %q: %v", rr.Body.String(), err)
|
|
}
|
|
return body.Error.Code
|
|
}
|
|
|
|
// TestValidatePathRejectsUnbindablePathText drives the REAL server (not
|
|
// ValidatePath directly) so it vouches for the middleware's BINDING as well
|
|
// as its logic — a direct call would pass even if nobody had wired it into
|
|
// the chain (CONVE-19).
|
|
//
|
|
// Every case is a 400 before any handler runs.
|
|
//
|
|
// What this test proves is that claim and no more. It does NOT reproduce
|
|
// the unfixed behaviour, and the sweep it comes from found no single
|
|
// answer to replace it with. The pre-fix distribution across 247 probes on
|
|
// Postgres, pasted from the run:
|
|
//
|
|
// 500: 191 404: 34 403: 12 401: 4 503: 4 400: 2
|
|
//
|
|
// The 56 non-500s are routes whose authorization or configuration gate
|
|
// answers before any store call is reached — admin user lookup, the
|
|
// attachment routes on a server with no storage configured. "These were
|
|
// all 500 before" would have been the tidier sentence and false for
|
|
// nearly a quarter of the table.
|
|
//
|
|
// No test here reproduces that 500, and none can: every test runs the
|
|
// FIXED server. TestValidatePathPostgresNoInternalError below drives the
|
|
// vector on the backend where the 500 occurred and requires 400; what
|
|
// keeps the 500 reachable as evidence is the mutation matrix, since
|
|
// unwiring ValidatePath fails that test.
|
|
func TestValidatePathRejectsUnbindablePathText(t *testing.T) {
|
|
srv := testServer(t)
|
|
ws := createWSForTest(t, srv)
|
|
|
|
targets := []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
}{
|
|
// One per resolver family reachable from a path segment.
|
|
{"workspace slug", "GET", "/api/v1/workspaces/" + badPathSeg},
|
|
{"workspace slug (subroute)", "GET", "/api/v1/workspaces/" + badPathSeg + "/activity"},
|
|
{"item slug", "GET", "/api/v1/workspaces/" + ws + "/items/" + badPathSeg},
|
|
{"item slug (subroute)", "GET", "/api/v1/workspaces/" + ws + "/items/" + badPathSeg + "/timeline"},
|
|
{"collection slug", "GET", "/api/v1/workspaces/" + ws + "/collections/" + badPathSeg + "/items"},
|
|
{"attachment id", "GET", "/api/v1/workspaces/" + ws + "/attachments/" + badPathSeg},
|
|
{"admin user id", "GET", "/api/v1/admin/users/" + badPathSeg},
|
|
{"invitation code", "GET", "/api/v1/invitations/" + badPathSeg + "/preview"},
|
|
{"share token", "GET", "/api/v1/s/" + badPathSeg},
|
|
{"collab item id", "GET", "/api/v1/collab/" + badPathSeg},
|
|
// Methods other than GET route through the same chain.
|
|
{"PATCH item", "PATCH", "/api/v1/workspaces/" + ws + "/items/" + badPathSeg},
|
|
{"DELETE item", "DELETE", "/api/v1/workspaces/" + ws + "/items/" + badPathSeg},
|
|
{"POST comment", "POST", "/api/v1/workspaces/" + ws + "/items/" + badPathSeg + "/comments"},
|
|
// A NUL is VALID UTF-8 but Postgres refuses it in a text parameter,
|
|
// so the rule covers it too and this case would survive dropping the
|
|
// ContainsRune half of bindableText.
|
|
{"NUL byte", "GET", "/api/v1/workspaces/" + ws + "/items/bad-%00-x"},
|
|
// Non-canonical escaping of the same byte. Harmless on today's chi
|
|
// (RawPath is populated, so the segment stays percent-encoded), and
|
|
// answered identically anyway — the response must not depend on the
|
|
// case of a hex digit.
|
|
{"lowercase %ff", "GET", "/api/v1/workspaces/" + ws + "/items/bad-%ff-x"},
|
|
// Not an /api/v1 route: ValidatePath is on the ROOT router, so the
|
|
// SPA catch-all is covered too. Fails if someone moves the Use() into
|
|
// the API group.
|
|
{"non-API route (SPA catch-all)", "GET", "/" + badPathSeg},
|
|
}
|
|
|
|
for _, tc := range targets {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assertPathVectorIntact(t, tc.target)
|
|
rr := doRequest(srv, tc.method, tc.target, nil)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("%s %s: expected 400, got %d: %s",
|
|
tc.method, tc.target, rr.Code, rr.Body.String())
|
|
}
|
|
if code := pathErrorCode(t, rr); code != "invalid_path" {
|
|
t.Fatalf("%s %s: expected error code invalid_path, got %q",
|
|
tc.method, tc.target, code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidatePathAllowsValidText is the control leg. The rule is not "only
|
|
// what the database rejects" — it deliberately refuses bytes SQLite and a
|
|
// SQL_ASCII Postgres would accept, because a uniform transport policy is
|
|
// the point. What it must not do is refuse anything a CLIENT can
|
|
// legitimately send: a middleware that answered 400 to every path with a
|
|
// percent-escape, or to anything non-ASCII, would pass the test above and
|
|
// break real callers. These cases are what tells the two apart.
|
|
func TestValidatePathAllowsValidText(t *testing.T) {
|
|
srv := testServer(t)
|
|
ws := createWSForTest(t, srv)
|
|
|
|
// Valid UTF-8 segments reach the resolver and get its answer (404),
|
|
// NOT the middleware's (400).
|
|
for _, seg := range []string{
|
|
"caf%C3%A9-x", // é
|
|
"rocket-%F0%9F%9A%80", // emoji
|
|
"plain-ascii-miss",
|
|
} {
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+ws+"/items/"+seg, nil)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("GET item %q: expected 404 from the resolver, got %d: %s",
|
|
seg, rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// The emoji reaction route, which is the real reason the rule permits
|
|
// non-ASCII rather than a hypothetical: the web client sends
|
|
// DELETE .../reactions/${encodeURIComponent(emoji)}, so a rule that
|
|
// refused non-ASCII paths would break removing a reaction. The comment
|
|
// id is bogus, so the expected answer is the handler's, not the
|
|
// middleware's — what matters is that it is not 400 invalid_path.
|
|
{
|
|
emojiRoute := "/api/v1/workspaces/" + ws + "/comments/00000000-0000-0000-0000-000000000000/reactions/%F0%9F%9A%80"
|
|
rr := doRequest(srv, "DELETE", emojiRoute, nil)
|
|
if rr.Code == http.StatusBadRequest && pathErrorCode(t, rr) == "invalid_path" {
|
|
t.Fatalf("the emoji reaction route was refused by ValidatePath: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// And a real item still resolves end to end.
|
|
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/collections/tasks/items",
|
|
map[string]interface{}{"title": "Path control item"})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create item: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var it models.Item
|
|
parseJSON(t, rr, &it)
|
|
if rr := doRequest(srv, "GET", "/api/v1/workspaces/"+ws+"/items/"+it.Slug, nil); rr.Code != http.StatusOK {
|
|
t.Fatalf("GET real item: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBindableText(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want bool
|
|
}{
|
|
{"/api/v1/workspaces/demo/items/task-5", true},
|
|
{"/api/v1/workspaces/demo/items/café", true},
|
|
{"/api/v1/workspaces/demo/items/\U0001F680", true},
|
|
{"/", true},
|
|
{"", true},
|
|
{"/api/v1/items/bad-\xff-x", false}, // lone 0xff
|
|
{"/api/v1/items/bad-\xc3(-x", false}, // truncated 2-byte sequence
|
|
{"/api/v1/items/\xed\xa0\x80", false}, // surrogate half
|
|
{"/api/v1/items/\xc0\xaf", false}, // overlong encoding
|
|
{"/api/v1/items/bad-\x00-x", false}, // NUL: valid UTF-8, refused by Postgres
|
|
}
|
|
for _, c := range cases {
|
|
if got := bindableText(c.in); got != c.want {
|
|
t.Errorf("bindableText(%q) = %v, want %v", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestValidatePathPostgresNoInternalError is the dialect half: it drives the
|
|
// vector on the backend where the 500 occurred, against a real Postgres
|
|
// store, and requires 400.
|
|
//
|
|
// It does NOT reproduce the original symptom, and the name should not be
|
|
// read as claiming that — it runs the FIXED server, so the 500 is exactly
|
|
// what it can never observe. The 500 was established by a counterfactual
|
|
// sweep with the middleware unwired (the distribution is recorded above);
|
|
// the mutation matrix is what keeps that reachable, since unwiring
|
|
// ValidatePath fails this test. A test cannot both apply the fix and
|
|
// witness the bug.
|
|
//
|
|
// Skips unless PAD_TEST_POSTGRES_URL is set; runs under `make test-pg`.
|
|
func TestValidatePathPostgresNoInternalError(t *testing.T) {
|
|
if os.Getenv("PAD_TEST_POSTGRES_URL") == "" {
|
|
t.Skip("PAD_TEST_POSTGRES_URL not set — the 500 only reproduces on Postgres")
|
|
}
|
|
s := storetest.NewPostgres(t)
|
|
srv := New(s)
|
|
t.Cleanup(func() { srv.Stop() })
|
|
if srv.store.D().Driver() != store.DriverPostgres {
|
|
t.Fatalf("expected a Postgres store, got %s", srv.store.D().Driver())
|
|
}
|
|
|
|
ws := createWSForTest(t, srv)
|
|
for _, target := range []string{
|
|
"/api/v1/workspaces/" + badPathSeg,
|
|
"/api/v1/workspaces/" + ws + "/items/" + badPathSeg,
|
|
"/api/v1/workspaces/" + ws + "/collections/" + badPathSeg + "/items",
|
|
"/api/v1/workspaces/" + ws + "/items/bad-%00-x",
|
|
} {
|
|
assertPathVectorIntact(t, target)
|
|
rr := doRequest(srv, "GET", target, nil)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("GET %s on Postgres: expected 400, got %d: %s",
|
|
target, rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// Control: the same store answers a valid request normally, so the
|
|
// 400s above are the middleware's judgement and not a broken fixture.
|
|
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+ws+"/items/no-such-item", nil)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("GET valid-but-absent item on Postgres: expected 404, got %d: %s",
|
|
rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestValidatePathRejectionLooksLikeEveryOtherAPIError pins the response
|
|
// SHAPE, not just the status.
|
|
//
|
|
// ValidatePath runs on the root router, so its rejection short-circuits
|
|
// above the /api/v1 group's cors.Handler and jsonContentType and gets
|
|
// neither for free. Before this was handled, the 400 carried a JSON body
|
|
// typed text/plain and no CORS headers at all, which on a cross-origin
|
|
// deployment means the browser will not let the page read the response —
|
|
// a debuggable 400 arrives as an opaque network error. Each header below
|
|
// is compared against the SAME request path answered normally (404), so
|
|
// the assertion is parity with the API's own errors rather than a list of
|
|
// header names copied from a spec.
|
|
func TestValidatePathRejectionLooksLikeEveryOtherAPIError(t *testing.T) {
|
|
const allowed = "https://app.example.com"
|
|
|
|
srv := testServer(t)
|
|
srv.SetCORSOrigins(allowed)
|
|
ws := createWSForTest(t, srv)
|
|
|
|
get := func(method, target, origin string, preflight bool) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, target, nil)
|
|
req.RemoteAddr = "10.9.9.9:1"
|
|
if origin != "" {
|
|
req.Header.Set("Origin", origin)
|
|
}
|
|
if preflight {
|
|
req.Header.Set("Access-Control-Request-Method", "GET")
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
srv.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
badTarget := "/api/v1/workspaces/" + ws + "/items/" + badPathSeg
|
|
okTarget := "/api/v1/workspaces/" + ws + "/items/no-such-item"
|
|
compared := []string{
|
|
"Content-Type",
|
|
"Access-Control-Allow-Origin",
|
|
"Access-Control-Allow-Credentials",
|
|
"Vary",
|
|
}
|
|
|
|
// An allowed origin, and a disallowed one. Both directions matter: the
|
|
// second is what would fail if the rejection echoed origins the shared
|
|
// cors.Handler would refuse.
|
|
for _, origin := range []string{allowed, "https://evil.example"} {
|
|
assertPathVectorIntact(t, badTarget)
|
|
rejected := get("GET", badTarget, origin, false)
|
|
normal := get("GET", okTarget, origin, false)
|
|
|
|
if rejected.Code != http.StatusBadRequest {
|
|
t.Fatalf("origin %s: expected 400, got %d", origin, rejected.Code)
|
|
}
|
|
if normal.Code != http.StatusNotFound {
|
|
t.Fatalf("origin %s: control request expected 404, got %d", origin, normal.Code)
|
|
}
|
|
for _, h := range compared {
|
|
if got, want := rejected.Header().Get(h), normal.Header().Get(h); got != want {
|
|
t.Errorf("origin %s: header %s on the 400 = %q, but the 404 for the same route carries %q",
|
|
origin, h, got, want)
|
|
}
|
|
}
|
|
if ct := rejected.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
|
t.Errorf("origin %s: expected a JSON content type on the 400, got %q", origin, ct)
|
|
}
|
|
}
|
|
|
|
// A genuine preflight is answered by the shared cors.Handler, exactly as
|
|
// it is for any other path: a preflight asks whether the METHOD and
|
|
// HEADERS are permitted, not whether the resource exists. The real
|
|
// request that follows still gets the 400 — and can now be read.
|
|
pre := get("OPTIONS", badTarget, allowed, true)
|
|
if pre.Code != http.StatusOK {
|
|
t.Fatalf("preflight for an invalid path: expected 200 from the CORS handler, got %d", pre.Code)
|
|
}
|
|
if got := pre.Header().Get("Access-Control-Allow-Origin"); got != allowed {
|
|
t.Fatalf("preflight: expected Access-Control-Allow-Origin %q, got %q", allowed, got)
|
|
}
|
|
follow := get("GET", badTarget, allowed, false)
|
|
if follow.Code != http.StatusBadRequest || follow.Header().Get("Access-Control-Allow-Origin") != allowed {
|
|
t.Fatalf("request after preflight: got %d with Access-Control-Allow-Origin %q; want 400 readable cross-origin",
|
|
follow.Code, follow.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
}
|
|
|
|
// TestValidatePathRejectsBeforeAuthAndRateLimit pins an ORDERING decision,
|
|
// not an accident.
|
|
//
|
|
// ValidatePath sits on the root router, so a rejected request never reaches
|
|
// the /api/v1 group's TokenAuth, SessionAuth, RateLimit or CSRFProtect. That
|
|
// is deliberate, and the direction is the opposite of what "bypasses the
|
|
// rate limiter" usually implies: BEFORE this middleware existed the same
|
|
// request ran SessionAuth — which is a store.ValidateSession round trip —
|
|
// then the limiter, then a handler that issued a query the database refused,
|
|
// and answered 500. It now costs a UTF-8 scan over the path and a short JSON
|
|
// write, with no database contact at all, so the unmetered path is strictly
|
|
// cheaper than every path the limiter protects. There is also nothing to
|
|
// learn by flooding it: the answer is constant for all inputs of this shape,
|
|
// independent of authentication and of whether anything exists.
|
|
//
|
|
// Placing the check inside the group instead — where the limiter would meter
|
|
// it — would trade this for a real coverage hole, since the SPA catch-all
|
|
// and /api/v1/collab/{itemID} are mounted outside that group.
|
|
//
|
|
// The limiter itself is a plain token bucket per key (no escalating ban, no
|
|
// durable block), so skipping it for a rejected request defeats no state
|
|
// that outlives the request.
|
|
func TestValidatePathRejectsBeforeAuthAndRateLimit(t *testing.T) {
|
|
srv := testServer(t)
|
|
ws := createWSForTest(t, srv)
|
|
|
|
const attacker = "198.51.100.7:1234"
|
|
const bystander = "198.51.100.8:1234"
|
|
// The general API limiter is 600/min per key — rate 10/s, burst 60
|
|
// (NewRateLimiters, "API:"). A token bucket refills WHILE the loop runs,
|
|
// so the margin that matters is not flood-vs-burst but how long the loop
|
|
// would have to take for refill to cover the excess: at 10/s, 400
|
|
// requests are rescued only by a loop lasting longer than (400-60)/10 =
|
|
// 34 seconds. These are in-process httptest calls measured in
|
|
// microseconds each, so the margin is four orders of magnitude, and the
|
|
// test does not become flaky on a loaded box or under -race.
|
|
//
|
|
// 80 was the first value here and was wrong for exactly this reason: 20
|
|
// tokens of headroom is 2 seconds of tolerance.
|
|
const flood = 400
|
|
|
|
badTarget := "/api/v1/workspaces/" + ws + "/items/" + badPathSeg
|
|
okTarget := "/api/v1/workspaces/" + ws + "/items/no-such-item"
|
|
|
|
assertPathVectorIntact(t, badTarget)
|
|
for i := 0; i < flood; i++ {
|
|
rr := doRequestFromRemoteAddr(srv, "GET", badTarget, nil, attacker)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("invalid-path request %d/%d: expected 400, got %d: %s",
|
|
i+1, flood, rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// The same IP's budget is intact: a legitimate request still gets the
|
|
// resolver's answer rather than a 429.
|
|
if rr := doRequestFromRemoteAddr(srv, "GET", okTarget, nil, attacker); rr.Code != http.StatusNotFound {
|
|
t.Fatalf("valid request from an IP that just sent %d invalid paths: expected 404, got %d: %s",
|
|
flood, rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// PREMISE CHECK. Everything above is vacuous if the limiter is not armed
|
|
// in this configuration — an inert limiter produces the identical
|
|
// reading. The same volume of VALID requests from a different IP must
|
|
// actually hit it.
|
|
var limited bool
|
|
for i := 0; i < flood; i++ {
|
|
if doRequestFromRemoteAddr(srv, "GET", okTarget, nil, bystander).Code == http.StatusTooManyRequests {
|
|
limited = true
|
|
break
|
|
}
|
|
}
|
|
if !limited {
|
|
t.Fatalf("premise broken: %d valid requests from one IP were never rate limited, "+
|
|
"so this test cannot distinguish an unmetered rejection from a disabled limiter", flood)
|
|
}
|
|
}
|