fix: item lists refuse a filter key that names no field (BUG-3480) (#1880)

* fix: item lists refuse a filter key that names no field (BUG-3480)

The item list endpoints read every query parameter they did not know as
a field filter, so a parameter the server never had (the web Library
page's `all`, WebMCP's `collection`) answered 200 with an empty list.
A filter key must now be declared by a schema in scope (the
collection's, or any visible collection's on the workspace-wide list),
or be stored by an item in scope, which is honoured and named in the
X-Pad-Undeclared-Filter-Keys header (printed to stderr by the CLI).
Otherwise 400 validation_error, "invalid list filter ...". A key that
is not a field key is refused too; the store used to drop it and
answer the unfiltered list. WebMCP pad_item:list now lists a
collection through its path. ToolSurfaceVersion 0.73.

Co-Authored-By: Claude <noreply@anthropic.com>

* test: type the listByCollection mock so svelte-check can index its calls (BUG-3480)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
xarmian
2026-10-08 10:43:33 -04:00
committed by GitHub
parent 7b41a0298e
commit ee288cbebd
18 changed files with 451 additions and 47 deletions
+4 -4
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -390,7 +390,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
the per-user clients (including Codex) and skip it.
**Tool catalog (v0.74)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
**Tool catalog (v0.75)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
| Tool | Actions |
|---|---|
@@ -414,7 +414,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
and `pad://_meta/version`.
**Stability contract.** The catalog is versioned
(`tool_surface_version: "0.74"`) and advertised in the initialize handshake and at
(`tool_surface_version: "0.75"`) and advertised in the initialize handshake and at
`pad://_meta/version`, so external agents can pin against it; the per-version
changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors
come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a
+10 -2
View File
@@ -552,6 +552,7 @@ Examples:
}
var items []models.Item
var undeclared []string
var err error
if len(args) > 0 {
@@ -559,14 +560,21 @@ Examples:
// by its singular alias (BUG-2630); the fallback preserves
// shorthand against pre-resolver servers (BUG-2578).
items, err = cli.WithCollectionAliasFallback(args[0], client.CollectionNotFoundIsAuthoritative, func(slug string) ([]models.Item, error) {
return client.ListCollectionItems(ws, slug, params)
got, keys, lerr := client.ListCollectionItemsReport(ws, slug, params)
undeclared = keys
return got, lerr
})
} else {
items, err = client.ListItems(ws, params)
items, undeclared, err = client.ListItemsReport(ws, params)
}
if err != nil {
return err
}
// Stderr, like the write warnings, so --format json stays
// parseable (BUG-3480).
if len(undeclared) > 0 {
fmt.Fprintf(os.Stderr, "warning: filtered on %s, which no schema declares (matched against the value items store)\n", strings.Join(undeclared, ", "))
}
if formatFlag == "json" {
// Default to the token-light summary shape (drops the rich
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+37 -5
View File
@@ -253,12 +253,29 @@ func (c *Client) RestoreCollection(wsSlug, collRef string) (*models.Collection,
// ListItems returns items across all collections in a workspace.
// Use params for filtering, sorting, grouping, pagination, etc.
func (c *Client) ListItems(wsSlug string, params url.Values) ([]models.Item, error) {
items, _, err := c.ListItemsReport(wsSlug, params)
return items, err
}
// ListItemsReport is ListItems plus the filter keys the server honoured
// although no schema declares them (BUG-3480's X-Pad-Undeclared-Filter-Keys),
// for a caller that warns about them.
func (c *Client) ListItemsReport(wsSlug string, params url.Values) ([]models.Item, []string, error) {
var result []models.Item
path := "/workspaces/" + wsSlug + "/items"
if len(params) > 0 {
path += "?" + params.Encode()
}
return result, c.get(path, &result)
h, err := c.getWithHeader(path, &result)
return result, undeclaredFilterKeys(h), err
}
func undeclaredFilterKeys(h http.Header) []string {
v := h.Get("X-Pad-Undeclared-Filter-Keys")
if v == "" {
return nil
}
return strings.Split(v, ",")
}
// ListTags returns the distinct tags used across a workspace's items with
@@ -270,12 +287,20 @@ func (c *Client) ListTags(wsSlug string) ([]models.TagCount, error) {
// ListCollectionItems returns items within a specific collection.
func (c *Client) ListCollectionItems(wsSlug, collSlug string, params url.Values) ([]models.Item, error) {
items, _, err := c.ListCollectionItemsReport(wsSlug, collSlug, params)
return items, err
}
// ListCollectionItemsReport is ListCollectionItems plus the undeclared filter
// keys the server honoured (see ListItemsReport).
func (c *Client) ListCollectionItemsReport(wsSlug, collSlug string, params url.Values) ([]models.Item, []string, error) {
var result []models.Item
path := "/workspaces/" + wsSlug + "/collections/" + collSlug + "/items"
if len(params) > 0 {
path += "?" + params.Encode()
}
return result, c.get(path, &result)
h, err := c.getWithHeader(path, &result)
return result, undeclaredFilterKeys(h), err
}
func (c *Client) CreateItem(wsSlug, collSlug string, input models.ItemCreate) (*models.Item, error) {
@@ -2357,16 +2382,23 @@ func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request,
}
func (c *Client) get(path string, result interface{}) error {
_, err := c.getWithHeader(path, result)
return err
}
// getWithHeader is get that also returns the response headers (nil when no
// response arrived).
func (c *Client) getWithHeader(path string, result interface{}) (http.Header, error) {
req, err := c.newRequest("GET", path, nil)
if err != nil {
return err
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
return c.handleResponse(resp, result)
return resp.Header, c.handleResponse(resp, result)
}
func (c *Client) post(path string, body interface{}, result interface{}) error {
@@ -0,0 +1,45 @@
package mcp
import (
"context"
"errors"
"net/http"
"testing"
)
// BUG-3480: an item list filter on a key no schema declares (and no item
// stores) is now a 400. Stdio classifies the CLI's stderr prose, so the
// refusal must read as validation there too, or an agent is told to retry a
// deterministic refusal. The server's messages start "invalid list filter";
// internal/server's TestBUG3480_ListFilterKeys pins that wording.
func TestBUG3480_ListFilterRefusalClassifiesAsValidation(t *testing.T) {
cases := []struct{ stderr, body string }{
{
`Error: invalid list filter "all": it is not a list parameter, collection "conventions" declares no such field, and no item stores one`,
`{"error":{"code":"validation_error","message":"invalid list filter \"all\": it is not a list parameter, collection \"conventions\" declares no such field, and no item stores one"}}`,
},
{
`Error: invalid list filter "bad.key": a field key may hold only letters, digits, '_' and '-'`,
`{"error":{"code":"validation_error","message":"invalid list filter \"bad.key\": a field key may hold only letters, digits, '_' and '-'"}}`,
},
}
for _, c := range cases {
stdio := classifyExecError(context.Background(), []string{"item", "list"}, errors.New("exit 1"), c.stderr, nil)
if env, ok := stdio.StructuredContent.(ErrorEnvelope); !ok || env.Error.Code != ErrValidationFailed {
t.Errorf("stdio %q: got %+v, want %q", c.stderr, stdio.StructuredContent, ErrValidationFailed)
}
remote := classifyHTTPStatus(context.Background(), "item list", http.StatusBadRequest, []byte(c.body), nil)
if env, ok := remote.StructuredContent.(ErrorEnvelope); !ok || env.Error.Code != ErrValidationFailed {
t.Errorf("remote %q: got %+v, want %q", c.body, remote.StructuredContent, ErrValidationFailed)
}
}
// Control: the wording first drafted for this refusal matches no stdio
// pattern and lands as the retryable server_error, which is why the
// messages lead with "invalid".
const drafted = `Error: unknown list filter "all": it is not a list parameter, collection "conventions" declares no such field, and no item stores one`
stdio := classifyExecError(context.Background(), []string{"item", "list"}, errors.New("exit 1"), drafted, nil)
if env, ok := stdio.StructuredContent.(ErrorEnvelope); !ok || env.Error.Code != ErrServerError {
t.Errorf("control: got %+v, want %q", stdio.StructuredContent, ErrServerError)
}
}
File diff suppressed because one or more lines are too long
+16 -1
View File
@@ -1738,6 +1738,21 @@ const CmdhelpVersion = "0.1"
// caller cannot read embeds nothing; a failed fetch stays non-fatal,
// as on stdio. Stdio is unchanged.
//
// 0.75 — BUG-3480 (lead ruling). BEHAVIOR bump on the v0.43 / v0.29
// grounds: `pad_item.action=list` on both transports REFUSES a filter
// key (a `field` entry, or `priority` on a collection without one)
// that no schema in scope declares and no item in scope stores, with
// 400 / `validation_failed` and a message starting "invalid list
// filter". It used to answer an empty list, indistinguishable from "no
// rows match". A key that is not a field key (only letters, digits,
// `_`, `-`) is refused too; the store used to DROP it and answer the
// unfiltered list. Scope: on a collection list, that collection's
// schema; on the workspace-wide list, any collection the caller can
// see. A key no schema declares but items in scope store is still
// honoured (the BUG-2850 census), and the REST response names it in
// `X-Pad-Undeclared-Filter-Keys`, which the CLI prints to stderr; the
// MCP result does not carry it. No name, enum or param moved.
//
// 0.74 — TASK-2188 (lead ruling). ADDITIVE bump on the v0.67 grounds
// (an omitempty warning on a write result): `pad_collection.action=update`
// answers, on both transports, with `warnings.orphaned`
@@ -1872,7 +1887,7 @@ const CmdhelpVersion = "0.1"
// this surface can receive it; the entry exists so a future action does
// not collapse it to permission_denied. When an action that can reach
// it is added, that addition is the contract change and owns the bump.
const ToolSurfaceVersion = "0.74"
const ToolSurfaceVersion = "0.75"
// MetaVersionURI is the canonical URI of the queryable version document.
// Lives outside the pad://workspace/{ws}/... namespace because it's a
+14 -9
View File
@@ -1836,15 +1836,20 @@ type ItemListParams struct {
// one producer of an OR from caller input is parseItemListParams, which
// lowers a `?key=a,b` query value here to keep the documented list
// contract; everything else asks for an OR by name.
FieldsAnyOf map[string][]string
Sort string // e.g. "priority:desc,created_at:asc"
GroupBy string
Search string // FTS query
ParentID string
Tag string
AssignedUserID string // filter by assigned user
AgentRoleID string // filter by agent role (ID or slug)
ParentLinkID string // filter by parent link (item ID of the parent)
FieldsAnyOf map[string][]string
// FieldKeyPresent keeps only items whose fields store this key with a
// non-null value. Used by the list handlers' check of an undeclared
// filter key (BUG-3480), through ListItems so it shares the
// scope/permission clauses.
FieldKeyPresent string
Sort string // e.g. "priority:desc,created_at:asc"
GroupBy string
Search string // FTS query
ParentID string
Tag string
AssignedUserID string // filter by assigned user
AgentRoleID string // filter by agent role (ID or slug)
ParentLinkID string // filter by parent link (item ID of the parent)
// Unparented keeps only items with neither the legacy parent_id column nor
// an outgoing parent/implements item_link. Incoming links do not count.
Unparented bool
@@ -0,0 +1,107 @@
package server
import (
"net/http"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3480: the item list endpoints read every query parameter they do not
// know as a field filter, so a parameter the server never had (the web
// Library page's `all`, WebMCP's `collection`) answered 200 with an empty list
// and nothing said why. A filter key must now name a field a schema in scope
// declares, or one items in scope store (honoured, and named in a header).
func bug3480List(t *testing.T, srv *Server, path string) (int, []models.Item, string, string) {
t.Helper()
rr := doRequest(srv, "GET", path, nil)
var items []models.Item
if rr.Code == http.StatusOK {
parseJSON(t, rr, &items)
}
return rr.Code, items, rr.Header().Get(undeclaredFilterKeysHeader), rr.Body.String()
}
func TestBUG3480_ListFilterKeys(t *testing.T) {
bothBackends(t, func(t *testing.T, srv *Server) {
slug, wsID := task3462Workspace(t, srv, "Filters 3480", "startup")
tasks, err := srv.store.GetCollectionBySlug(wsID, "tasks")
if err != nil || tasks == nil {
t.Fatalf("tasks collection: %v", err)
}
// A key no schema declares, stored on one task (what the BUG-2850
// census found in live data). The store does not validate fields.
legacy, err := srv.store.CreateItem(wsID, tasks.ID, models.ItemCreate{Title: "Carries a legacy key", Fields: `{"status":"open","legacy_key":"x"}`})
if err != nil {
t.Fatal(err)
}
if _, err := srv.store.CreateItem(wsID, tasks.ID, models.ItemCreate{Title: "High task", Fields: `{"status":"open","priority":"high"}`}); err != nil {
t.Fatal(err)
}
coll := "/api/v1/workspaces/" + slug + "/collections/"
ws := "/api/v1/workspaces/" + slug + "/items"
refused := func(path, key string) {
t.Helper()
code, _, _, body := bug3480List(t, srv, path)
if code != http.StatusBadRequest || !strings.Contains(body, "validation_error") ||
!strings.Contains(body, `invalid list filter \"`+key+`\"`) {
t.Errorf("%s: got %d %s, want 400 validation_error naming %q", path, code, body, key)
}
}
// The live victims: a parameter the server never had.
refused(coll+"conventions/items?all=true", "all")
refused(ws+"?collection=tasks", "collection")
// A key that is not a field key used to be DROPPED by the store, which
// answered the unfiltered list.
refused(coll+"tasks/items?bad.key=1", "bad.key")
// A schema may DECLARE a key the store cannot filter on (no charset
// rule on schema keys), and the store drops such a filter, answering
// the unfiltered list. Refused here before it gets that far.
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections", map[string]any{
"name": "Odd 3480",
"schema": `{"fields":[{"key":"odd.key","label":"Odd","type":"text"}]}`,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create odd collection: %d %s", rr.Code, rr.Body.String())
}
var odd models.Collection
parseJSON(t, rr, &odd)
if _, err := srv.store.CreateItem(wsID, odd.ID, models.ItemCreate{Title: "Odd one", Fields: `{"odd.key":"y"}`}); err != nil {
t.Fatal(err)
}
refused(coll+odd.Slug+"/items?odd.key=nope", "odd.key")
// Declared by tasks, not by ideas: the collection list is scoped to its
// own schema; the workspace list accepts it.
refused(coll+"ideas/items?priority=high", "priority")
if code, items, hdr, body := bug3480List(t, srv, ws+"?priority=high"); code != http.StatusOK || len(items) != 1 || hdr != "" {
t.Errorf("workspace ?priority=high: %d, %d items, header %q: %s", code, len(items), hdr, body)
}
// A declared field filters as before, comma OR included.
if code, items, hdr, body := bug3480List(t, srv, coll+"tasks/items?priority=high,low"); code != http.StatusOK || len(items) != 1 || hdr != "" {
t.Errorf("tasks ?priority=high,low: %d, %d items, header %q: %s", code, len(items), hdr, body)
}
// Stored but undeclared: honoured, and named in the header.
for _, path := range []string{coll + "tasks/items?legacy_key=x", ws + "?legacy_key=x"} {
code, items, hdr, body := bug3480List(t, srv, path)
if code != http.StatusOK || len(items) != 1 || items[0].ID != legacy.ID || hdr != "legacy_key" {
t.Errorf("%s: %d, %d items, header %q: %s", path, code, len(items), hdr, body)
}
}
// The stored check is scoped like the list: ideas stores no legacy_key.
refused(coll+"ideas/items?legacy_key=x", "legacy_key")
// Keys the handlers consume are not field filters.
if code, _, _, body := bug3480List(t, srv, coll+"tasks/items?parent="+legacy.Slug); code != http.StatusOK {
t.Errorf("?parent: %d %s", code, body)
}
if code, _, _, body := bug3480List(t, srv, ws+"?unparented=true&non_terminal=true&include_archived=false&limit=5&offset=0&sort=title:asc"); code != http.StatusOK {
t.Errorf("known params: %d %s", code, body)
}
})
}
+119 -1
View File
@@ -71,6 +71,34 @@ func (s *Server) handleListItems(w http.ResponseWriter, r *http.Request) {
params.ItemIDs = grantedItemIDs
}
// Every filter key must name a field (BUG-3480): here, a field some
// collection the caller can see declares.
if len(params.Fields) > 0 || len(params.FieldsAnyOf) > 0 {
colls, err := s.store.ListCollections(workspaceID)
if err != nil {
writeInternalError(w, err)
return
}
var schemas []models.CollectionSchema
for _, c := range colls {
if !isCollectionVisible(c.ID, visibleIDs) || c.Schema == "" {
continue
}
var sc models.CollectionSchema
if models.UnmarshalItemFieldSchema([]byte(c.Schema), &sc) == nil {
schemas = append(schemas, sc)
}
}
undeclared, err := s.checkListFilterKeys(workspaceID, params, schemas, "no collection in this workspace")
if err != nil {
writeListFilterKeyError(w, err)
return
}
if len(undeclared) > 0 {
w.Header().Set(undeclaredFilterKeysHeader, strings.Join(undeclared, ","))
}
}
result, err := s.store.ListItems(workspaceID, params)
if err != nil {
writeInternalError(w, err)
@@ -634,6 +662,16 @@ func (s *Server) handleListCollectionItems(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// Every filter key must name a field this collection declares
// (BUG-3480), or one its items store.
undeclared, err := s.checkListFilterKeys(workspaceID, params, []models.CollectionSchema{collSchema}, fmt.Sprintf("collection %q", coll.Slug))
if err != nil {
writeListFilterKeyError(w, err)
return
}
if len(undeclared) > 0 {
w.Header().Set(undeclaredFilterKeysHeader, strings.Join(undeclared, ","))
}
result, err := s.store.ListItems(workspaceID, params)
if err != nil {
@@ -4201,7 +4239,9 @@ func parseItemListParams(r *http.Request) models.ItemListParams {
params.NonTerminal = true
}
// Extract field filters: any query param that isn't a known param is a field filter.
// Extract field filters: any query param that isn't a known param is a field
// filter. The handlers then refuse one that names no field (BUG-3480,
// checkListFilterKeys).
knownParams := map[string]bool{
"sort": true, "group_by": true, "search": true, "parent_id": true,
"tag": true, "include_archived": true, "limit": true, "offset": true,
@@ -4282,6 +4322,84 @@ func validateUnparentedListRequest(r *http.Request, params models.ItemListParams
return nil
}
// undeclaredFilterKeysHeader names the list filter keys that no schema in
// scope declares but that items in scope store (BUG-3480). The list body is a
// bare array, so the warning rides a header, as the import warnings do.
const undeclaredFilterKeysHeader = "X-Pad-Undeclared-Filter-Keys"
// checkListFilterKeys refuses a field filter the list cannot honour (BUG-3480).
// parseItemListParams reads every query parameter it does not know as a
// field filter, so a parameter the server never had (the web Library page's
// `all`, WebMCP's `collection`) answered 200 with an empty list and nothing
// said why. A key must now be declared by a schema in scope (the collection's,
// or for the workspace-wide list any visible collection's). A key no schema
// declares is still honoured when items in scope store it (the BUG-2850
// census found undeclared keys in live use) and is returned so the caller can
// warn; otherwise the request is refused. Call it after resolveParentFilter
// has consumed parent/plan and with params' scope and permission members set.
func (s *Server) checkListFilterKeys(workspaceID string, params models.ItemListParams, schemas []models.CollectionSchema, where string) (undeclared []string, err error) {
var keys []string
for k := range params.Fields {
keys = append(keys, k)
}
for k := range params.FieldsAnyOf {
keys = append(keys, k)
}
if len(keys) == 0 {
return nil, nil
}
sort.Strings(keys)
var unknown []string
for _, k := range keys {
if !store.IsValidFieldKey(k) {
return nil, listFilterRefusal(fmt.Sprintf("invalid list filter %q: a field key may hold only letters, digits, '_' and '-'", k))
}
declared := false
for _, sc := range schemas {
if schemaHasField(sc, k) {
declared = true
break
}
}
if !declared {
unknown = append(unknown, k)
}
}
if len(unknown) == 0 {
return nil, nil
}
stored, err := s.store.StoredFieldKeys(workspaceID, params, unknown)
if err != nil {
return nil, err
}
for _, k := range unknown {
if !stored[k] {
return nil, listFilterRefusal(fmt.Sprintf("invalid list filter %q: it is not a list parameter, %s declares no such field, and no item stores one", k, where))
}
undeclared = append(undeclared, k)
}
return undeclared, nil
}
// listFilterRefusal is checkListFilterKeys' 400; any other error it returns
// is a store failure. Both messages start "invalid list filter": local stdio
// MCP derives the error code from the CLI's stderr prose, and "invalid" is
// what classifies it validation_failed rather than a retryable server_error.
type listFilterRefusal string
func (e listFilterRefusal) Error() string { return string(e) }
// writeListFilterKeyError answers checkListFilterKeys' error: a refusal is a
// 400, anything else a store failure.
func writeListFilterKeyError(w http.ResponseWriter, err error) {
var refusal listFilterRefusal
if errors.As(err, &refusal) {
writeError(w, http.StatusBadRequest, "validation_error", err.Error())
return
}
writeInternalError(w, err)
}
// handleListItemActivity returns the activity feed for a specific item.
func (s *Server) handleListItemActivity(w http.ResponseWriter, r *http.Request) {
workspaceID, ok := s.getWorkspaceID(w, r)
+28 -14
View File
@@ -135,24 +135,38 @@ func TestValidateQueryAllowsValidText(t *testing.T) {
for _, tc := range []struct {
name string
target string
// handlerRefuses: the middleware must let the request through, and the
// HANDLER refuses it for its own reason. Asserted by the error code,
// which says whose refusal it is.
handlerRefuses string
}{
{"no query at all", "/api/v1/workspaces/" + ws + "/items"},
{"empty query", "/api/v1/workspaces/" + ws + "/items?"},
{"plain ascii", "/api/v1/workspaces/" + ws + "/items?search=hello"},
{"escaped accented text", "/api/v1/workspaces/" + ws + "/items?search=caf%C3%A9"},
{"unescaped accented text", "/api/v1/workspaces/" + ws + "/items?search=café"},
{"escaped emoji", "/api/v1/workspaces/" + ws + "/items?search=%F0%9F%9A%80"},
{"plus as space", "/api/v1/workspaces/" + ws + "/items?search=two+words"},
{"escaped space", "/api/v1/workspaces/" + ws + "/items?search=two%20words"},
{"escaped ampersand in value", "/api/v1/workspaces/" + ws + "/items?search=a%26b"},
{"escaped equals in value", "/api/v1/workspaces/" + ws + "/items?search=a%3Db"},
{"empty value", "/api/v1/workspaces/" + ws + "/items?search="},
{"key with no equals", "/api/v1/workspaces/" + ws + "/items?search"},
{"repeated key", "/api/v1/workspaces/" + ws + "/items?search=a&search=b"},
{"non-ascii key", "/api/v1/workspaces/" + ws + "/items?caf%C3%A9=x"},
{"no query at all", "/api/v1/workspaces/" + ws + "/items", ""},
{"empty query", "/api/v1/workspaces/" + ws + "/items?", ""},
{"plain ascii", "/api/v1/workspaces/" + ws + "/items?search=hello", ""},
{"escaped accented text", "/api/v1/workspaces/" + ws + "/items?search=caf%C3%A9", ""},
{"unescaped accented text", "/api/v1/workspaces/" + ws + "/items?search=café", ""},
{"escaped emoji", "/api/v1/workspaces/" + ws + "/items?search=%F0%9F%9A%80", ""},
{"plus as space", "/api/v1/workspaces/" + ws + "/items?search=two+words", ""},
{"escaped space", "/api/v1/workspaces/" + ws + "/items?search=two%20words", ""},
{"escaped ampersand in value", "/api/v1/workspaces/" + ws + "/items?search=a%26b", ""},
{"escaped equals in value", "/api/v1/workspaces/" + ws + "/items?search=a%3Db", ""},
{"empty value", "/api/v1/workspaces/" + ws + "/items?search=", ""},
{"key with no equals", "/api/v1/workspaces/" + ws + "/items?search", ""},
{"repeated key", "/api/v1/workspaces/" + ws + "/items?search=a&search=b", ""},
// Valid UTF-8, so the middleware passes it; since BUG-3480 the list
// handler refuses a filter key that is not a field key (letters,
// digits, '_', '-'), which the store used to drop, answering the
// unfiltered list.
{"non-ascii key", "/api/v1/workspaces/" + ws + "/items?caf%C3%A9=x", "validation_error"},
} {
t.Run(tc.name, func(t *testing.T) {
rr := doRequest(srv, "GET", tc.target, nil)
if tc.handlerRefuses != "" {
if rr.Code != http.StatusBadRequest || pathErrorCode(t, rr) != tc.handlerRefuses {
t.Fatalf("GET %s: expected the handler's 400 %s, got %d: %s", tc.target, tc.handlerRefuses, rr.Code, rr.Body.String())
}
return
}
if rr.Code != http.StatusOK {
t.Fatalf("GET %s: expected 200, got %d: %s", tc.target, rr.Code, rr.Body.String())
}
@@ -213,6 +213,9 @@ func bindableText(s string) bool {
// parseItemListParams folds every parameter it does not recognise into
// a field filter (params.Fields, or params.FieldsAnyOf for a comma list), so `?email=`, `?type=` and `?anything-at-all=` become
// field filters and reach a text comparison exactly as `?search=` does.
// (Since BUG-3480 the list handlers refuse a key no schema in scope declares
// and no item stores, but that check reads the database for the key, AFTER
// this middleware, and the set it admits is still data-defined, not a list.)
// There is no finite list of points to validate, because the wildcard
// branch is what turns an undeclared name into a filter in the first
// place.
+33
View File
@@ -2337,9 +2337,42 @@ func (s *Store) appendFieldFilters(workspaceID string, params models.ItemListPar
}
query += " AND (" + strings.Join(conds, " OR ") + ")"
}
if key := params.FieldKeyPresent; key != "" && isValidFieldKey(key) {
query += " AND " + s.dialect.JSONExtractText("i.fields", key) + " IS NOT NULL"
}
return query, args
}
// StoredFieldKeys reports which of keys some item within scope stores in its
// fields (BUG-3480: a list filter on a key no schema declares is honoured,
// with a warning, only when items actually carry it). Only scope's
// collection scope, permission pair and include_archived are used, and each
// key is asked through ListItems, so the permission clauses are the list's own.
func (s *Store) StoredFieldKeys(workspaceID string, scope models.ItemListParams, keys []string) (map[string]bool, error) {
out := make(map[string]bool, len(keys))
for _, key := range keys {
if !isValidFieldKey(key) {
continue
}
found, err := s.ListItems(workspaceID, models.ItemListParams{
ScopeCollectionID: scope.ScopeCollectionID,
CollectionIDs: scope.CollectionIDs,
ItemIDs: scope.ItemIDs,
IncludeArchived: scope.IncludeArchived,
FieldKeyPresent: key,
NoContent: true,
Limit: 1,
})
if err != nil {
return nil, err
}
if len(found) > 0 {
out[key] = true
}
}
return out, nil
}
// scalarRelationFilterKeys returns the scalar `relation` keys of the collection
// a list is scoped to, for the BUG-3028 empty-value filter. Nil when the list
// is not scoped to one collection (a key can be a relation in one collection
+5
View File
@@ -1242,6 +1242,11 @@ func isAlpha(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_'
}
// IsValidFieldKey reports whether key is a field name the store will filter
// on; a list filter key that is not one is refused (BUG-3480) rather than
// dropped, which used to answer the unfiltered list.
func IsValidFieldKey(key string) bool { return isValidFieldKey(key) }
// isValidFieldKey checks that a field name contains only safe characters
// (alphanumeric, underscore, hyphen). This prevents SQL injection when
// field keys from user input are interpolated into JSON path expressions.
+9
View File
@@ -9,6 +9,7 @@ function mockApi() {
search: vi.fn(async () => ({ results: [] })),
items: {
list: vi.fn(async () => []),
listByCollection: vi.fn(async (_ws: string, _coll: string, _params?: Record<string, unknown>) => []),
get: vi.fn(async () => ({ id: 'uuid-target', ref: 'TASK-1' })),
backlinks: vi.fn(async () => []),
create: vi.fn(async () => ({ ref: 'TASK-9' })),
@@ -132,6 +133,14 @@ describe('dispatch — wsSlug injection (DR-4)', () => {
expect(api.items.list).toHaveBeenCalledWith(WS, expect.objectContaining({ status: 'open' }));
});
it('lists one collection through its path, never a ?collection= filter (BUG-3480)', async () => {
const api = mockApi();
await run(api, 'pad_item', { action: 'list', collection: 'bugs', status: 'open' });
expect(api.items.listByCollection).toHaveBeenCalledWith(WS, 'bugs', expect.objectContaining({ status: 'open' }));
expect(api.items.listByCollection.mock.calls[0][2]).not.toHaveProperty('collection');
expect(api.items.list).not.toHaveBeenCalled();
});
it('injects wsSlug into search filters', async () => {
const api = mockApi();
await run(api, 'pad_search', { action: 'query', query: 'hello' });
+12 -4
View File
@@ -412,15 +412,23 @@ const HANDLERS: Record<string, Handler> = {
}),
// ── pad_item reads ──
'pad_item:list': (api, ws, args) =>
api.items.list(ws, {
collection: str(args, 'collection'),
// A collection is a PATH segment, never a query parameter (BUG-3480): the
// workspace-wide list read `?collection=` as a filter on a field nobody
// declares and answered [] for every collection-scoped call; it now
// refuses the parameter outright.
'pad_item:list': (api, ws, args) => {
const filters = {
status: str(args, 'status'),
priority: str(args, 'priority'),
parent: str(args, 'parent'),
tag: str(args, 'tag'),
limit: num(args, 'limit'),
}),
};
const collection = str(args, 'collection');
return collection
? api.items.listByCollection(ws, collection, filters)
: api.items.list(ws, filters);
},
'pad_item:get': (api, ws, args) => api.items.get(ws, requireRef(args)),
'pad_item:deps': (api, ws, args) => api.links.list(ws, requireRef(args)),
'pad_item:list-comments': (api, ws, args) =>