mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
e4e914d399
* fix(server): reject path segments the database cannot be asked about (BUG-2782) Every handler that resolves a workspace, collection, item, comment or attachment from a URL path segment passes that segment to the store verbatim, and the store binds it into a text comparison. Postgres refuses a text parameter that is not valid UTF-8 or that contains a NUL (SQLSTATE 22021 / 22P05); the driver surfaces that as a query error and the handler answers 500. SQLite accepts both bytes and matches nothing, so the same request is a clean 404 there — a dialect divergence that leaves the defect invisible to self-hosted installs and live on Pad Cloud. Measured before the fix, driving every route that carries a path parameter with one segment set to "bad-%FF-x" (247 probes, one per parameter position per method, real values elsewhere): Postgres answered 500 to 191 of them, SQLite to 0. After: 0 and 0, all 247 answered 400. Fixed with one root-level middleware rather than at ~112 chi.URLParam call sites, because this is a transport-level input rule and per-call-site fixes rely on every future route remembering. ValidatePath rejects a request whose percent-DECODED path is not valid UTF-8 or contains a NUL, before routing. It validates r.URL.Path rather than what chi hands the handler. chi routes on RawPath when non-empty and Path otherwise, and Go populates RawPath only when the client's escaping is not already canonical — Go escapes 0xff as uppercase "%FF", so the CANONICAL form any ordinary client emits is exactly the one that reaches the store decoded, and the lowercase "%ff" oddity is the harmless one. Validating the decoded path answers both identically and does not depend on chi continuing to prefer RawPath. It cannot refuse Pad's own URLs: store.slugify emits only [a-z0-9-], ids are UUIDs or hex, refs are a prefix plus digits. Valid non-ASCII segments pass through untouched — the database accepts them and they may legitimately name something. 400 rather than 404 because the request is malformed as a URI and the answer does not depend on whether anything exists, so it is not an existence oracle. Scope is the path; the query string is validated at its points of use, per BUG-2774's validCursorID. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * fix(server): the invalid-path rejection must look like every other API error (BUG-2782) Codex round 1, verified before acting on: ValidatePath runs on the root router, so its rejection short-circuits ABOVE the /api/v1 group's cors.Handler and jsonContentType and inherited neither. Measured — the 400 carried a JSON body sniffed as text/plain and no CORS headers at all, while a normal 404 on the same route carried Content-Type: application/json plus the full CORS set. On a cross-origin deployment (PAD_CORS_ORIGINS set) the browser refuses to let the page read a response with no Access-Control- Allow-Origin, so a debuggable 400 arrives as an opaque network error. Fixed without duplicating the CORS configuration: the group's cors.Handler is hoisted into one shared instance, the group mounts it as before, and ValidatePath serves its rejection THROUGH the same instance. Content-Type is set explicitly, since jsonContentType is mounted below and never runs for a rejection. Moving ValidatePath down into the group instead was rejected: two covered routes live outside it — the SPA catch-all and /api/v1/collab/{itemID} — and the mutant that makes that move is caught by exactly those two subtests. A genuine preflight (Origin + Access-Control-Request-Method) to an invalid path is answered 200 by the shared handler, the same as 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. Asserted rather than described. The new test compares each header on the 400 against the SAME route answered normally, for an allowed origin AND a disallowed one, so it pins parity with the API's own errors rather than a header list copied from a spec — and the disallowed-origin leg is what would fail if the rejection echoed origins the shared handler refuses. Mutation matrix, all nine verified to COMPILE first: dropping the CORS decoration and dropping the explicit Content-Type are each detected, and only by this new test. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * test(server): pin the ordering decision the path check makes (BUG-2782) Codex round 2, angle rotated to middleware contracts: a rejected request never reaches TokenAuth, SessionAuth, RateLimit or CSRFProtect, because ValidatePath sits on the root router above that group. The finding is factually right and the ordering is deliberate, but nothing in the diff said so and no test held it — which is the same defect shape as an undocumented invariant: true today, unenforced tomorrow. Verified rather than argued, because "bypasses the rate limiter" reads as a weakening and here the direction is inverted. Before this middleware, the same request ran SessionAuth — a store.ValidateSession round trip — then the limiter, then a handler whose query the database refused, and answered 500. It now costs a UTF-8 scan and a short JSON write with no database contact, so the unmetered path is strictly cheaper than every path the limiter protects. The answer is also constant for all inputs of this shape, independent of auth and of existence, so a flood learns nothing. And the limiter is a plain token bucket per key — no escalating ban, no durable block — so skipping it defeats no state that outlives the request. The alternative, metering it inside the /api/v1 group, trades this for a real coverage hole: the SPA catch-all and /api/v1/collab/{itemID} are mounted outside that group. The test floods 80 invalid paths from one IP (burst is 60), requires all 80 to be 400 and none 429, then requires a VALID request from the same IP to still get the resolver's 404 — proving the budget was untouched. It then asserts its own premise: the same volume of valid requests from a second IP must actually hit the limiter, because an inert limiter would produce an identical reading for the first half. Both mutants land where they should: metering the rejection fails at request 61 (burst 60 + 1, which independently confirms the constant cited above), and disabling the limiter fails the premise check rather than passing quietly. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * docs(server): correct four claims this branch's own measurements refute (BUG-2782) Codex round 3, angle rotated onto prose accuracy. No behaviour changes — every finding is a sentence that was stronger than what was verified, and in two cases stronger than data already sitting in this branch. 1. "It rejects exactly what the DATABASE rejects." Too strong, and inherited verbatim from validCursorID. Postgres refuses these two classes under a UTF8 database encoding; SQL_ASCII accepts the same bytes, and SQLite's sqlite3_bind_text accepts arbitrary sequences with NUL undefined rather than erroring. Pad neither creates nor configures that database — nothing issues CREATE DATABASE or sets client_encoding — so the encoding is the operator's. Now stated as what it is: the strictest reading, applied uniformly so the two backends stop disagreeing about the same request, measured against postgres:17-alpine at its defaults. A first draft of this correction replaced the overstatement with a NEW unverified claim ("the encoding Pad's migrations create"). Grepping for CREATE DATABASE found it only in test helpers. Fixing an unchecked sentence with another unchecked sentence is the same defect wearing the repair's clothes. 2. "Against unfixed code these are 500 on Postgres and 404 on SQLite." False for 56 of the 247 pre-fix probes, and my own sweep output said so — routes whose authorization or configuration gate answers before any store call (admin user lookup; attachments with no storage configured). Replaced with the pasted distribution: 500:191 404:34 403:12 401:4 503:4 400:2. 3. "Passed through untouched" oversold what this middleware guarantees. It does not touch a valid path, but chi still hands the handler the ESCAPED text whenever RawPath is populated: "caf%C3%A9" arrives as "café", the non-canonical "caf%c3%a9" arrives literally, and "%2F" never becomes a separator. Pre-existing chi behaviour, unaffected by this change, written down because the obvious reading is stronger than the truth. 4. "The request is malformed as a URI." It is not — "%FF" and "%00" are syntactically valid percent-encoded octets. The 400 is because the DECODED value cannot be a resource identifier here, which is the actual reason and a different one. Also reconciled the two probe counts that appear in this branch's history (111/94 GET-only, 247/191 all methods) so a reader meeting both does not have to guess which is wrong; they are one sweep at two widths. CONVE-23 sweep: finding 1 falsifies the same sentence in validCursorID (handlers_timeline.go, BUG-2774), which is where this branch inherited it. Corrected there too rather than left standing — the rule that comment describes is unchanged and still right; only its claim about the database was wrong. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * test(mcp): drive the in-process transport seam the path check walks past (BUG-2782) Codex round 4's exploration pointed at the door I had asserted rather than driven: the remote /mcp transport does not reach the server over a socket. HTTPHandlerDispatcher SYNTHESIZES an *http.Request and calls Handler.ServeHTTP in-process, so "the middleware covers every route" was a claim about a path this transport bypasses on its face. Driven, it is covered — and for a chain nothing in the tree stated: Handler is the *server.Server, chi's Mux.ServeHTTP runs mx.handler (middlewares + routeHTTP) on BOTH branches, and buildAuthedRequest forces the fresh-routing branch with a typed-nil RouteCtxKey. Every link is load-bearing and none was written down; this test is what notices if one changes. The counterfactual was worth more than the confirmation. Unfixed, an MCP agent that put an invalid byte in a ref got upstream_error on Postgres — whose hint says the failure is "usually transient, retry" — for an input that can never succeed. An agent obeying that hint retries forever. That is the retry-hostile misclassification family BUG-2675 added a code for, and this change removes an instance of it that nobody had noticed. Now validation_failed: the agent is told its INPUT is wrong. The first version of this test named upstream_error in its comment while running on SQLite, where unfixed gives item_not_found instead — an assertion that would have failed for a reason other than the one it named. The comment now states both backends separately and the fixture takes Postgres when PAD_TEST_POSTGRES_URL is set, so under make test-pg the dangerous half is what actually runs. Control leg included: a valid-but-absent ref must still return item_not_found, or a dispatcher that refused every ref would pass. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * chore(mcp): remove a throwaway probe that was committed by accident (BUG-2782) The probe that established the MCP seam behaviour was meant to be deleted once dispatch_http_invalid_path_test.go replaced it. The 'rm' was written as the first half of a compound command whose second half the tool layer REJECTED, so the whole command never executed — and a later 'git add -A' swept the file in. It duplicates the real test with printf-style output and no assertions. The rule this breaks is one I already hold: verify the mutation, not the report of it. I read 'rm -f X && cat > Y' as having removed X because I wrote it, when the command never ran at all. A rejected command and a successful one look identical in a transcript if you do not look. Caught by a Codex file listing showing an A for a file I believed gone. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * test: five corrections from the final review pass (BUG-2782) Codex round 5, judging the whole change. All five are mine; none needed a behaviour change. **A flake I built in.** The rate-limit test flooded 80 requests against a bucket of burst 60 — but a token bucket REFILLS while the loop runs, at 10/s here, so 20 tokens of headroom is 2 seconds of tolerance and a slow or -race'd run would admit all 80 and fail spuriously. The margin that matters is not flood-vs-burst but how long the loop must take for refill to cover the excess. At 400 requests that is (400-60)/10 = 34 seconds against in-process calls measured in microseconds: four orders of magnitude. The constant now carries that derivation, including the rate and burst it depends on. Both mutants still land, and metering the rejection still fails at request 61 — burst 60 + 1, unchanged by the larger flood. **A claim about MCP that JSON does not support.** The seam test's comment said an agent could put "a raw invalid byte" into a ref. Measured with encoding/json instead of assumed: raw 0xff / lone surrogate / truncated sequence → U+FFFD, valid UTF-8 raw 0x00 → JSON parse error the u0000 ESCAPE → a real NUL So exactly one of the five cases is reachable end to end over a JSON transport, and it is the one only the NUL half of validPathText refuses. The raw-byte cases stay — Dispatch is a Go API and the JSON decode is upstream of that boundary, so they assert the seam holds for callers that do not launder their strings through encoding/json — but the comment no longer offers them as evidence a JSON client can send them. **Two prose overstatements the earlier sweep missed.** The control test still said the rule rejects "only what the database rejects", which the previous commit had already established is false in the permissive direction. And TestValidatePathPostgresNoInternalError was described as reproducing the original 500 when it runs the FIXED server and can only ever observe a 400; the 500 lives in the counterfactual sweep and in the mutation matrix, and a test cannot both apply a fix and witness the bug. **One dead construction**, plus a smaller instance of the same habit: the MCP fixture built a SQLite store and discarded it in Postgres mode. My first attempt replaced the comment with one claiming the branch had been hoisted, and left the code as it was — writing the fix into the prose instead of the code, in the same hour I committed a message about not doing exactly that. Now actually branched. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * docs+test: seven more corrections, and one the emoji route earns (BUG-2782) Codex round 6, reading the three files as they now stand. All prose or assertion strength; one of them changes what the tests cover. **Pad DOES emit a non-ASCII path segment, and I said it never does.** `DELETE /workspaces/{ws}/comments/{commentID}/reactions/{emoji}` — the web client sends `encodeURIComponent(emoji)`. So the justification I gave for "it cannot refuse Pad's own URLs" was false in its premise while true in its conclusion, which is the worst combination: a reader checking the premise finds a counterexample and has no reason to trust the rest. It is also the best possible illustration of why the rule permits valid non-ASCII, so the control test now drives that ACTUAL route with a real emoji rather than relying on an emoji-shaped item slug — the claim is true by construction instead of by careful wording. A new mutant confirms the leg discriminates: a rule that rejects all non-ASCII (the plausible wrong version, not the absurd one) is caught there. **"The handler answers 500" was universal and is not.** Handlers that collapse a resolution error into not-found already answer 404 — the timeline handler's `err != nil || item == nil` is the example. My own measured distribution said so; the sentence did not. **"Self-hosted installs never see it" was wrong about the axis.** The split is by BACKEND, not deployment: a SQLite install never sees it, any Postgres install does — Pad Cloud and a self-hoster on Postgres alike. **A stale cross-reference of my own making.** The previous commit corrected TestValidatePathPostgresNoInternalError's claim to reproduce the 500, and left the sentence POINTING at it still saying it does. Fixing a claim at one site and leaving its pointer false is the CONVE-23 case in miniature. **The MCP test asserted too little.** validation_failed is how the dispatcher classifies ANY 400, so the test could have passed on a mapper-level refusal without ValidatePath running at all. It now pins the middleware's own message, which rides through on the hint. **Two overstatements in the same file.** "Exactly one case is reachable" should be one input CLASS (two cases carry a NUL). And the raw-byte cases do not cover "the stdio path": local stdio MCP is ExecDispatcher, which shells out to the binary and never touches this in-process door. Scope now says HTTPHandlerDispatcher and says what it does not speak for. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * docs(server): two qualifications the file already owed itself (BUG-2782) Codex round 7. Two, both narrow — the review is converging (7 findings last round, 2 this one), and both are internal inconsistencies rather than new ground. "Any Postgres install does" contradicted a qualification made forty lines lower in the same file, where validPathText spells out that SQL_ASCII Postgres accepts these bytes. Now says a Postgres install whose database encoding is UTF8, notes that this is initdb's default, and points at the place the qualification lives so the two cannot drift apart again. validCursorID's paragraph still described the 500 in the present tense, though BUG-2774 fixed it — it is the behaviour the guard PREVENTS, not what the endpoint does. My first attempt at this appended "past tense throughout this paragraph" and left the following sentence in the present tense, which is annotating a problem instead of fixing it. Rewritten so the tense carries the meaning without a note telling the reader to read it differently. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X * docs(server): the SQLite half of the claim needed the same narrowing (BUG-2782) Codex round 8, one P3 and it is the mirror image of round 6's. I qualified "the handler answers 500" on the Postgres side and left the symmetric sentence — "the same request is a clean 404 there" — universal on the SQLite side, in the same paragraph. Not every request reaches a store resolution on either backend; a gate that answers first keeps its own status, and my own GET-only sweep recorded 102 x 404 alongside 5 x 403, 2 x 200, 1 x 401 and 1 x 503 on SQLite. Fixing one direction of a symmetric claim and leaving the other is a shape I have hit before and evidently do not catch by intention. The paragraph now says the divergence is in what happens once a value REACHES the store, which is the true and symmetric statement, with the distribution pasted. Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
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 validPathText.
|
|
{"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 TestValidPathText(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 := validPathText(c.in); got != c.want {
|
|
t.Errorf("validPathText(%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)
|
|
}
|
|
}
|