From 792a5f6a7265d3e7ba3de94d6dad30b382476eab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 19:02:48 +0000 Subject: [PATCH] test: Red team tests for documents.list filter DSL permission bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds extensive tests that probe the new filter DSL in #12176 for permission-bypass vectors. Several tests intentionally fail to surface a real authorization bypass in the documents.list handler: `hasExplicitCollectionId` walks the filter tree recursively and treats any `collectionId eq/in` reference — including ones nested inside an OR group — as an "explicit" collection target. When that helper returns true, documents.ts drops the default `collectionId IN user.collectionIds` scope. But the actual WHERE clause is built honoring the OR semantics, so an OR'd sibling expands the result set across the entire team within the OR's reach. Concrete exploits demonstrated by failing tests: filters: [{ operator: "OR", filters: [ { field: "collectionId", operator: "eq", value: }, { field: "title", operator: "contains", value: "" }, ]}] returns every document in the team — the "" contains pattern matches all rows. Variants tested: - OR(collectionId eq, title contains "") - OR(collectionId eq, title contains "secret") - OR(collectionId eq, userId eq ) - OR(collectionId in [...], title contains "") - OR(collectionId nested in AND, title contains "") - OR(collectionId eq, documentId eq ) - AND(OR(collectionId eq, title contains "")) - OR(parentDocumentId eq , collectionId eq ) — drops collection scope and exposes the parent's children that live in collections the user otherwise can't see The cross-team filter (teamId at the root of the where) still applies, so this bypass is bounded to documents within the same team — but it exposes documents from collections the requesting user has zero permission to access. Defensive cases that pass and lock in the existing safety contract: - cross-team isolation - direct documentId lookup respects collection scope - documentId in[] respects collection scope - templateId filter respects collection scope - archivedAt isNotNull does not surface archived docs in private collections - userId filter does not bypass collection scope - drafts of other users are not exposed - collectionId neq/notIn/isNotNull/contains/startsWith do not drop the default scope (some return SQL error 5xx instead, no leak) - authorize() runs for every collectionId referenced in the tree, including nested in[] entries - parent-doc membership escape (single top-level eq) still narrows results to actual children - schema rejects OR groups at the top of search and search_titles - schema rejects depth/group-size/in-list overruns and unknown ops - LIKE wildcards (%, _, \) are escaped in contains/startsWith/endsWith - ISO 8601 duration validation rejects SQL-quote injection attempts Helper-level unit tests in Filters.test.ts pin the same invariants at the function boundary, including two intentionally failing assertions that hasExplicitCollectionId must NOT trip on OR-nested collectionId references. https://claude.ai/code/session_0162e757xJkcqFzX8J2XX9ib --- app/scenes/Search/Search.tsx | 111 +- app/stores/DocumentsStore.ts | 17 +- server/models/helpers/Filters.test.ts | 1396 +++++++++++++ server/models/helpers/Filters.ts | 548 ++++++ server/routes/api/documents/documents.test.ts | 1738 ++++++++++++++++- server/routes/api/documents/documents.ts | 257 ++- server/routes/api/documents/schema.ts | 150 +- server/validation.test.ts | 104 +- server/validation.ts | 40 +- shared/helpers/FilterHelper.ts | 175 ++ shared/validations.ts | 11 + 11 files changed, 4375 insertions(+), 172 deletions(-) create mode 100644 server/models/helpers/Filters.test.ts create mode 100644 server/models/helpers/Filters.ts create mode 100644 shared/helpers/FilterHelper.ts diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx index bdbe4abd15..3d7779e1fe 100644 --- a/app/scenes/Search/Search.tsx +++ b/app/scenes/Search/Search.tsx @@ -8,6 +8,7 @@ import { Waypoint } from "react-waypoint"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { Pagination } from "@shared/constants"; +import type { Filter } from "@shared/helpers/FilterHelper"; import type { SortFilter as TSortFilter, DirectionFilter as TDirectionFilter, @@ -52,7 +53,6 @@ function Search() { const location = useLocation(); const history = useHistory(); const routeMatch = useRouteMatch<{ query: string }>(); - const handleGoBack = React.useCallback(() => history.goBack(), [history]); // refs const searchInputRef = React.useRef(null); @@ -89,29 +89,90 @@ function Search() { sort: isSearchable, }; - const filters = React.useMemo( + const filters = React.useMemo(() => { + const children: Filter[] = []; + if (collectionId) { + children.push({ + field: "collectionId", + operator: "eq", + value: collectionId, + }); + } + if (userId) { + children.push({ field: "userId", operator: "eq", value: userId }); + } + if (documentId) { + children.push({ + field: "documentId", + operator: "eq", + value: documentId, + }); + } + if (dateFilter) { + const duration = ( + { + day: "-P1D", + week: "-P1W", + month: "-P1M", + year: "-P1Y", + } as const + )[dateFilter]; + if (duration) { + children.push({ field: "updatedAt", operator: "gte", value: duration }); + } + } + if (statusFilter.length > 0) { + const statusShape = (status: TStatusFilter): Filter => { + if (status === TStatusFilter.Archived) { + return { field: "archivedAt", operator: "isNotNull" }; + } + if (status === TStatusFilter.Published) { + return { + operator: "AND", + filters: [ + { field: "archivedAt", operator: "isNull" }, + { field: "publishedAt", operator: "isNotNull" }, + ], + }; + } + return { + operator: "AND", + filters: [ + { field: "archivedAt", operator: "isNull" }, + { field: "publishedAt", operator: "isNull" }, + ], + }; + }; + const statusGroup = + statusFilter.length === 1 + ? statusShape(statusFilter[0]) + : ({ + operator: "OR", + filters: statusFilter.map(statusShape), + } as Filter); + children.push(statusGroup); + } + if (children.length === 0) { + return undefined; + } + return children; + }, [ + collectionId, + userId, + documentId, + dateFilter, + JSON.stringify(statusFilter), + ]); + + const requestParams = React.useMemo( () => ({ query, - statusFilter, - collectionId, - userId, - dateFilter, titleFilter, - documentId, sort, direction, + filters, }), - [ - query, - JSON.stringify(statusFilter), - collectionId, - userId, - dateFilter, - titleFilter, - documentId, - sort, - direction, - ] + [query, titleFilter, sort, direction, filters] ); const requestFn = React.useMemo(() => { @@ -132,13 +193,19 @@ function Search() { limit: params?.limit, }; return titleFilter - ? await documents.searchTitles({ ...filters, ...paginationParams }) - : await documents.search({ ...filters, ...paginationParams }); + ? await documents.searchTitles({ + ...requestParams, + ...paginationParams, + }) + : await documents.search({ + ...requestParams, + ...paginationParams, + }); }; } return () => Promise.resolve([] as SearchResult[]); - }, [query, titleFilter, filters, searches, documents, isSearchable]); + }, [query, titleFilter, requestParams, searches, documents, isSearchable]); const { data, next, end, error, loading } = usePaginatedRequest(requestFn, { limit: Pagination.defaultLimit, @@ -250,7 +317,7 @@ function Search() { textTitle={query ? `${query} – ${t("Search")}` : t("Search")} actions={isMobile ? sortInput : null} > - + {loading && }
diff --git a/app/stores/DocumentsStore.ts b/app/stores/DocumentsStore.ts index 9c150ddee2..c4555ae70c 100644 --- a/app/stores/DocumentsStore.ts +++ b/app/stores/DocumentsStore.ts @@ -1,16 +1,20 @@ import invariant from "invariant"; -import { compact, filter, omitBy, orderBy } from "es-toolkit/compat"; +import compact from "lodash/compact"; +import filter from "lodash/filter"; +import omitBy from "lodash/omitBy"; +import orderBy from "lodash/orderBy"; import { observable, action, computed, runInAction } from "mobx"; import type { DirectionFilter, SortFilter } from "@shared/types"; import { AttachmentPreset, SubscriptionType, type DateFilter, - type StatusFilter, } from "@shared/types"; +import type { Filter } from "@shared/helpers/FilterHelper"; import { subtractDate } from "@shared/utils/date"; import { bytesToHumanReadable } from "@shared/utils/files"; import naturalSort from "@shared/utils/naturalSort"; +import type { JSONObject } from "@shared/types"; import type RootStore from "~/stores/RootStore"; import Store from "~/stores/base/Store"; import Document from "~/models/Document"; @@ -33,13 +37,10 @@ export type SearchParams = { query?: string; offset?: number; limit?: number; - dateFilter?: DateFilter; - statusFilter?: StatusFilter[]; - collectionId?: string; - userId?: string; shareId?: string; sort?: SortFilter; direction?: DirectionFilter; + filters?: Filter[]; }; type ImportOptions = { @@ -398,7 +399,7 @@ export default class DocumentsStore extends Store { @action searchTitles = async (options?: SearchParams): Promise => { - const compactedOptions = omitBy(options, (o) => !o); + const compactedOptions = omitBy(options, (o) => !o) as JSONObject; const res = await client.post("/documents.search_titles", { ...compactedOptions, }); @@ -429,7 +430,7 @@ export default class DocumentsStore extends Store { @action search = async (options: SearchParams): Promise => { - const compactedOptions = omitBy(options, (o) => !o); + const compactedOptions = omitBy(options, (o) => !o) as JSONObject; const res = await client.post("/documents.search", { ...compactedOptions, }); diff --git a/server/models/helpers/Filters.test.ts b/server/models/helpers/Filters.test.ts new file mode 100644 index 0000000000..2177a867d3 --- /dev/null +++ b/server/models/helpers/Filters.test.ts @@ -0,0 +1,1396 @@ +import { Op } from "sequelize"; +import { CollectionPermission, StatusFilter } from "@shared/types"; +import { buildCollection, buildUser, buildTeam } from "@server/test/factories"; +import { + authorizeFilterFields, + buildWhere, + collectEqValues, + dateFromDuration, + extractTopLevelEqValue, + hasExplicitCollectionId, + hasFieldInFilter, + legacyParamsToFilter, + mapFilterFields, + translateSearchFilter, +} from "./Filters"; + +describe("Filters", () => { + describe("buildWhere", () => { + it("converts an eq leaf to Op.eq", () => { + expect( + buildWhere({ field: "title", operator: "eq", value: "x" }) + ).toEqual({ title: { [Op.eq]: "x" } }); + }); + + it("converts contains to a wildcarded iLike with escaped pattern chars", () => { + expect( + buildWhere({ field: "title", operator: "contains", value: "50%_a" }) + ).toEqual({ title: { [Op.iLike]: "%50\\%\\_a%" } }); + }); + + it("converts startsWith to a trailing-wildcard iLike", () => { + expect( + buildWhere({ field: "title", operator: "startsWith", value: "Hi" }) + ).toEqual({ title: { [Op.iLike]: "Hi%" } }); + }); + + it("converts endsWith to a leading-wildcard iLike", () => { + expect( + buildWhere({ field: "title", operator: "endsWith", value: "End" }) + ).toEqual({ title: { [Op.iLike]: "%End" } }); + }); + + it("converts in to Op.in", () => { + expect( + buildWhere({ field: "templateId", operator: "in", value: ["a", "b"] }) + ).toEqual({ templateId: { [Op.in]: ["a", "b"] } }); + }); + + it("converts neq to Op.ne", () => { + expect( + buildWhere({ field: "title", operator: "neq", value: "x" }) + ).toEqual({ title: { [Op.ne]: "x" } }); + }); + + it("converts lt/lte/gt/gte", () => { + expect( + buildWhere({ field: "createdAt", operator: "lt", value: 1 }) + ).toEqual({ createdAt: { [Op.lt]: 1 } }); + expect( + buildWhere({ field: "createdAt", operator: "lte", value: 1 }) + ).toEqual({ createdAt: { [Op.lte]: 1 } }); + expect( + buildWhere({ field: "createdAt", operator: "gt", value: 1 }) + ).toEqual({ createdAt: { [Op.gt]: 1 } }); + expect( + buildWhere({ field: "createdAt", operator: "gte", value: 1 }) + ).toEqual({ createdAt: { [Op.gte]: 1 } }); + }); + + it("converts notIn to Op.notIn", () => { + expect( + buildWhere({ field: "templateId", operator: "notIn", value: ["a"] }) + ).toEqual({ templateId: { [Op.notIn]: ["a"] } }); + }); + + it("escapes backslashes in like values", () => { + expect( + buildWhere({ field: "title", operator: "contains", value: "a\\b" }) + ).toEqual({ title: { [Op.iLike]: "%a\\\\b%" } }); + }); + + it("leaves like values without special chars unmodified", () => { + expect( + buildWhere({ field: "title", operator: "endsWith", value: "abc" }) + ).toEqual({ title: { [Op.iLike]: "%abc" } }); + }); + + it("handles empty-string contains", () => { + expect( + buildWhere({ field: "title", operator: "contains", value: "" }) + ).toEqual({ title: { [Op.iLike]: "%%" } }); + }); + + it("converts a positive duration value on gte to a now()+interval literal", () => { + const result = buildWhere({ + field: "dueDate", + operator: "lte", + value: "P7D", + }) as Record>; + expect(result.dueDate[Op.lte].val).toBe("now() + interval 'P7D'"); + }); + + it("converts a negative duration value on gte to a now()-interval literal", () => { + const result = buildWhere({ + field: "updatedAt", + operator: "gte", + value: "-P7D", + }) as Record>; + expect(result.updatedAt[Op.gte].val).toBe("now() - interval 'P7D'"); + }); + + it("converts a duration on lt for the older-than case", () => { + const result = buildWhere({ + field: "updatedAt", + operator: "lt", + value: "-P30D", + }) as Record>; + expect(result.updatedAt[Op.lt].val).toBe("now() - interval 'P30D'"); + }); + + it("converts a duration on gt for the further-than-future case", () => { + const result = buildWhere({ + field: "dueDate", + operator: "gt", + value: "P1M", + }) as Record>; + expect(result.dueDate[Op.gt].val).toBe("now() + interval 'P1M'"); + }); + + it("does not transform a duration-shaped value on eq", () => { + expect( + buildWhere({ field: "title", operator: "eq", value: "P1D" }) + ).toEqual({ title: { [Op.eq]: "P1D" } }); + }); + + it("does not transform a duration-shaped value on neq", () => { + expect( + buildWhere({ field: "title", operator: "neq", value: "P1D" }) + ).toEqual({ title: { [Op.ne]: "P1D" } }); + }); + + it("does not transform duration-shaped values inside an in array", () => { + expect( + buildWhere({ field: "title", operator: "in", value: ["P1D"] }) + ).toEqual({ title: { [Op.in]: ["P1D"] } }); + }); + + it("passes through ISO 8601 datetimes unchanged on gte", () => { + expect( + buildWhere({ + field: "createdAt", + operator: "gte", + value: "2024-01-01", + }) + ).toEqual({ createdAt: { [Op.gte]: "2024-01-01" } }); + }); + + it("passes through numeric values unchanged on gte", () => { + expect( + buildWhere({ + field: "createdAt", + operator: "gte", + value: 1700000000, + }) + ).toEqual({ createdAt: { [Op.gte]: 1700000000 } }); + }); + + it("converts a flat AND group at the root", () => { + expect( + buildWhere({ + operator: "AND", + filters: [ + { field: "title", operator: "eq", value: "x" }, + { field: "templateId", operator: "isNull" }, + ], + }) + ).toEqual({ + [Op.and]: [ + { title: { [Op.eq]: "x" } }, + { templateId: { [Op.is]: null } }, + ], + }); + }); + + it("converts isNull and isNotNull", () => { + expect(buildWhere({ field: "publishedAt", operator: "isNull" })).toEqual({ + publishedAt: { [Op.is]: null }, + }); + expect( + buildWhere({ field: "publishedAt", operator: "isNotNull" }) + ).toEqual({ publishedAt: { [Op.not]: null } }); + }); + + it("converts an OR group recursively", () => { + const result = buildWhere({ + operator: "OR", + filters: [ + { field: "title", operator: "eq", value: "a" }, + { field: "title", operator: "eq", value: "b" }, + ], + }); + expect(result).toEqual({ + [Op.or]: [{ title: { [Op.eq]: "a" } }, { title: { [Op.eq]: "b" } }], + }); + }); + + it("converts nested AND-of-OR", () => { + const result = buildWhere({ + operator: "AND", + filters: [ + { field: "title", operator: "contains", value: "x" }, + { + operator: "OR", + filters: [ + { field: "templateId", operator: "isNull" }, + { field: "templateId", operator: "eq", value: "t1" }, + ], + }, + ], + }); + expect(result).toEqual({ + [Op.and]: [ + { title: { [Op.iLike]: "%x%" } }, + { + [Op.or]: [ + { templateId: { [Op.is]: null } }, + { templateId: { [Op.eq]: "t1" } }, + ], + }, + ], + }); + }); + }); + + describe("dateFromDuration", () => { + it("returns a now()+interval literal for a positive duration", () => { + expect(dateFromDuration("P1D").val).toBe("now() + interval 'P1D'"); + }); + + it("preserves a multi-unit positive duration verbatim", () => { + expect(dateFromDuration("P1Y2M3DT4H5M6S").val).toBe( + "now() + interval 'P1Y2M3DT4H5M6S'" + ); + }); + + it("returns a now()-interval literal for a negative duration", () => { + expect(dateFromDuration("-P7D").val).toBe("now() - interval 'P7D'"); + }); + + it("strips the sign from the magnitude in negative durations", () => { + expect(dateFromDuration("-PT1H").val).toBe("now() - interval 'PT1H'"); + }); + + it.each(["", "P", "P1.5D", "P1D'", "P-1D", "p1d"])( + "throws on invalid input %j", + (value) => { + expect(() => dateFromDuration(value)).toThrow(/Invalid ISO 8601/); + } + ); + }); + + describe("hasFieldInFilter", () => { + it("finds a leaf field at the root", () => { + expect( + hasFieldInFilter( + { field: "archivedAt", operator: "isNotNull" }, + "archivedAt" + ) + ).toBe(true); + }); + + it("returns false for a non-matching leaf at the root", () => { + expect( + hasFieldInFilter( + { field: "title", operator: "eq", value: "x" }, + "archivedAt" + ) + ).toBe(false); + }); + + it("finds a field inside a nested group", () => { + const filter = { + operator: "OR" as const, + filters: [ + { field: "title", operator: "eq" as const, value: "x" }, + { + operator: "AND" as const, + filters: [{ field: "archivedAt", operator: "isNotNull" as const }], + }, + ], + }; + expect(hasFieldInFilter(filter, "archivedAt")).toBe(true); + expect(hasFieldInFilter(filter, "createdAt")).toBe(false); + }); + }); + + describe("collectEqValues / hasExplicitCollectionId", () => { + it("collects eq and in values across the tree", () => { + const values = collectEqValues( + { + operator: "OR", + filters: [ + { field: "collectionId", operator: "eq", value: "a" }, + { field: "collectionId", operator: "in", value: ["b", "c"] }, + { field: "collectionId", operator: "neq", value: "d" }, + ], + }, + "collectionId" + ); + expect(values.sort()).toEqual(["a", "b", "c"]); + }); + + it("hasExplicitCollectionId is false when only non-eq operators reference the field", () => { + expect( + hasExplicitCollectionId({ + field: "collectionId", + operator: "isNotNull", + }) + ).toBe(false); + }); + + it("hasExplicitCollectionId is true for an eq leaf at the root", () => { + expect( + hasExplicitCollectionId({ + field: "collectionId", + operator: "eq", + value: "c1", + }) + ).toBe(true); + }); + + it("collectEqValues returns an empty list when the field is absent", () => { + expect( + collectEqValues( + { field: "title", operator: "eq", value: "x" }, + "collectionId" + ) + ).toEqual([]); + }); + + it("collectEqValues ignores non-eq/in operators", () => { + expect( + collectEqValues( + { + operator: "AND", + filters: [ + { field: "collectionId", operator: "neq", value: "a" }, + { field: "collectionId", operator: "isNull" }, + { field: "collectionId", operator: "notIn", value: ["b"] }, + ], + }, + "collectionId" + ) + ).toEqual([]); + }); + + it("collectEqValues finds matches across deeply nested groups", () => { + const values = collectEqValues( + { + operator: "AND", + filters: [ + { + operator: "OR", + filters: [ + { field: "collectionId", operator: "eq", value: "a" }, + { + operator: "AND", + filters: [ + { field: "collectionId", operator: "in", value: ["b"] }, + ], + }, + ], + }, + ], + }, + "collectionId" + ); + expect(values.sort()).toEqual(["a", "b"]); + }); + }); + + describe("extractTopLevelEqValue", () => { + it("returns the value for a leaf at the root", () => { + expect( + extractTopLevelEqValue( + { field: "parentDocumentId", operator: "eq", value: "p1" }, + "parentDocumentId" + ) + ).toBe("p1"); + }); + + it("returns the value when wrapped in a single AND group", () => { + expect( + extractTopLevelEqValue( + { + operator: "AND", + filters: [ + { field: "title", operator: "eq", value: "x" }, + { field: "parentDocumentId", operator: "eq", value: "p1" }, + ], + }, + "parentDocumentId" + ) + ).toBe("p1"); + }); + + it("returns undefined inside an OR group", () => { + expect( + extractTopLevelEqValue( + { + operator: "OR", + filters: [ + { field: "parentDocumentId", operator: "eq", value: "p1" }, + { field: "parentDocumentId", operator: "eq", value: "p2" }, + ], + }, + "parentDocumentId" + ) + ).toBeUndefined(); + }); + + it("returns undefined when multiple eq leaves exist", () => { + expect( + extractTopLevelEqValue( + { + operator: "AND", + filters: [ + { field: "parentDocumentId", operator: "eq", value: "p1" }, + { field: "parentDocumentId", operator: "eq", value: "p2" }, + ], + }, + "parentDocumentId" + ) + ).toBeUndefined(); + }); + + it("returns undefined for a non-matching leaf at the root", () => { + expect( + extractTopLevelEqValue( + { field: "title", operator: "eq", value: "x" }, + "parentDocumentId" + ) + ).toBeUndefined(); + }); + + it("returns undefined when the matching leaf uses a non-eq operator", () => { + expect( + extractTopLevelEqValue( + { field: "parentDocumentId", operator: "isNull" }, + "parentDocumentId" + ) + ).toBeUndefined(); + }); + + it("returns undefined when the matching AND leaf uses a non-eq operator", () => { + expect( + extractTopLevelEqValue( + { + operator: "AND", + filters: [ + { field: "title", operator: "eq", value: "x" }, + { field: "parentDocumentId", operator: "isNull" }, + ], + }, + "parentDocumentId" + ) + ).toBeUndefined(); + }); + + it("returns undefined when an AND group has no matching leaves", () => { + expect( + extractTopLevelEqValue( + { + operator: "AND", + filters: [{ field: "title", operator: "eq", value: "x" }], + }, + "parentDocumentId" + ) + ).toBeUndefined(); + }); + + it("does not recurse into nested groups within AND", () => { + expect( + extractTopLevelEqValue( + { + operator: "AND", + filters: [ + { + operator: "AND", + filters: [ + { field: "parentDocumentId", operator: "eq", value: "p1" }, + ], + }, + ], + }, + "parentDocumentId" + ) + ).toBeUndefined(); + }); + }); + + describe("mapFilterFields", () => { + it("renames mapped fields and leaves others alone", () => { + const result = mapFilterFields( + { + operator: "AND", + filters: [ + { field: "userId", operator: "eq", value: "u1" }, + { field: "title", operator: "eq", value: "x" }, + ], + }, + { userId: "createdById" } + ); + expect(result).toEqual({ + operator: "AND", + filters: [ + { field: "createdById", operator: "eq", value: "u1" }, + { field: "title", operator: "eq", value: "x" }, + ], + }); + }); + + it("returns the same leaf when its field is not in the mapping", () => { + const leaf = { + field: "title" as const, + operator: "eq" as const, + value: "x", + }; + expect(mapFilterFields(leaf, { userId: "createdById" })).toBe(leaf); + }); + + it("renames a leaf at the root", () => { + expect( + mapFilterFields( + { field: "userId", operator: "eq", value: "u1" }, + { userId: "createdById" } + ) + ).toEqual({ field: "createdById", operator: "eq", value: "u1" }); + }); + + it("is a no-op when the mapping is empty", () => { + const leaf = { + field: "userId" as const, + operator: "eq" as const, + value: "u1", + }; + expect(mapFilterFields(leaf, {})).toBe(leaf); + }); + + it("renames fields inside nested groups", () => { + const result = mapFilterFields( + { + operator: "OR", + filters: [ + { + operator: "AND", + filters: [{ field: "userId", operator: "eq", value: "u1" }], + }, + ], + }, + { userId: "createdById" } + ); + expect(result).toEqual({ + operator: "OR", + filters: [ + { + operator: "AND", + filters: [{ field: "createdById", operator: "eq", value: "u1" }], + }, + ], + }); + }); + }); + + describe("legacyParamsToFilter", () => { + it("returns undefined when no legacy params are set", () => { + expect(legacyParamsToFilter({})).toBeUndefined(); + }); + + it("ignores parentDocumentId when undefined (distinct from null)", () => { + expect( + legacyParamsToFilter({ parentDocumentId: undefined }) + ).toBeUndefined(); + }); + + it("returns a single leaf when only one legacy param is set", () => { + expect(legacyParamsToFilter({ userId: "u1" })).toEqual({ + field: "userId", + operator: "eq", + value: "u1", + }); + }); + + it("converts parentDocumentId === null to isNull", () => { + expect(legacyParamsToFilter({ parentDocumentId: null })).toEqual({ + field: "parentDocumentId", + operator: "isNull", + }); + }); + + it("converts a truthy parentDocumentId to an eq leaf", () => { + expect(legacyParamsToFilter({ parentDocumentId: "p1" })).toEqual({ + field: "parentDocumentId", + operator: "eq", + value: "p1", + }); + }); + + it("ANDs all three legacy leaves when multiple are supplied", () => { + expect( + legacyParamsToFilter({ + userId: "u1", + collectionId: "c1", + parentDocumentId: "p1", + }) + ).toEqual({ + operator: "AND", + filters: [ + { field: "userId", operator: "eq", value: "u1" }, + { field: "collectionId", operator: "eq", value: "c1" }, + { field: "parentDocumentId", operator: "eq", value: "p1" }, + ], + }); + }); + }); + + describe("authorizeFilterFields", () => { + it("is a no-op when the filter does not reference collectionId", async () => { + const user = await buildUser(); + await expect( + authorizeFilterFields(user, { + field: "title", + operator: "eq", + value: "x", + }) + ).resolves.toBeUndefined(); + }); + + it("is a no-op when collectionId only appears via non-eq operators", async () => { + const user = await buildUser(); + await expect( + authorizeFilterFields(user, { + field: "collectionId", + operator: "isNotNull", + }) + ).resolves.toBeUndefined(); + }); + + it("authorizes an eq collectionId the user can read", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ teamId: team.id }); + await expect( + authorizeFilterFields(user, { + field: "collectionId", + operator: "eq", + value: collection.id, + }) + ).resolves.toBeUndefined(); + }); + + it("authorizes every collection in an in-list", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const c1 = await buildCollection({ teamId: team.id }); + const c2 = await buildCollection({ teamId: team.id }); + await expect( + authorizeFilterFields(user, { + field: "collectionId", + operator: "in", + value: [c1.id, c2.id], + }) + ).resolves.toBeUndefined(); + }); + + it("authorizes collection references buried inside nested groups", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ teamId: team.id }); + await expect( + authorizeFilterFields(user, { + operator: "OR", + filters: [ + { field: "title", operator: "eq", value: "x" }, + { + operator: "AND", + filters: [ + { + field: "collectionId", + operator: "eq", + value: collection.id, + }, + ], + }, + ], + }) + ).resolves.toBeUndefined(); + }); + + it("throws when the user cannot read the referenced collection", async () => { + const team = await buildTeam(); + const owner = await buildUser({ teamId: team.id }); + const outsider = await buildUser({ teamId: team.id }); + const privateCollection = await buildCollection({ + teamId: team.id, + userId: owner.id, + permission: null, + }); + await expect( + authorizeFilterFields(outsider, { + field: "collectionId", + operator: "eq", + value: privateCollection.id, + }) + ).rejects.toThrow(); + }); + + it("throws when any collection in an in-list is unauthorized", async () => { + const team = await buildTeam(); + const owner = await buildUser({ teamId: team.id }); + const outsider = await buildUser({ teamId: team.id }); + const accessible = await buildCollection({ + teamId: team.id, + permission: CollectionPermission.Read, + }); + const privateCollection = await buildCollection({ + teamId: team.id, + userId: owner.id, + permission: null, + }); + await expect( + authorizeFilterFields(outsider, { + field: "collectionId", + operator: "in", + value: [accessible.id, privateCollection.id], + }) + ).rejects.toThrow(); + }); + }); + + describe("translateSearchFilter", () => { + it("handles a single collectionId leaf", () => { + expect( + translateSearchFilter({ + field: "collectionId", + operator: "eq", + value: "abc", + }) + ).toEqual({ collectionId: "abc" }); + }); + + it("handles userId eq as a single-element collaboratorIds", () => { + expect( + translateSearchFilter({ + field: "userId", + operator: "eq", + value: "user-1", + }) + ).toEqual({ collaboratorIds: ["user-1"] }); + }); + + it("handles userId in as multi-element collaboratorIds", () => { + expect( + translateSearchFilter({ + field: "userId", + operator: "in", + value: ["a", "b"], + }) + ).toEqual({ collaboratorIds: ["a", "b"] }); + }); + + it("combines an AND group of supported leaves", () => { + expect( + translateSearchFilter( + { + operator: "AND", + filters: [ + { field: "collectionId", operator: "eq", value: "c" }, + { field: "userId", operator: "eq", value: "u" }, + { field: "documentId", operator: "eq", value: "d" }, + ], + }, + { allowDocumentId: true } + ) + ).toEqual({ + collectionId: "c", + collaboratorIds: ["u"], + documentId: "d", + }); + }); + + it("rejects documentId when not allowed", () => { + expect(() => + translateSearchFilter({ + field: "documentId", + operator: "eq", + value: "d", + }) + ).toThrow(); + }); + + it("rejects unsupported fields", () => { + expect(() => + translateSearchFilter({ field: "title", operator: "eq", value: "x" }) + ).toThrow(); + }); + + it("rejects OR groups at the top level", () => { + expect(() => + translateSearchFilter({ + operator: "OR", + filters: [ + { field: "collectionId", operator: "eq", value: "a" }, + { field: "userId", operator: "eq", value: "u" }, + ], + }) + ).toThrow(); + }); + + it("rejects nested groups", () => { + expect(() => + translateSearchFilter({ + operator: "AND", + filters: [ + { + operator: "AND", + filters: [{ field: "collectionId", operator: "eq", value: "a" }], + }, + ], + }) + ).toThrow(); + }); + + it("rejects unsupported operators", () => { + expect(() => + translateSearchFilter({ + field: "collectionId", + operator: "neq", + value: "a", + }) + ).toThrow(); + }); + + it("rejects duplicate leaves on the same field", () => { + expect(() => + translateSearchFilter({ + operator: "AND", + filters: [ + { field: "collectionId", operator: "eq", value: "a" }, + { field: "collectionId", operator: "eq", value: "b" }, + ], + }) + ).toThrow(); + }); + + it("translates updatedAt gte -P1D into the day dateFilter preset", () => { + expect( + translateSearchFilter({ + field: "updatedAt", + operator: "gte", + value: "-P1D", + }) + ).toEqual({ dateFilter: "day" }); + }); + + it("translates each supported duration into its dateFilter preset", () => { + const cases: Array<[string, string]> = [ + ["-P1D", "day"], + ["-P1W", "week"], + ["-P1M", "month"], + ["-P1Y", "year"], + ]; + for (const [duration, preset] of cases) { + expect( + translateSearchFilter({ + field: "updatedAt", + operator: "gte", + value: duration, + }) + ).toEqual({ dateFilter: preset }); + } + }); + + it("translates archivedAt isNotNull into statusFilter [archived]", () => { + expect( + translateSearchFilter({ + field: "archivedAt", + operator: "isNotNull", + }) + ).toEqual({ statusFilter: [StatusFilter.Archived] }); + }); + + it("translates AND of archivedAt isNull + publishedAt isNotNull into statusFilter [published]", () => { + expect( + translateSearchFilter({ + operator: "AND", + filters: [ + { + operator: "AND", + filters: [ + { field: "archivedAt", operator: "isNull" }, + { field: "publishedAt", operator: "isNotNull" }, + ], + }, + ], + }) + ).toEqual({ statusFilter: [StatusFilter.Published] }); + }); + + it("translates AND of archivedAt isNull + publishedAt isNull into statusFilter [draft]", () => { + expect( + translateSearchFilter({ + operator: "AND", + filters: [ + { field: "archivedAt", operator: "isNull" }, + { field: "publishedAt", operator: "isNull" }, + ], + }) + ).toEqual({ statusFilter: [StatusFilter.Draft] }); + }); + + it("translates an OR of status shapes into a multi-status statusFilter", () => { + expect( + translateSearchFilter({ + operator: "OR", + filters: [ + { + operator: "AND", + filters: [ + { field: "archivedAt", operator: "isNull" }, + { field: "publishedAt", operator: "isNotNull" }, + ], + }, + { field: "archivedAt", operator: "isNotNull" }, + ], + }) + ).toEqual({ + statusFilter: [StatusFilter.Published, StatusFilter.Archived], + }); + }); + + it("combines collectionId, dateFilter and statusFilter in an AND", () => { + expect( + translateSearchFilter({ + operator: "AND", + filters: [ + { field: "collectionId", operator: "eq", value: "c" }, + { field: "updatedAt", operator: "gte", value: "-P1W" }, + { field: "archivedAt", operator: "isNotNull" }, + ], + }) + ).toEqual({ + collectionId: "c", + dateFilter: "week", + statusFilter: [StatusFilter.Archived], + }); + }); + + it("rejects an unsupported duration on updatedAt gte", () => { + expect(() => + translateSearchFilter({ + field: "updatedAt", + operator: "gte", + value: "-P3D", + }) + ).toThrow(); + }); + }); + + /** + * Red-team unit tests targeting the security-relevant invariants of the + * filter helpers. The integration tests in documents.test.ts probe the + * full end-to-end behavior; these tests pin the helper-level contracts + * that the documents.list handler relies on for authorization. + */ + describe("red team — security invariants", () => { + describe("hasExplicitCollectionId / collection scope drop", () => { + it("must NOT report an explicit collectionId when it is buried inside an OR group", () => { + // Rationale: documents.list drops the default collection scope + // (`collectionId IN user.collectionIds`) when this returns true. + // If collectionId is OR'd with another condition, the OR'd sibling + // would expand the result set across the user's accessible + // collections — a permission bypass. To be safe, the helper must + // only flag collectionId references that genuinely AND-narrow the + // query. + const filter = { + operator: "OR" as const, + filters: [ + { field: "collectionId", operator: "eq" as const, value: "c1" }, + { field: "title", operator: "contains" as const, value: "x" }, + ], + }; + expect(hasExplicitCollectionId(filter)).toBe(false); + }); + + it("must NOT report an explicit collectionId when it is in a deeply nested OR", () => { + const filter = { + operator: "AND" as const, + filters: [ + { field: "title", operator: "contains" as const, value: "x" }, + { + operator: "OR" as const, + filters: [ + { + field: "collectionId", + operator: "eq" as const, + value: "c1", + }, + { + field: "userId", + operator: "eq" as const, + value: "u1", + }, + ], + }, + ], + }; + expect(hasExplicitCollectionId(filter)).toBe(false); + }); + + it("must report an explicit collectionId at the root", () => { + expect( + hasExplicitCollectionId({ + field: "collectionId", + operator: "eq", + value: "c1", + }) + ).toBe(true); + }); + + it("must report an explicit collectionId inside an AND group", () => { + // AND-narrowing is safe: every result must satisfy collectionId = c1. + expect( + hasExplicitCollectionId({ + operator: "AND", + filters: [ + { field: "collectionId", operator: "eq", value: "c1" }, + { field: "title", operator: "contains", value: "x" }, + ], + }) + ).toBe(true); + }); + + it("must NOT report an explicit collectionId for non-eq/in operators", () => { + for (const op of [ + "neq", + "isNull", + "isNotNull", + "contains", + "startsWith", + "endsWith", + "notIn", + ] as const) { + const value = op === "isNull" || op === "isNotNull" ? undefined : "x"; + const leaf = + op === "isNull" || op === "isNotNull" + ? { field: "collectionId", operator: op } + : op === "notIn" + ? { field: "collectionId", operator: op, value: ["x"] } + : { field: "collectionId", operator: op, value }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(hasExplicitCollectionId(leaf as any)).toBe(false); + } + }); + }); + + describe("authorizeFilterFields", () => { + it("must authorize EVERY collectionId referenced anywhere in the tree", async () => { + // If the helper only checked top-level leaves, an attacker could + // smuggle an unauthorized collectionId into a nested OR. + const team = await buildTeam(); + const ownerA = await buildUser({ teamId: team.id }); + const ownerB = await buildUser({ teamId: team.id }); + const reader = await buildUser({ teamId: team.id }); + const accessible = await buildCollection({ + teamId: team.id, + userId: reader.id, + permission: CollectionPermission.Read, + }); + const inaccessible = await buildCollection({ + teamId: team.id, + userId: ownerA.id, + permission: null, + }); + + // Buried in an OR group with sibling fields. + const filter = { + operator: "OR" as const, + filters: [ + { + field: "collectionId", + operator: "eq" as const, + value: accessible.id, + }, + { + field: "collectionId", + operator: "eq" as const, + value: inaccessible.id, + }, + ], + }; + + await expect( + authorizeFilterFields(reader, filter) + ).rejects.toBeDefined(); + // Suppress unused var lint + expect(ownerB.id).toBeDefined(); + }); + + it("must authorize collectionId inside a deeply nested AND-OR chain", async () => { + const team = await buildTeam(); + const ownerA = await buildUser({ teamId: team.id }); + const reader = await buildUser({ teamId: team.id }); + const inaccessible = await buildCollection({ + teamId: team.id, + userId: ownerA.id, + permission: null, + }); + + const filter = { + operator: "AND" as const, + filters: [ + { field: "title", operator: "contains" as const, value: "x" }, + { + operator: "OR" as const, + filters: [ + { + operator: "AND" as const, + filters: [ + { + field: "collectionId", + operator: "eq" as const, + value: inaccessible.id, + }, + ], + }, + ], + }, + ], + }; + await expect( + authorizeFilterFields(reader, filter) + ).rejects.toBeDefined(); + }); + + it("must authorize every entry of an in[] list, even with one entry accessible", async () => { + const team = await buildTeam(); + const ownerA = await buildUser({ teamId: team.id }); + const reader = await buildUser({ teamId: team.id }); + const accessible = await buildCollection({ + teamId: team.id, + userId: reader.id, + permission: CollectionPermission.Read, + }); + const inaccessible = await buildCollection({ + teamId: team.id, + userId: ownerA.id, + permission: null, + }); + const filter = { + field: "collectionId" as const, + operator: "in" as const, + value: [accessible.id, inaccessible.id], + }; + await expect( + authorizeFilterFields(reader, filter) + ).rejects.toBeDefined(); + }); + + it("must NOT authorize values that are smuggled through non-eq/in operators today", () => { + // The current contract only authorizes via collectEqValues which + // ignores neq/contains/etc — so those operators must NOT be + // available as a way to scope queries to specific collections. + // hasExplicitCollectionId encodes that contract: it must return + // false for those operators so the default collection scope is + // applied as a safety net. + for (const op of [ + "neq", + "contains", + "startsWith", + "endsWith", + "notIn", + "isNull", + "isNotNull", + ] as const) { + const leaf = + op === "isNull" || op === "isNotNull" + ? { field: "collectionId", operator: op } + : op === "notIn" + ? { field: "collectionId", operator: op, value: ["x"] } + : { field: "collectionId", operator: op, value: "x" }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(collectEqValues(leaf as any, "collectionId")).toEqual([]); + } + }); + }); + + describe("extractTopLevelEqValue / parent-doc membership escape", () => { + it("must NOT extract a parentDocumentId nested inside an OR group", () => { + // The membership escape drops collection scoping when this returns + // a value. An OR-nested parentDocumentId must not trigger it. + const filter = { + operator: "OR" as const, + filters: [ + { + field: "parentDocumentId", + operator: "eq" as const, + value: "p1", + }, + { field: "title", operator: "contains" as const, value: "x" }, + ], + }; + expect(extractTopLevelEqValue(filter, "parentDocumentId")).toBeUndefined(); + }); + + it("must NOT extract a parentDocumentId from a nested AND inside the root AND", () => { + // Only true top-level AND children count. Nested groups must not be + // recursed into. + const filter = { + operator: "AND" as const, + filters: [ + { field: "title", operator: "contains" as const, value: "x" }, + { + operator: "AND" as const, + filters: [ + { + field: "parentDocumentId", + operator: "eq" as const, + value: "p1", + }, + ], + }, + ], + }; + expect(extractTopLevelEqValue(filter, "parentDocumentId")).toBeUndefined(); + }); + + it("must extract a parentDocumentId at the root or in a one-leaf top-level AND", () => { + expect( + extractTopLevelEqValue( + { + field: "parentDocumentId", + operator: "eq", + value: "p1", + }, + "parentDocumentId" + ) + ).toBe("p1"); + expect( + extractTopLevelEqValue( + { + operator: "AND", + filters: [ + { field: "title", operator: "contains", value: "x" }, + { field: "parentDocumentId", operator: "eq", value: "p1" }, + ], + }, + "parentDocumentId" + ) + ).toBe("p1"); + }); + + it("must NOT extract a parentDocumentId when more than one leaf references the field", () => { + // Ambiguous: two `eq` leaves on parentDocumentId (caller likely + // meant in[] but used a duplicated leaf instead). The escape must + // not engage with an ambiguous target. + expect( + extractTopLevelEqValue( + { + operator: "AND", + filters: [ + { field: "parentDocumentId", operator: "eq", value: "p1" }, + { field: "parentDocumentId", operator: "eq", value: "p2" }, + ], + }, + "parentDocumentId" + ) + ).toBeUndefined(); + }); + }); + + describe("buildWhere LIKE escaping", () => { + it("must escape backslashes in contains values", () => { + // Without escaping, `\%` would itself be interpreted as a literal + // `%` after PostgreSQL parses the LIKE pattern. + expect( + buildWhere({ + field: "title", + operator: "contains", + value: "a\\b", + }) + ).toEqual({ title: { [Op.iLike]: "%a\\\\b%" } }); + }); + + it("must escape underscores so they cannot match arbitrary chars", () => { + expect( + buildWhere({ + field: "title", + operator: "startsWith", + value: "a_b", + }) + ).toEqual({ title: { [Op.iLike]: "a\\_b%" } }); + }); + }); + + describe("dateFromDuration SQL safety", () => { + it("must throw on inputs containing SQL quote characters", () => { + expect(() => dateFromDuration("P1D' OR 1=1 --")).toThrow(); + }); + + it("must throw on inputs containing whitespace", () => { + expect(() => dateFromDuration("P 1D")).toThrow(); + }); + + it("must throw on inputs with disallowed characters", () => { + for (const value of [ + "P1D;DROP", + "P1D-", + "P1D--", + "P1D/*", + "P1D)", + "1D", + "PT", + "P", + "", + "P1.5D", + ]) { + expect(() => dateFromDuration(value)).toThrow(); + } + }); + }); + + describe("hasFieldInFilter", () => { + it("walks recursively across both AND and OR groups", () => { + const filter = { + operator: "AND" as const, + filters: [ + { + operator: "OR" as const, + filters: [ + { + field: "archivedAt", + operator: "isNotNull" as const, + }, + { field: "title", operator: "contains" as const, value: "x" }, + ], + }, + ], + }; + // The current handler uses this to disable the default + // archivedAt-is-null exclusion. That's defensible (a referenced + // archivedAt clause is the caller's intent), but the test pins the + // contract so future refactors don't silently change it. + expect(hasFieldInFilter(filter, "archivedAt")).toBe(true); + }); + }); + + describe("mapFilterFields", () => { + it("does not mutate the input filter tree", () => { + const original = { + operator: "AND" as const, + filters: [ + { field: "userId", operator: "eq" as const, value: "u1" }, + { + operator: "OR" as const, + filters: [ + { field: "documentId", operator: "eq" as const, value: "d1" }, + ], + }, + ], + }; + const snapshot = JSON.parse(JSON.stringify(original)); + mapFilterFields(original, { userId: "createdById", documentId: "id" }); + expect(original).toEqual(snapshot); + }); + }); + + describe("legacyParamsToFilter", () => { + it("never produces an OR group, so cannot enable the OR-bypass on its own", () => { + const result = legacyParamsToFilter({ + userId: "u1", + collectionId: "c1", + parentDocumentId: "p1", + }); + // The result should always be a single leaf or an AND group — never + // an OR group — to preserve the invariant that legacy callers AND + // their constraints together. + if (result && "filters" in result) { + expect(result.operator).toBe("AND"); + } else { + expect(result).toBeDefined(); + } + }); + }); + }); +}); diff --git a/server/models/helpers/Filters.ts b/server/models/helpers/Filters.ts new file mode 100644 index 0000000000..1833ce8a33 --- /dev/null +++ b/server/models/helpers/Filters.ts @@ -0,0 +1,548 @@ +import { Op, Sequelize, type Utils, type WhereOptions } from "sequelize"; +import type { + ComparisonOperator, + Filter, + FilterCondition, + FilterGroup, +} from "@shared/helpers/FilterHelper"; +import type { DateFilter, StatusFilter } from "@shared/types"; +import { StatusFilter as StatusFilterEnum } from "@shared/types"; +import { Collection } from "@server/models"; +import type User from "@server/models/User"; +import { authorize } from "@server/policies"; +import { isISO8601Duration } from "@server/validation"; + +const operatorMap: Record< + ComparisonOperator, + symbol | { op: symbol; transform?: (value: unknown) => unknown } +> = { + eq: Op.eq, + neq: Op.ne, + lt: Op.lt, + lte: Op.lte, + gt: Op.gt, + gte: Op.gte, + contains: Op.iLike, + startsWith: Op.iLike, + endsWith: Op.iLike, + in: Op.in, + notIn: Op.notIn, + isNull: Op.is, + isNotNull: Op.not, +}; + +/** Escape a value for safe use inside a SQL LIKE / iLike pattern. */ +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, (c) => `\\${c}`); +} + +const COMPARISON_OPERATORS = new Set([ + "gt", + "gte", + "lt", + "lte", +]); + +/** + * Convert an ISO 8601 duration into a Sequelize literal expressing + * `now() ± interval ''`. A leading `-` flips the sign, allowing + * relative-past durations (`-P7D` → `now() - 7 days`) and relative-future + * durations (`P7D` → `now() + 7 days`). + * + * The duration is regex-validated to contain only `P`, `T`, digits, and the + * unit letters `YMWDHS`, so the literal interpolation is safe by construction. + * + * @param duration an ISO 8601 duration, optionally signed with a leading `-`. + * @returns a Sequelize literal that resolves to `now() ± interval ''`. + * @throws if the input is not a valid ISO 8601 duration. + */ +export function dateFromDuration(duration: string): Utils.Literal { + if (!isISO8601Duration(duration)) { + throw new Error(`Invalid ISO 8601 duration: ${duration}`); + } + const negative = duration.startsWith("-"); + const magnitude = negative ? duration.slice(1) : duration; + const op = negative ? "-" : "+"; + return Sequelize.literal(`now() ${op} interval '${magnitude}'`); +} + +function isGroup( + filter: Filter +): filter is FilterGroup { + return "filters" in filter; +} + +function leafToWhere(condition: FilterCondition): Record { + const { field, operator, value } = condition; + + switch (operator) { + case "isNull": + return { [field]: { [Op.is]: null } }; + case "isNotNull": + return { [field]: { [Op.not]: null } }; + case "contains": + return { [field]: { [Op.iLike]: `%${escapeLike(String(value))}%` } }; + case "startsWith": + return { [field]: { [Op.iLike]: `${escapeLike(String(value))}%` } }; + case "endsWith": + return { [field]: { [Op.iLike]: `%${escapeLike(String(value))}` } }; + default: { + const op = operatorMap[operator]; + if (typeof op !== "symbol") { + throw new Error(`Unhandled filter operator: ${operator}`); + } + const resolved = + COMPARISON_OPERATORS.has(operator) && + typeof value === "string" && + isISO8601Duration(value) + ? dateFromDuration(value) + : value; + return { [field]: { [op]: resolved } }; + } + } +} + +/** + * Combine a list of filter expressions into a single tree, ANDing top-level entries. + * + * The wire-level API accepts `filters: Filter[]` with an implicit AND between + * entries; internal helpers operate on a single Filter tree. This bridges the two. + * + * @param filters the list of filter expressions, or undefined. + * @returns the equivalent single filter, or undefined if the list is empty/missing. + */ +export function combineFilters( + filters: Filter[] | undefined +): Filter | undefined { + if (!filters || filters.length === 0) { + return undefined; + } + if (filters.length === 1) { + return filters[0]; + } + return { operator: "AND", filters }; +} + +/** + * Convert a filter DSL expression into a Sequelize WhereOptions clause. + * + * @param filter the filter expression to convert. + * @returns a Sequelize-compatible where clause. + */ +export function buildWhere( + filter: Filter +): WhereOptions { + if (isGroup(filter)) { + const subWheres = filter.filters.map((f) => buildWhere(f)); + const op = filter.operator === "AND" ? Op.and : Op.or; + return { [op]: subWheres } as WhereOptions; + } + return leafToWhere(filter) as WhereOptions; +} + +/** + * Recursively check whether a filter references a particular field anywhere in its tree. + * + * @param filter the filter to inspect. + * @param field the field name to look for. + * @returns true if the field appears in any leaf condition. + */ +export function hasFieldInFilter(filter: Filter, field: string): boolean { + if (isGroup(filter)) { + return filter.filters.some((f) => hasFieldInFilter(f, field)); + } + return filter.field === field; +} + +/** + * Collect all values referenced by `eq` and `in` leaves for a given field. + * + * @param filter the filter to inspect. + * @param field the field name to extract values for. + * @returns the union of values across matching leaves; empty if none. + */ +export function collectEqValues(filter: Filter, field: string): string[] { + const out: string[] = []; + const walk = (f: Filter) => { + if (isGroup(f)) { + f.filters.forEach(walk); + return; + } + if (f.field !== field) { + return; + } + if (f.operator === "eq" && f.value !== undefined) { + out.push(String(f.value)); + } else if (f.operator === "in" && Array.isArray(f.value)) { + out.push(...f.value.map(String)); + } + }; + walk(filter); + return out; +} + +/** + * Whether the filter narrows results to one or more specific collections via `eq` or `in`. + * + * Used by the `documents.list` handler to decide if the default `user.collectionIds()` + * scope should be applied. Other operators (e.g. `neq`, `isNull`) do not constitute + * an explicit collection target, and the default scope must still be applied. + * + * @param filter the filter to inspect. + * @returns true if collectionId is restricted to specific values via eq/in. + */ +export function hasExplicitCollectionId(filter: Filter): boolean { + return collectEqValues(filter, "collectionId").length > 0; +} + +/** + * Extract a single top-level eq value for a given field, if present. + * + * Only matches when the filter root is either a leaf condition or an AND group + * containing exactly one matching leaf — used for handling parameters whose + * semantics only make sense as a single value (e.g. parentDocumentId membership + * escape, sort=index special case). + * + * @param filter the filter to inspect. + * @param field the field name to extract. + * @returns the single eq value, or undefined if not unambiguously present. + */ +export function extractTopLevelEqValue( + filter: Filter, + field: string +): string | undefined { + const leaves: FilterCondition[] = isGroup(filter) + ? filter.operator === "AND" + ? filter.filters.filter( + (f): f is FilterCondition => !isGroup(f) && f.field === field + ) + : [] + : filter.field === field + ? [filter] + : []; + + if (leaves.length !== 1) { + return undefined; + } + const leaf = leaves[0]; + if (leaf.operator !== "eq" || leaf.value === undefined) { + return undefined; + } + return String(leaf.value); +} + +/** + * Rename leaf field names according to a mapping. + * + * Used to bridge the public API field naming (e.g. `userId`) to the underlying + * Sequelize column name (e.g. `createdById`) without exposing the column name + * in the API. + * + * @param filter the filter to transform. + * @param mapping map of source field name to target column name. + * @returns a new filter with mapped field names; structure preserved. + */ +export function mapFilterFields( + filter: Filter, + mapping: Record +): Filter { + if (isGroup(filter)) { + return { + operator: filter.operator, + filters: filter.filters.map((f) => mapFilterFields(f, mapping)), + }; + } + const mapped = mapping[filter.field]; + return mapped ? { ...filter, field: mapped } : filter; +} + +interface LegacyParams { + userId?: string; + collectionId?: string; + parentDocumentId?: string | null; +} + +/** + * Translate legacy top-level eq-params into an equivalent filter expression. + * + * `parentDocumentId === null` becomes an `isNull` leaf (matches root-level + * documents); a truthy value becomes an `eq` leaf. + * + * @param legacy legacy top-level params. + * @returns the equivalent filter, or undefined if no legacy params were provided. + */ +export function legacyParamsToFilter(legacy: LegacyParams): Filter | undefined { + const leaves: FilterCondition[] = []; + + if (legacy.userId) { + leaves.push({ field: "userId", operator: "eq", value: legacy.userId }); + } + if (legacy.collectionId) { + leaves.push({ + field: "collectionId", + operator: "eq", + value: legacy.collectionId, + }); + } + if (legacy.parentDocumentId === null) { + leaves.push({ field: "parentDocumentId", operator: "isNull" }); + } else if (legacy.parentDocumentId) { + leaves.push({ + field: "parentDocumentId", + operator: "eq", + value: legacy.parentDocumentId, + }); + } + + if (leaves.length === 0) { + return undefined; + } + if (leaves.length === 1) { + return leaves[0]; + } + return { operator: "AND", filters: leaves }; +} + +interface SearchFilterTranslation { + collectionId?: string; + collaboratorIds?: string[]; + documentId?: string; + dateFilter?: DateFilter; + statusFilter?: StatusFilter[]; +} + +interface TranslateSearchFilterOptions { + /** Whether `documentId` is a permitted leaf field. */ + allowDocumentId?: boolean; +} + +const DATE_FILTER_BY_DURATION: Record = { + "-P1D": "day", + "-P1W": "week", + "-P1M": "month", + "-P1Y": "year", +}; + +function recognizeDateFilter(leaf: FilterCondition): DateFilter | undefined { + if ( + leaf.field !== "updatedAt" || + leaf.operator !== "gte" || + typeof leaf.value !== "string" + ) { + return undefined; + } + return DATE_FILTER_BY_DURATION[leaf.value]; +} + +function recognizeStatus(node: Filter): StatusFilter | undefined { + if (!isGroup(node)) { + if (node.field === "archivedAt" && node.operator === "isNotNull") { + return StatusFilterEnum.Archived; + } + return undefined; + } + if (node.operator !== "AND" || node.filters.length !== 2) { + return undefined; + } + const archived = node.filters.find( + (f): f is FilterCondition => !isGroup(f) && f.field === "archivedAt" + ); + const published = node.filters.find( + (f): f is FilterCondition => !isGroup(f) && f.field === "publishedAt" + ); + if ( + !archived || + !published || + archived.operator !== "isNull" || + node.filters.length !== 2 + ) { + return undefined; + } + if (published.operator === "isNotNull") { + return StatusFilterEnum.Published; + } + if (published.operator === "isNull") { + return StatusFilterEnum.Draft; + } + return undefined; +} + +function recognizeStatusGroup(node: Filter): StatusFilter[] | undefined { + const single = recognizeStatus(node); + if (single) { + return [single]; + } + if (isGroup(node) && node.operator === "OR") { + const statuses: StatusFilter[] = []; + for (const child of node.filters) { + const s = recognizeStatus(child); + if (!s) { + return undefined; + } + statuses.push(s); + } + return statuses; + } + return undefined; +} + +/** + * Translate a search filter expression into the subset of SearchOptions fields + * that SearchProvider implementations accept. + * + * Search providers consume a fixed shape rather than arbitrary WHERE clauses, + * so the filter must be either a single leaf or an AND group whose children + * are each one of: + * - `collectionId` eq → `collectionId` + * - `userId` eq → `collaboratorIds: [value]` + * - `userId` in → `collaboratorIds: [...values]` + * - `documentId` eq → `documentId` (only when allowDocumentId is true) + * - `updatedAt` gte with a duration `-P1D|-P1W|-P1M|-P1Y` → `dateFilter` preset + * - a status shape (single or OR group): each shape is one of: + * - `archivedAt isNotNull` → `archived` + * - AND of `archivedAt isNull` + `publishedAt isNotNull` → `published` + * - AND of `archivedAt isNull` + `publishedAt isNull` → `draft` + * + * @param filter the filter to translate. + * @param options translation options. + * @returns the SearchOptions subset extracted from the filter. + * @throws if the filter shape is not supported for search. + */ +export function translateSearchFilter( + filter: Filter, + options: TranslateSearchFilterOptions = {} +): SearchFilterTranslation { + // The root itself may be a recognized status shape (single status, AND of + // archivedAt+publishedAt for published/draft, or OR group of statuses). + const rootStatuses = recognizeStatusGroup(filter); + if (rootStatuses) { + return { statusFilter: rootStatuses }; + } + + if (isGroup(filter) && filter.operator !== "AND") { + throw new Error( + `Search filter only supports AND groups at the top level; got ${filter.operator}` + ); + } + + const children: Filter[] = + isGroup(filter) && filter.operator === "AND" ? filter.filters : [filter]; + + const result: SearchFilterTranslation = {}; + const seenLeafFields = new Set(); + + const setOnce = ( + key: K, + value: SearchFilterTranslation[K], + label: string + ) => { + if (result[key] !== undefined) { + throw new Error(`Search filter has duplicate ${label}`); + } + result[key] = value; + }; + + for (const child of children) { + if (isGroup(child)) { + const statuses = recognizeStatusGroup(child); + if (!statuses) { + throw new Error("Search filter contains an unrecognized group"); + } + setOnce("statusFilter", statuses, "status filter"); + continue; + } + + // Try date filter + const dateFilter = recognizeDateFilter(child); + if (dateFilter) { + setOnce("dateFilter", dateFilter, "date filter"); + continue; + } + + // Try standalone status (e.g. archivedAt isNotNull) + const statusOnly = recognizeStatus(child); + if (statusOnly) { + setOnce("statusFilter", [statusOnly], "status filter"); + continue; + } + + if (seenLeafFields.has(child.field)) { + throw new Error( + `Search filter has multiple leaves on the same field '${child.field}'` + ); + } + seenLeafFields.add(child.field); + + switch (child.field) { + case "collectionId": + if (child.operator !== "eq" || child.value === undefined) { + throw new Error( + "Search filter only supports `eq` on collectionId with a value" + ); + } + result.collectionId = String(child.value); + break; + case "userId": + if (child.operator === "eq" && child.value !== undefined) { + result.collaboratorIds = [String(child.value)]; + } else if (child.operator === "in" && Array.isArray(child.value)) { + result.collaboratorIds = child.value.map(String); + } else { + throw new Error( + "Search filter only supports `eq` or `in` on userId with a value" + ); + } + break; + case "documentId": + if (!options.allowDocumentId) { + throw new Error( + "Search filter does not support `documentId` for this endpoint" + ); + } + if (child.operator !== "eq" || child.value === undefined) { + throw new Error( + "Search filter only supports `eq` on documentId with a value" + ); + } + result.documentId = String(child.value); + break; + default: + throw new Error( + `Search filter does not support field '${child.field}'` + ); + } + } + + return result; +} + +/** + * Re-run authorization for any auth-bearing fields referenced inside a filter. + * + * Currently authorizes each `collectionId` value referenced via `eq` or `in`, + * mirroring the authorize() call the legacy top-level `collectionId` param would + * have triggered. Throws if any referenced collection is not readable. + * + * @param user the current user. + * @param filter the filter to authorize. + * @throws if the user lacks read access to any referenced collection. + */ +export async function authorizeFilterFields( + user: User, + filter: Filter +): Promise { + const collectionIds = collectEqValues(filter, "collectionId"); + if (collectionIds.length === 0) { + return; + } + + const collections = await Promise.all( + Array.from(new Set(collectionIds)).map((id) => + Collection.findByPk(id, { userId: user.id }) + ) + ); + + for (const collection of collections) { + authorize(user, "readDocument", collection); + } +} diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index 1e06fe1b24..6d1a67a016 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -834,7 +834,7 @@ describe("#documents.list", () => { const body = await res.json(); expect(res.status).toEqual(200); expect(body.data).toHaveLength(2); - const docIds = body.data.map((doc: { id: string }) => doc.id); + const docIds = body.data.map((doc: any) => doc.id); expect(docIds).toContain(docs[0].id); expect(docIds).toContain(docs[1].id); expect(docIds).not.toContain(docs[2].id); @@ -1079,6 +1079,1175 @@ describe("#documents.list", () => { expect(body.data[0].id).toEqual(anotherDoc.id); }); + describe("filter DSL", () => { + it("should match a simple contains leaf case-insensitively", async () => { + const user = await buildUser(); + await buildDocument({ + title: "First document", + userId: user.id, + teamId: user.teamId, + }); + await buildDocument({ + title: "Second document", + userId: user.id, + teamId: user.teamId, + }); + + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [{ field: "title", operator: "contains", value: "first" }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toHaveLength(1); + expect(body.data[0].title).toEqual("First document"); + }); + + it("should match an OR group", async () => { + const user = await buildUser(); + await buildDocument({ + title: "First document", + userId: user.id, + teamId: user.teamId, + }); + await buildDocument({ + title: "Second document", + userId: user.id, + teamId: user.teamId, + }); + + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { field: "title", operator: "eq", value: "First document" }, + { field: "title", operator: "eq", value: "Second document" }, + ], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toHaveLength(2); + }); + + it("should reject an unknown field", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [{ field: "nope", operator: "eq", value: "x" }], + }, + }); + expect(res.status).toEqual(400); + }); + + it("should reject a value provided with isNull", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [{ field: "publishedAt", operator: "isNull", value: "x" }], + }, + }); + expect(res.status).toEqual(400); + }); + + it("should reject a scalar value for the in operator", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [{ field: "title", operator: "in", value: "x" }], + }, + }); + expect(res.status).toEqual(400); + }); + + it("should reject filter nesting beyond the depth limit", async () => { + const user = await buildUser(); + const leaf = { field: "title", operator: "eq", value: "x" }; + let nested: Record = leaf; + for (let i = 0; i < 6; i++) { + nested = { operator: "AND", filters: [nested] }; + } + const res = await server.post("/api/documents.list", { + body: { token: user.getJwtToken(), filters: [nested] }, + }); + expect(res.status).toEqual(400); + }); + + it("should not leak documents across teams", async () => { + const userA = await buildUser(); + const userB = await buildUser(); + await buildDocument({ + title: "Cross team", + userId: userB.id, + teamId: userB.teamId, + }); + const res = await server.post("/api/documents.list", { + body: { + token: userA.getJwtToken(), + filters: [{ field: "title", operator: "eq", value: "Cross team" }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toHaveLength(0); + }); + + it("should re-run authorize for collectionId in filter", async () => { + const user = await buildUser(); + const otherUser = await buildUser(); + const privateCollection = await buildCollection({ + teamId: otherUser.teamId, + userId: otherUser.id, + permission: null, + }); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [ + { + field: "collectionId", + operator: "eq", + value: privateCollection.id, + }, + ], + }, + }); + expect(res.status).toEqual(403); + }); + + it("should include archived documents when filter references archivedAt", async () => { + const user = await buildUser(); + const archived = await buildDocument({ + title: "Old doc", + userId: user.id, + teamId: user.teamId, + archivedAt: new Date(), + }); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [{ field: "archivedAt", operator: "isNotNull" }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.map((d: { id: string }) => d.id)).toContain(archived.id); + }); + + it("should escape LIKE wildcards in contains values", async () => { + const user = await buildUser(); + await buildDocument({ + title: "5050", + userId: user.id, + teamId: user.teamId, + }); + const literal = await buildDocument({ + title: "50% off", + userId: user.id, + teamId: user.teamId, + }); + + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [{ field: "title", operator: "contains", value: "50%" }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toHaveLength(1); + expect(body.data[0].id).toEqual(literal.id); + }); + + it("should produce equivalent results for legacy collectionId and filter", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + const legacyRes = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + collectionId: document.collectionId, + }, + }); + const filterRes = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [ + { + field: "collectionId", + operator: "eq", + value: document.collectionId, + }, + ], + }, + }); + const legacyBody = await legacyRes.json(); + const filterBody = await filterRes.json(); + expect(legacyRes.status).toEqual(200); + expect(filterRes.status).toEqual(200); + expect(filterBody.data.map((d: { id: string }) => d.id).sort()).toEqual( + legacyBody.data.map((d: { id: string }) => d.id).sort() + ); + }); + + it("should reject filters combined with legacy userId", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + userId: user.id, + filters: [{ field: "title", operator: "eq", value: "Match" }], + }, + }); + expect(res.status).toEqual(400); + }); + + it("should reject filters combined with legacy collectionId", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + collectionId: document.collectionId, + filters: [{ field: "title", operator: "eq", value: "x" }], + }, + }); + expect(res.status).toEqual(400); + }); + + it("should reject filters combined with statusFilter", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + statusFilter: [StatusFilter.Archived], + filters: [{ field: "title", operator: "eq", value: "x" }], + }, + }); + expect(res.status).toEqual(400); + }); + + it("should apply parent-doc membership escape for parentDocumentId in filters", async () => { + const user = await buildUser(); + const otherUser = await buildUser({ teamId: user.teamId }); + const privateCollection = await buildCollection({ + teamId: user.teamId, + userId: otherUser.id, + permission: null, + }); + const parent = await buildDocument({ + teamId: user.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + }); + const child = await buildDocument({ + teamId: user.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + parentDocumentId: parent.id, + }); + await UserMembership.create({ + createdById: otherUser.id, + documentId: parent.id, + userId: user.id, + permission: DocumentPermission.Read, + }); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [ + { field: "parentDocumentId", operator: "eq", value: parent.id }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.map((d: { id: string }) => d.id)).toEqual([child.id]); + }); + }); + + describe("filter DSL — red team", () => { + /** + * Helper: builds a viewing user plus a teammate-owned private collection + * containing one secret document. The viewing user has zero access to the + * collection. Tests below assert that no filter shape can surface the + * secret document to the viewing user. + */ + const setupVictim = async () => { + const viewer = await buildUser(); + const otherUser = await buildUser({ teamId: viewer.teamId }); + const privateCollection = await buildCollection({ + teamId: viewer.teamId, + userId: otherUser.id, + permission: null, + }); + const secretDoc = await buildDocument({ + title: "TOP SECRET PAYROLL", + text: "salaries everywhere", + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + }); + const ownCollection = await buildCollection({ + teamId: viewer.teamId, + userId: viewer.id, + }); + const ownDoc = await buildDocument({ + title: "Mundane", + teamId: viewer.teamId, + userId: viewer.id, + collectionId: ownCollection.id, + }); + return { + viewer, + otherUser, + privateCollection, + secretDoc, + ownCollection, + ownDoc, + }; + }; + + it("must not leak docs across collections via OR with collectionId+title", async () => { + const { viewer, ownCollection, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { + field: "collectionId", + operator: "eq", + value: ownCollection.id, + }, + { field: "title", operator: "contains", value: "secret" }, + ], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not leak docs across collections via OR with collectionId+empty contains", async () => { + const { viewer, ownCollection, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { + field: "collectionId", + operator: "eq", + value: ownCollection.id, + }, + { field: "title", operator: "contains", value: "" }, + ], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not leak docs across collections via OR with collectionId+userId of teammate", async () => { + const { viewer, otherUser, ownCollection, secretDoc } = + await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { + field: "collectionId", + operator: "eq", + value: ownCollection.id, + }, + { field: "userId", operator: "eq", value: otherUser.id }, + ], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not leak docs across collections via OR using collectionId in[]", async () => { + const { viewer, ownCollection, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { + field: "collectionId", + operator: "in", + value: [ownCollection.id], + }, + { field: "title", operator: "contains", value: "" }, + ], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not leak docs across collections via OR with collectionId nested in AND", async () => { + const { viewer, ownCollection, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { + operator: "AND", + filters: [ + { + field: "collectionId", + operator: "eq", + value: ownCollection.id, + }, + { field: "title", operator: "contains", value: "x" }, + ], + }, + { field: "title", operator: "contains", value: "" }, + ], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not leak docs via OR with collectionId+documentId of secret doc", async () => { + const { viewer, ownCollection, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { + field: "collectionId", + operator: "eq", + value: ownCollection.id, + }, + { + field: "documentId", + operator: "eq", + value: secretDoc.id, + }, + ], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not leak docs via deeply nested AND-of-OR with collectionId", async () => { + const { viewer, ownCollection, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "AND", + filters: [ + { + operator: "OR", + filters: [ + { + field: "collectionId", + operator: "eq", + value: ownCollection.id, + }, + { field: "title", operator: "contains", value: "" }, + ], + }, + ], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not leak docs via top-level array implicit-AND with sibling OR group", async () => { + const { viewer, ownCollection, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + field: "collectionId", + operator: "eq", + value: ownCollection.id, + }, + { + operator: "OR", + filters: [ + { field: "title", operator: "contains", value: "" }, + { field: "title", operator: "contains", value: "secret" }, + ], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + // The collectionId leaf is at top level (AND), so semantically results + // must be limited to ownCollection. Secret doc lives in a different + // collection and must not appear. + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must reject OR with one authorized collectionId and one unauthorized collectionId", async () => { + const { viewer, ownCollection, privateCollection } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { + field: "collectionId", + operator: "eq", + value: ownCollection.id, + }, + { + field: "collectionId", + operator: "eq", + value: privateCollection.id, + }, + ], + }, + ], + }, + }); + // Authorize must run for every collectionId eq value referenced + // anywhere in the tree. The unauthorized one must trigger 403. + expect(res.status).toEqual(403); + }); + + it("must reject in[] containing both an authorized and unauthorized collection", async () => { + const { viewer, ownCollection, privateCollection } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + field: "collectionId", + operator: "in", + value: [ownCollection.id, privateCollection.id], + }, + ], + }, + }); + expect(res.status).toEqual(403); + }); + + it("must not leak docs from a private collection via filtering by teammate userId", async () => { + // No collectionId in filter at all — just createdById of a teammate. + // The default collection scope must still apply, restricting results to + // the viewer's accessible collections. + const { viewer, otherUser, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [{ field: "userId", operator: "eq", value: otherUser.id }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not leak a secret doc by direct documentId lookup", async () => { + const { viewer, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { field: "documentId", operator: "eq", value: secretDoc.id }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not leak secret docs via documentId in[]", async () => { + const { viewer, secretDoc, ownDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + field: "documentId", + operator: "in", + value: [secretDoc.id, ownDoc.id], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + expect(ids).toContain(ownDoc.id); + }); + + it("must not leak archived docs in private collections via archivedAt filter", async () => { + const viewer = await buildUser(); + const otherUser = await buildUser({ teamId: viewer.teamId }); + const privateCollection = await buildCollection({ + teamId: viewer.teamId, + userId: otherUser.id, + permission: null, + }); + const archivedSecret = await buildDocument({ + title: "Archived secret", + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + archivedAt: new Date(), + }); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [{ field: "archivedAt", operator: "isNotNull" }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(archivedSecret.id); + }); + + it("must not bypass collection scope via templateId filter", async () => { + const { viewer, otherUser, privateCollection, secretDoc } = + await setupVictim(); + const template = await buildTemplate({ + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + }); + // Make the secret doc reference the template + await secretDoc.update({ templateId: template.id }); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { field: "templateId", operator: "eq", value: template.id }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not leak docs across teams via cross-team collectionId in OR", async () => { + const viewer = await buildUser(); + const otherTeamUser = await buildUser(); + const otherTeamCollection = await buildCollection({ + teamId: otherTeamUser.teamId, + userId: otherTeamUser.id, + }); + const otherTeamDoc = await buildDocument({ + title: "Other team secret", + teamId: otherTeamUser.teamId, + userId: otherTeamUser.id, + collectionId: otherTeamCollection.id, + }); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { field: "title", operator: "contains", value: "secret" }, + { + field: "collectionId", + operator: "eq", + value: otherTeamCollection.id, + }, + ], + }, + ], + }, + }); + // Either the cross-team collection auth fails (403), or the doc is + // simply not returned because the team filter filters it out. Both + // outcomes are acceptable; what is NOT acceptable is leaking the doc. + if (res.status === 200) { + const body = await res.json(); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(otherTeamDoc.id); + } else { + expect(res.status).toEqual(403); + } + }); + + it("must not leak docs via parentDocumentId membership escape combined with sibling collectionId access", async () => { + // Membership escape on parentDocumentId drops the default collection + // scope. If the schema then ANDs the parentDocumentId leaf with the + // rest of the filter, results must still be restricted to actual + // children of the parent doc. + const viewer = await buildUser(); + const otherUser = await buildUser({ teamId: viewer.teamId }); + const privateCollection = await buildCollection({ + teamId: viewer.teamId, + userId: otherUser.id, + permission: null, + }); + const parent = await buildDocument({ + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + }); + const sibling = await buildDocument({ + title: "Sibling not a child", + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + }); + await UserMembership.create({ + createdById: otherUser.id, + documentId: parent.id, + userId: viewer.id, + permission: DocumentPermission.Read, + }); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { field: "parentDocumentId", operator: "eq", value: parent.id }, + { field: "title", operator: "contains", value: "" }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + // The viewer has membership on `parent`, so its children would be + // returned. But `sibling` is not a child of `parent`, so it must not + // appear despite living in the same private collection. + expect(ids).not.toContain(sibling.id); + }); + + it("must not leak docs via parentDocumentId in OR with collectionId", async () => { + // Even if the filter mentions parentDocumentId in an OR, the membership + // escape must not engage (it requires a single, top-level eq). And the + // OR with collectionId must not drop the default collection scope. + const viewer = await buildUser(); + const otherUser = await buildUser({ teamId: viewer.teamId }); + const privateCollection = await buildCollection({ + teamId: viewer.teamId, + userId: otherUser.id, + permission: null, + }); + const parent = await buildDocument({ + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + }); + const child = await buildDocument({ + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + parentDocumentId: parent.id, + }); + await UserMembership.create({ + createdById: otherUser.id, + documentId: parent.id, + userId: viewer.id, + permission: DocumentPermission.Read, + }); + const ownCollection = await buildCollection({ + teamId: viewer.teamId, + userId: viewer.id, + }); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { field: "parentDocumentId", operator: "eq", value: parent.id }, + { + field: "collectionId", + operator: "eq", + value: ownCollection.id, + }, + ], + }, + ], + }, + }); + const body = await res.json(); + // Inside an OR group, the membership escape on parentDocumentId is not + // triggered. The default collection scope must therefore still apply, + // and the child of `parent` (which lives in a private collection the + // viewer cannot access) must not appear. + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(child.id); + }); + + it("must not return drafts of other users via filter", async () => { + // Draft visibility is enforced by statusFilter handling for the + // legacy path; with filters it must still hold. + const viewer = await buildUser(); + const otherUser = await buildUser({ teamId: viewer.teamId }); + const sharedCollection = await buildCollection({ + teamId: viewer.teamId, + userId: viewer.id, + }); + const otherDraft = await buildDraftDocument({ + title: "Other user draft", + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: sharedCollection.id, + }); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [{ field: "publishedAt", operator: "isNull" }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(otherDraft.id); + }); + + it("must not bypass collection scope via collectionId neq", async () => { + // `neq` must not be considered an explicit collection target. The + // default collection scope must still apply. + const { viewer, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + field: "collectionId", + operator: "neq", + value: "00000000-0000-0000-0000-000000000000", + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not bypass collection scope via collectionId notIn", async () => { + // `notIn` must not be considered an explicit collection target. + const { viewer, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + field: "collectionId", + operator: "notIn", + value: ["00000000-0000-0000-0000-000000000000"], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not bypass collection scope via collectionId isNotNull", async () => { + const { viewer, secretDoc } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [{ field: "collectionId", operator: "isNotNull" }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + }); + + it("must not bypass auth via collectionId pattern matching", async () => { + // contains/startsWith/endsWith on collectionId must not be treated as + // an explicit collection target. Postgres may also error out applying + // iLike to a UUID column — that's a separate availability concern, + // but either way no data must leak. + const { viewer, secretDoc, privateCollection } = await setupVictim(); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + field: "collectionId", + operator: "startsWith", + value: privateCollection.id.substring(0, 8), + }, + ], + }, + }); + // Acceptable: 200 with empty/scoped data, 400 (validation), or 500 + // (SQL type mismatch). Unacceptable: 200 with the secret doc visible. + if (res.status === 200) { + const body = await res.json(); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).not.toContain(secretDoc.id); + } else { + expect([400, 500]).toContain(res.status); + } + }); + + it("must not allow contains-injected SQL wildcards to broaden matches", async () => { + // The `%` and `_` characters in user-supplied contains values must be + // escaped so they cannot match unrelated rows. + const user = await buildUser(); + const decoy = await buildDocument({ + title: "abxc", + teamId: user.teamId, + userId: user.id, + }); + const target = await buildDocument({ + title: "ab_c", + teamId: user.teamId, + userId: user.id, + }); + + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [{ field: "title", operator: "contains", value: "ab_c" }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + expect(ids).toContain(target.id); + expect(ids).not.toContain(decoy.id); + }); + + it("must reject NOT-style operators that are not in the comparison allowlist", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [ + { field: "title", operator: "regexp", value: ".*" } as never, + ], + }, + }); + expect(res.status).toEqual(400); + }); + + it("must reject filters when an empty array is passed", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.list", { + body: { token: user.getJwtToken(), filters: [] }, + }); + expect(res.status).toEqual(400); + }); + + it("must reject filters when in[] is empty", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [{ field: "title", operator: "in", value: [] }], + }, + }); + expect(res.status).toEqual(400); + }); + + it("must not allow exceeding maxFiltersPerGroup limit", async () => { + const user = await buildUser(); + const filters = Array.from({ length: 51 }).map(() => ({ + field: "title", + operator: "eq", + value: "x", + })); + const res = await server.post("/api/documents.list", { + body: { token: user.getJwtToken(), filters }, + }); + expect(res.status).toEqual(400); + }); + + it("must not allow exceeding maxInValues limit on in[]", async () => { + const user = await buildUser(); + const value = Array.from({ length: 101 }).map( + (_, i) => `00000000-0000-0000-0000-${String(i).padStart(12, "0")}` + ); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [{ field: "documentId", operator: "in", value }], + }, + }); + expect(res.status).toEqual(400); + }); + + it("must reject SQL-injection attempts in ISO 8601 duration values", async () => { + // dateFromDuration interpolates the duration into a Sequelize literal. + // The validation regex must reject anything containing quote + // characters or other SQL metacharacters. + const user = await buildUser(); + const res = await server.post("/api/documents.list", { + body: { + token: user.getJwtToken(), + filters: [ + { + field: "updatedAt", + operator: "gte", + value: "P1D'; DROP TABLE documents; --", + }, + ], + }, + }); + // The value isn't a valid duration so it falls through as a literal + // string compared via Op.gte. That comparison will either return zero + // rows or fail at the type-cast level; what matters is no SQL error + // and no leaked rows. Acceptable: 200 with empty data, or 400. + expect([200, 400, 500]).toContain(res.status); + if (res.status === 200) { + const body = await res.json(); + // No rows should leak. + expect(Array.isArray(body.data)).toBe(true); + } + }); + + it("must not leak draft drafts via OR with createdById self + collectionId of teammate's collection", async () => { + // Drafts are restricted to creator + members. Filter DSL must not + // provide a path to bypass that. + const viewer = await buildUser(); + const otherUser = await buildUser({ teamId: viewer.teamId }); + const sharedCollection = await buildCollection({ + teamId: viewer.teamId, + userId: viewer.id, + }); + const otherDraft = await buildDraftDocument({ + title: "Other user secret draft", + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: sharedCollection.id, + }); + + const res = await server.post("/api/documents.list", { + body: { + token: viewer.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { field: "userId", operator: "eq", value: viewer.id }, + { + field: "collectionId", + operator: "eq", + value: sharedCollection.id, + }, + ], + }, + ], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const ids = body.data.map((d: { id: string }) => d.id); + // statusFilter is not set in this request, so drafts shouldn't be + // included by default — but verify either way that the other user's + // draft is not exposed. + expect(ids).not.toContain(otherDraft.id); + }); + }); + it("should require authentication", async () => { const res = await server.post("/api/documents.list"); const body = await res.json(); @@ -1412,6 +2581,207 @@ describe("#documents.search_titles", () => { const res = await server.post("/api/documents.search_titles"); expect(res.status).toEqual(401); }); + + describe("filter DSL", () => { + it("should filter by userId via collaboratorIds", async () => { + const user = await buildUser(); + const document = await buildDocument({ + title: "match title", + teamId: user.teamId, + userId: user.id, + }); + await buildDocument({ + title: "match title", + teamId: user.teamId, + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + query: "match", + filters: [{ field: "userId", operator: "eq", value: user.id }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toHaveLength(1); + expect(body.data[0].id).toEqual(document.id); + }); + + it("should filter by documentId scope", async () => { + const user = await buildUser(); + const parent = await buildDocument({ + title: "parent match", + teamId: user.teamId, + userId: user.id, + }); + const child = await buildDocument({ + title: "child match", + teamId: user.teamId, + userId: user.id, + parentDocumentId: parent.id, + collectionId: parent.collectionId, + }); + await buildDocument({ + title: "unrelated match", + teamId: user.teamId, + userId: user.id, + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + query: "match", + filters: [{ field: "documentId", operator: "eq", value: parent.id }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const returnedIds = body.data.map((d: { id: string }) => d.id).sort(); + expect(returnedIds).toEqual([parent.id, child.id].sort()); + }); + + it("should reject filters combined with legacy userId", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + query: "match", + userId: user.id, + filters: [{ field: "userId", operator: "eq", value: user.id }], + }, + }); + expect(res.status).toEqual(400); + }); + }); + + describe("filter DSL — red team", () => { + it("must reject OR groups at the top level of the filter", async () => { + // Search filters must not allow OR semantics at the top level — that + // is the path that breaks collection scoping in documents.list and + // must not be inherited here. + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + query: "match", + filters: [ + { + operator: "OR", + filters: [ + { field: "collectionId", operator: "eq", value: collection.id }, + { field: "userId", operator: "eq", value: user.id }, + ], + }, + ], + }, + }); + expect(res.status).toEqual(400); + }); + + it("must reject nested OR groups except for known status shapes", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + query: "x", + filters: [ + { field: "collectionId", operator: "eq", value: collection.id }, + { + operator: "OR", + filters: [ + { field: "userId", operator: "eq", value: user.id }, + { field: "title", operator: "contains", value: "x" }, + ], + }, + ], + }, + }); + expect(res.status).toEqual(400); + }); + + it("must reject collectionId operators other than eq", async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: user.getJwtToken(), + query: "x", + filters: [ + { + field: "collectionId", + operator: "in", + value: [collection.id], + }, + ], + }, + }); + expect(res.status).toEqual(400); + }); + + it("must 403 when a private collection is targeted via filter", async () => { + const viewer = await buildUser(); + const otherUser = await buildUser(); + const privateCollection = await buildCollection({ + teamId: otherUser.teamId, + userId: otherUser.id, + permission: null, + }); + const res = await server.post("/api/documents.search_titles", { + body: { + token: viewer.getJwtToken(), + query: "secret", + filters: [ + { + field: "collectionId", + operator: "eq", + value: privateCollection.id, + }, + ], + }, + }); + expect(res.status).toEqual(403); + }); + + it("must 403 on documentId scope to a doc the user cannot read", async () => { + const viewer = await buildUser(); + const otherUser = await buildUser({ teamId: viewer.teamId }); + const privateCollection = await buildCollection({ + teamId: viewer.teamId, + userId: otherUser.id, + permission: null, + }); + const secretParent = await buildDocument({ + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + }); + + const res = await server.post("/api/documents.search_titles", { + body: { + token: viewer.getJwtToken(), + query: "x", + filters: [ + { + field: "documentId", + operator: "eq", + value: secretParent.id, + }, + ], + }, + }); + expect(res.status).toEqual(403); + }); + }); }); describe("#documents.search", () => { @@ -2033,12 +3403,336 @@ describe("#documents.search", () => { const body = await res.json(); expect(res.status).toEqual(200); expect(body.data).toHaveLength(2); - const returnedIds = body.data - .map((d: { document: { id: string } }) => d.document.id) - .sort(); + const returnedIds = body.data.map((d: any) => d.document.id).sort(); const expectedIds = docsInCollection1.map((d) => d.id).sort(); expect(returnedIds).toEqual(expectedIds); }); + + describe("filter DSL", () => { + it("should produce equivalent results for legacy collectionId and filter", async () => { + const user = await buildUser(); + const document = await buildDocument({ + title: "search term", + text: "search term", + teamId: user.teamId, + userId: user.id, + }); + const legacyRes = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + query: "search term", + collectionId: document.collectionId, + }, + }); + const filterRes = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + query: "search term", + filters: [ + { + field: "collectionId", + operator: "eq", + value: document.collectionId, + }, + ], + }, + }); + const legacyBody = await legacyRes.json(); + const filterBody = await filterRes.json(); + expect(legacyRes.status).toEqual(200); + expect(filterRes.status).toEqual(200); + expect( + filterBody.data + .map((r: { document: { id: string } }) => r.document.id) + .sort() + ).toEqual( + legacyBody.data + .map((r: { document: { id: string } }) => r.document.id) + .sort() + ); + }); + + it("should filter by userId via collaboratorIds", async () => { + const user = await buildUser(); + const document = await buildDocument({ + title: "search term", + text: "search term", + teamId: user.teamId, + userId: user.id, + }); + await buildDocument({ + title: "search term", + text: "search term", + teamId: user.teamId, + }); + const res = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + query: "search term", + filters: [{ field: "userId", operator: "eq", value: user.id }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toHaveLength(1); + expect(body.data[0].document.id).toEqual(document.id); + }); + + it("should filter by documentId scope", async () => { + const user = await buildUser(); + const parent = await buildDocument({ + title: "parent", + text: "search term", + teamId: user.teamId, + userId: user.id, + }); + const child = await buildDocument({ + title: "child", + text: "search term", + teamId: user.teamId, + userId: user.id, + parentDocumentId: parent.id, + collectionId: parent.collectionId, + }); + await buildDocument({ + title: "unrelated", + text: "search term", + teamId: user.teamId, + userId: user.id, + }); + const res = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + query: "search term", + filters: [{ field: "documentId", operator: "eq", value: parent.id }], + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + const returnedIds = body.data + .map((r: { document: { id: string } }) => r.document.id) + .sort(); + expect(returnedIds).toEqual([parent.id, child.id].sort()); + }); + + it("should re-run authorize for collectionId in filter", async () => { + const user = await buildUser(); + const otherUser = await buildUser(); + const privateCollection = await buildCollection({ + teamId: otherUser.teamId, + userId: otherUser.id, + permission: null, + }); + const res = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + query: "search term", + filters: [ + { + field: "collectionId", + operator: "eq", + value: privateCollection.id, + }, + ], + }, + }); + expect(res.status).toEqual(403); + }); + + it("should reject filters combined with legacy collectionId", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + const res = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + collectionId: document.collectionId, + filters: [ + { + field: "collectionId", + operator: "eq", + value: document.collectionId, + }, + ], + }, + }); + expect(res.status).toEqual(400); + }); + + it("should reject an unknown field", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + filters: [{ field: "title", operator: "eq", value: "x" }], + }, + }); + expect(res.status).toEqual(400); + }); + + it("should reject an OR group at top level", async () => { + const user = await buildUser(); + const res = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + filters: [ + { + operator: "OR", + filters: [ + { field: "collectionId", operator: "eq", value: user.id }, + { field: "userId", operator: "eq", value: user.id }, + ], + }, + ], + }, + }); + expect(res.status).toEqual(400); + }); + }); + + describe("filter DSL — red team", () => { + it("must not allow OR with collectionId+title to leak across collections", async () => { + // documents.search is supposed to disallow OR groups at the top level; + // verify a sibling OR with a collectionId leaf is also rejected and + // does not silently fall back to a permissive scope. + const viewer = await buildUser(); + const otherUser = await buildUser({ teamId: viewer.teamId }); + const privateCollection = await buildCollection({ + teamId: viewer.teamId, + userId: otherUser.id, + permission: null, + }); + const ownCollection = await buildCollection({ + teamId: viewer.teamId, + userId: viewer.id, + }); + await buildDocument({ + title: "secret material", + text: "secret material body", + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + }); + + const res = await server.post("/api/documents.search", { + body: { + token: viewer.getJwtToken(), + query: "secret material", + filters: [ + { + operator: "OR", + filters: [ + { + field: "collectionId", + operator: "eq", + value: ownCollection.id, + }, + { field: "title", operator: "contains", value: "secret" }, + ], + }, + ], + }, + }); + // OR at top level must be rejected. + expect(res.status).toEqual(400); + }); + + it("must 403 when filter targets a private collection", async () => { + const viewer = await buildUser(); + const otherUser = await buildUser(); + const privateCollection = await buildCollection({ + teamId: otherUser.teamId, + userId: otherUser.id, + permission: null, + }); + const res = await server.post("/api/documents.search", { + body: { + token: viewer.getJwtToken(), + query: "x", + filters: [ + { + field: "collectionId", + operator: "eq", + value: privateCollection.id, + }, + ], + }, + }); + expect(res.status).toEqual(403); + }); + + it("must 403 when filter targets a documentId in a private collection", async () => { + const viewer = await buildUser(); + const otherUser = await buildUser({ teamId: viewer.teamId }); + const privateCollection = await buildCollection({ + teamId: viewer.teamId, + userId: otherUser.id, + permission: null, + }); + const secret = await buildDocument({ + teamId: viewer.teamId, + userId: otherUser.id, + collectionId: privateCollection.id, + }); + const res = await server.post("/api/documents.search", { + body: { + token: viewer.getJwtToken(), + query: "x", + filters: [ + { field: "documentId", operator: "eq", value: secret.id }, + ], + }, + }); + expect(res.status).toEqual(403); + }); + + it("must reject duplicate collectionId leaves to prevent ambiguity", async () => { + const user = await buildUser(); + const c1 = await buildCollection({ teamId: user.teamId, userId: user.id }); + const c2 = await buildCollection({ teamId: user.teamId, userId: user.id }); + const res = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + query: "x", + filters: [ + { field: "collectionId", operator: "eq", value: c1.id }, + { field: "collectionId", operator: "eq", value: c2.id }, + ], + }, + }); + expect(res.status).toEqual(400); + }); + + it("must reject a status group with extra leaves to prevent OR injection", async () => { + // OR groups are only acceptable inside translateSearchFilter for known + // status shapes. An OR containing a non-status leaf must be rejected. + const user = await buildUser(); + const res = await server.post("/api/documents.search", { + body: { + token: user.getJwtToken(), + query: "x", + filters: [ + { + operator: "OR", + filters: [ + { + operator: "AND", + filters: [ + { field: "archivedAt", operator: "isNull" }, + { field: "publishedAt", operator: "isNotNull" }, + ], + }, + { field: "userId", operator: "eq", value: user.id }, + ], + }, + ], + }, + }); + expect(res.status).toEqual(400); + }); + }); }); describe("#documents.templatize", () => { @@ -2351,7 +4045,7 @@ describe("#documents.archived", () => { userId: user.id, teamId: user.teamId, }); - await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx)); + await document.delete(user); const res = await server.post("/api/documents.archived", { body: { token: user.getJwtToken(), @@ -2405,7 +4099,7 @@ describe("#documents.deleted", () => { userId: user.id, teamId: user.teamId, }); - await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx)); + await document.delete(user); const res = await server.post("/api/documents.deleted", { body: { token: user.getJwtToken(), @@ -2437,9 +4131,9 @@ describe("#documents.deleted", () => { collectionId: null, }); await Promise.all([ - withAPIContext(user, (ctx) => document.destroyWithCtx(ctx)), - withAPIContext(user, (ctx) => draftDocument.destroyWithCtx(ctx)), - withAPIContext(user2, (ctx) => otherUserDraft.destroyWithCtx(ctx)), + document.delete(user), + draftDocument.delete(user), + otherUserDraft.delete(user2), ]); const res = await server.post("/api/documents.deleted", { body: { @@ -2460,7 +4154,7 @@ describe("#documents.deleted", () => { userId: admin.id, teamId: admin.teamId, }); - await withAPIContext(admin, (ctx) => document.destroyWithCtx(ctx)); + await document.delete(admin); const res = await server.post("/api/documents.deleted", { body: { token: admin.getJwtToken(), @@ -2483,7 +4177,7 @@ describe("#documents.deleted", () => { teamId: user.teamId, collectionId: collection.id, }); - await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx)); + await document.delete(user); const res = await server.post("/api/documents.deleted", { body: { token: user.getJwtToken(), @@ -3056,7 +4750,7 @@ describe("#documents.restore", () => { collectionId: collection.id, teamId: team.id, }); - await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx)); + await document.delete(user); await collection.destroy({ hooks: false }); const res = await server.post("/api/documents.restore", { @@ -3086,7 +4780,7 @@ describe("#documents.restore", () => { collectionId: collection.id, teamId: team.id, }); - await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx)); + await document.delete(user); await collection.destroy({ hooks: false }); const res = await server.post("/api/documents.restore", { @@ -3220,7 +4914,7 @@ describe("#documents.restore", () => { revisionId, }, headers: { - "x-api-version": "3", + "x-api-version": 3, }, }); const body = await res.json(); @@ -3335,12 +5029,10 @@ describe("#documents.import", () => { collectionId: collection.id, }); - vi.spyOn(FileStorage, "store").mockResolvedValue( - undefined as unknown as string - ); - vi.spyOn(DocumentImportTask.prototype, "schedule").mockResolvedValue({ - finished: vi.fn().mockResolvedValue({ documentId: document.id }), - } as unknown as Awaited>); + jest.spyOn(FileStorage, "store").mockResolvedValue(undefined as any); + jest.spyOn(DocumentImportTask.prototype, "schedule").mockResolvedValue({ + finished: jest.fn().mockResolvedValue({ documentId: document.id }), + } as any); const content = await readFile( path.resolve( @@ -3366,7 +5058,7 @@ describe("#documents.import", () => { expect(res.status).toEqual(200); expect(body.data.id).toEqual(document.id); - vi.restoreAllMocks(); + jest.restoreAllMocks(); }); it("should require authentication", async () => { @@ -4618,7 +6310,7 @@ describe("#documents.unpublish", () => { userId: user.id, teamId: user.teamId, }); - await withAPIContext(user, (ctx) => document.destroyWithCtx(ctx)); + await document.delete(user); const res = await server.post("/api/documents.unpublish", { body: { token: user.getJwtToken(), @@ -5769,7 +7461,7 @@ describe("#documents.documents", () => { expect(res.status).toBe(200); expect(body.data.id).toBe(parent.id); - const childIds = body.data.children.map((node: { id: string }) => node.id); + const childIds = body.data.children.map((node: any) => node.id); expect(childIds).toContain(child1.id); expect(childIds).toContain(child2.id); }); diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index e8f3cac070..8eb88860b3 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -5,7 +5,10 @@ import invariant from "invariant"; import contentDisposition from "content-disposition"; import JSZip from "jszip"; import Router from "koa-router"; -import { escapeRegExp, has, remove, uniq } from "es-toolkit/compat"; +import escapeRegExp from "lodash/escapeRegExp"; +import has from "lodash/has"; +import remove from "lodash/remove"; +import uniq from "lodash/uniq"; import mime from "mime-types"; import type { Order, ScopeOptions, WhereOptions } from "sequelize"; import { Op, Sequelize } from "sequelize"; @@ -62,6 +65,17 @@ import { } from "@server/models"; import AttachmentHelper from "@server/models/helpers/AttachmentHelper"; import { DocumentHelper } from "@server/models/helpers/DocumentHelper"; +import { + authorizeFilterFields, + buildWhere, + combineFilters, + extractTopLevelEqValue, + hasExplicitCollectionId, + hasFieldInFilter, + legacyParamsToFilter, + mapFilterFields, + translateSearchFilter, +} from "@server/models/helpers/Filters"; import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper"; import SearchProviderManager from "@server/utils/SearchProviderManager"; import { TextHelper } from "@server/models/helpers/TextHelper"; @@ -106,12 +120,13 @@ router.post( const { sort, direction, - collectionId, backlinkDocumentId, - parentDocumentId, - userId: createdById, + parentDocumentId: legacyParentDocumentId, + userId: legacyUserId, statusFilter, + filters: rawFilters, } = ctx.input.body; + let { collectionId: legacyCollectionId } = ctx.input.body; const { offset, limit } = ctx.state.pagination; // always filter by the current team @@ -129,51 +144,26 @@ router.post( ], }; - // Exclude archived docs by default - if (!statusFilter) { - where[Op.and].push({ archivedAt: { [Op.eq]: null } }); - } - - // if a specific user is passed then add to filters. If the user doesn't - // exist in the team then nothing will be returned, so no need to check auth - if (createdById) { - where[Op.and].push({ createdById }); - } - - let documentIds: string[] = []; - - // if a specific collection is passed then we need to check auth to view it - if (collectionId) { - where[Op.and].push({ collectionId: [collectionId] }); - const collection = await Collection.findByPk(collectionId, { - userId: user.id, - includeDocumentStructure: sort === "index", - }); - - authorize(user, "readDocument", collection); - - // index sort is special because it uses the order of the documents in the - // collection.documentStructure rather than a database column - if (sort === "index") { - // Extract all document IDs from the collection structure. - documentIds = (collection.documentStructure || []) - .slice(offset, offset + limit) - .map((node) => node.id); - where[Op.and].push({ id: documentIds }); - } // if it's not a backlink request, filter by all collections the user has access to - } else if (!backlinkDocumentId) { - const collectionIds = await user.collectionIds(); - where[Op.and].push({ - collectionId: collectionIds, - }); - } + // Resolve the parent document being targeted from either the legacy + // top-level param or the filters DSL, so the membership escape below + // applies in both cases. `isNull` leaves resolve to undefined here + // (no specific parent to authorize against). + const normalizedFilter = combineFilters(rawFilters); + const parentDocumentId = + legacyParentDocumentId ?? + (normalizedFilter + ? extractTopLevelEqValue(normalizedFilter, "parentDocumentId") + : undefined); + // Membership escape: if the caller is filtering by a parent document they + // are a direct member of (or have group membership to), bypass the default + // collection access check. Mirrors the prior behavior of pushing then + // removing the legacy collectionId predicate. + let collectionScopeDropped = false; if (parentDocumentId) { const [groupMembership, membership] = await Promise.all([ GroupMembership.findOne({ - where: { - documentId: parentDocumentId, - }, + where: { documentId: parentDocumentId }, include: [ { model: Group, @@ -182,37 +172,84 @@ router.post( { model: GroupUser, required: true, - where: { - userId: user.id, - }, + where: { userId: user.id }, }, ], }, ], }), UserMembership.findOne({ - where: { - userId: user.id, - documentId: parentDocumentId, - }, + where: { userId: user.id, documentId: parentDocumentId }, }), ]); if (groupMembership || membership) { - remove(where[Op.and], (cond) => has(cond, "collectionId")); + collectionScopeDropped = true; + legacyCollectionId = undefined; } - - where[Op.and].push({ parentDocumentId }); } - // Explicitly passing 'null' as the parentDocumentId allows listing documents - // that have no parent document (aka they are at the root of the collection) - if (parentDocumentId === null) { - where[Op.and].push({ - parentDocumentId: { - [Op.is]: null, - }, + // The schema rejects callers that combine `filters` with the deprecated + // top-level params, so exactly one of these is set. + const filter = + normalizedFilter ?? + legacyParamsToFilter({ + userId: legacyUserId, + collectionId: legacyCollectionId, + parentDocumentId: legacyParentDocumentId, }); + + // Exclude archived docs by default. Suppressed when the caller targets a + // specific status, or when their filter already references archivedAt. + const filterMentionsArchivedAt = + filter !== undefined && hasFieldInFilter(filter, "archivedAt"); + if (!statusFilter && !filterMentionsArchivedAt) { + where[Op.and].push({ archivedAt: { [Op.eq]: null } }); + } + + // Sort=index needs the collection's documentStructure for ordering and + // pagination. Only meaningful when the filter targets a single collection. + let documentIds: string[] = []; + const explicitCollectionId = + filter !== undefined + ? extractTopLevelEqValue(filter, "collectionId") + : undefined; + if (explicitCollectionId && sort === "index") { + const collection = await Collection.findByPk(explicitCollectionId, { + userId: user.id, + includeDocumentStructure: true, + }); + authorize(user, "readDocument", collection); + documentIds = (collection.documentStructure || []) + .slice(offset, offset + limit) + .map((node) => node.id); + where[Op.and].push({ id: documentIds }); + } + + // Apply filter and re-run authorize() for any auth-bearing fields. Field + // names are mapped from the public API (e.g. `userId`) to the underlying + // column (e.g. `createdById`) only at the point of building the where clause. + if (filter) { + await authorizeFilterFields(user, filter); + const mapped = mapFilterFields(filter, { + userId: "createdById", + documentId: "id", + }); + where[Op.and].push(buildWhere(mapped)); + } + + // Default scope to the user's accessible collections unless the filter + // already restricts to specific collections, the request is a backlink + // lookup, or the parent-doc membership escape applies. + const filterHasExplicitCollection = + filter !== undefined && hasExplicitCollectionId(filter); + if ( + !filterHasExplicitCollection && + !backlinkDocumentId && + !collectionScopeDropped + ) { + const collectionIds = await user.collectionIds(); + where[Op.and].push({ collectionId: collectionIds }); } if (backlinkDocumentId) { @@ -1026,28 +1063,48 @@ router.post( rateLimiter(RateLimiterStrategy.OneHundredPerMinute), validate(T.DocumentsSearchTitlesSchema), async (ctx: APIContext) => { - const { - query, - statusFilter, - dateFilter, - collectionId, - userId, - sort, - direction, - } = ctx.input.body; + const { query, sort, direction, filters: rawFilters } = ctx.input.body; + let { collectionId, userId, documentId, statusFilter, dateFilter } = + ctx.input.body; const { offset, limit } = ctx.state.pagination; const { user } = ctx.state.auth; - let collaboratorIds = undefined; + const filter = combineFilters(rawFilters); + let collaboratorIds: string[] | undefined = undefined; - if (collectionId) { + if (filter) { + try { + const translated = translateSearchFilter(filter, { + allowDocumentId: true, + }); + collectionId = translated.collectionId; + collaboratorIds = translated.collaboratorIds; + documentId = translated.documentId; + statusFilter = translated.statusFilter; + dateFilter = translated.dateFilter; + } catch (err) { + throw InvalidRequestError( + err instanceof Error ? err.message : "Invalid filter" + ); + } + await authorizeFilterFields(user, filter); + } else if (userId) { + collaboratorIds = [userId]; + } + + if (collectionId && !filter) { const collection = await Collection.findByPk(collectionId, { userId: user.id, }); authorize(user, "readDocument", collection); } - if (userId) { - collaboratorIds = [userId]; + let documentIds: string[] | undefined = undefined; + if (documentId) { + const document = await Document.findByPk(documentId, { + userId: user.id, + }); + authorize(user, "read", document); + documentIds = [documentId, ...(await document.findAllChildDocumentIds())]; } const documents = @@ -1057,6 +1114,7 @@ router.post( statusFilter, collectionId, collaboratorIds, + documentIds, offset, limit, sort: sort as SortFilter, @@ -1082,17 +1140,21 @@ router.post( async (ctx: APIContext) => { const { query, - collectionId, - documentId, - userId, - dateFilter, - statusFilter = [], shareId, snippetMinWords, snippetMaxWords, sort, direction, + filters: rawFilters, } = ctx.input.body; + let { + collectionId, + documentId, + userId, + dateFilter, + statusFilter = [], + } = ctx.input.body; + const filter = combineFilters(rawFilters); const { offset, limit } = ctx.state.pagination; const { user } = ctx.state.auth; @@ -1155,8 +1217,29 @@ router.post( } teamId = user.teamId; + let collaboratorIds: string[] | undefined = undefined; - if (collectionId) { + if (filter) { + try { + const translated = translateSearchFilter(filter, { + allowDocumentId: true, + }); + collectionId = translated.collectionId; + collaboratorIds = translated.collaboratorIds; + documentId = translated.documentId; + dateFilter = translated.dateFilter; + statusFilter = translated.statusFilter ?? []; + } catch (err) { + throw InvalidRequestError( + err instanceof Error ? err.message : "Invalid filter" + ); + } + await authorizeFilterFields(user, filter); + } else if (userId) { + collaboratorIds = [userId]; + } + + if (collectionId && !filter) { const collection = await Collection.findByPk(collectionId, { userId: user.id, }); @@ -1175,12 +1258,6 @@ router.post( ]; } - let collaboratorIds = undefined; - - if (userId) { - collaboratorIds = [userId]; - } - response = await SearchProviderManager.getProvider().searchForUser(user, { query, collaboratorIds, @@ -1501,9 +1578,7 @@ router.post( "documents.delete", auth(), validate(T.DocumentsDeleteSchema), - transaction(), async (ctx: APIContext) => { - const { transaction } = ctx.state; const { id, permanent } = ctx.input.body; const { user } = ctx.state.auth; @@ -1511,7 +1586,6 @@ router.post( const document = await Document.findByPk(id, { userId: user.id, paranoid: false, - transaction, }); authorize(user, "permanentDelete", document); @@ -1527,12 +1601,11 @@ router.post( } else { const document = await Document.findByPk(id, { userId: user.id, - transaction, }); authorize(user, "delete", document); - await document.destroyWithCtx(ctx); + await document.delete(user); } ctx.body = { diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index d50844cd15..b498d52ac7 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -1,6 +1,7 @@ import type formidable from "formidable"; -import { isEmpty } from "es-toolkit/compat"; +import isEmpty from "lodash/isEmpty"; import { z } from "zod"; +import { createFilterSchema } from "@shared/helpers/FilterHelper"; import { DirectionFilter, DocumentPermission, @@ -8,10 +9,24 @@ import { TextEditMode, SortFilter, } from "@shared/types"; +import { FilterValidation } from "@shared/validations"; import { BaseSchema } from "@server/routes/api/schema"; import { zodIconType, zodIdType, zodShareIdType } from "@server/utils/zod"; import { ValidateColor } from "@server/validation"; +const documentFilter = createFilterSchema([ + "createdAt", + "updatedAt", + "publishedAt", + "archivedAt", + "title", + "templateId", + "collectionId", + "userId", + "documentId", + "parentDocumentId", +] as const); + const DocumentsSortParamsSchema = z.object({ /** Specifies the attributes by which documents will be sorted in the list */ sort: z @@ -36,7 +51,11 @@ const DocumentsSortParamsSchema = z.object({ }); const DateFilterSchema = z.object({ - /** Date filter */ + /** + * Date filter. + * @deprecated use `filters` with `updatedAt` and an ISO 8601 duration + * (`-P1D`, `-P1W`, `-P1M`, `-P1Y`) instead. + */ dateFilter: z .union([ z.literal("day"), @@ -48,16 +67,28 @@ const DateFilterSchema = z.object({ }); const BaseSearchSchema = DateFilterSchema.extend({ - /** Filter results for team based on the collection */ + /** + * Filter results for team based on the collection. + * @deprecated use `filters` with field `collectionId` instead. + */ collectionId: z.uuid().optional(), - /** Filter results based on user */ + /** + * Filter results based on user. + * @deprecated use `filters` with field `userId` instead. + */ userId: z.uuid().optional(), - /** Filter results based on content within a document and it's children */ + /** + * Filter results based on content within a document and it's children. + * @deprecated use `filters` with field `documentId` instead. + */ documentId: z.uuid().optional(), - /** Document statuses to include in results */ + /** + * Document statuses to include in results. + * @deprecated use `filters` with `archivedAt`/`publishedAt` instead. + */ statusFilter: z.enum(StatusFilter).array().optional(), /** Filter results for the team derived from shareId */ @@ -77,36 +108,79 @@ const BaseIdSchema = z.object({ export const DocumentsListSchema = BaseSchema.extend({ body: DocumentsSortParamsSchema.extend({ - /** Id of the user who created the doc */ + /** + * Id of the user who created the doc. + * @deprecated use `filters` with field `userId` instead. + */ userId: z.uuid().optional(), - /** Alias for userId - kept for backwards compatibility */ + /** + * Alias for userId - kept for backwards compatibility. + * @deprecated use `filters` with field `userId` instead. + */ user: z.uuid().optional(), - /** Id of the collection to which the document belongs */ + /** + * Id of the collection to which the document belongs. + * @deprecated use `filters` with field `collectionId` instead. + */ collectionId: z.uuid().optional(), - /** Alias for collectionId - kept for backwards compatibility */ + /** + * Alias for collectionId - kept for backwards compatibility. + * @deprecated use `filters` with field `collectionId` instead. + */ collection: z.uuid().optional(), /** Id of the backlinked document */ backlinkDocumentId: z.uuid().optional(), - /** Id of the parent document to which the document belongs */ + /** + * Id of the parent document to which the document belongs. + * @deprecated use `filters` with field `parentDocumentId` instead. + */ parentDocumentId: z.uuid().nullish(), - /** Document statuses to include in results */ + /** + * Document statuses to include in results. + * @deprecated use `filters` with `archivedAt`/`publishedAt` instead. + */ statusFilter: z.enum(StatusFilter).array().optional(), + + /** List of filter expressions. Implicit AND between top-level entries. */ + filters: z + .array(documentFilter.FilterSchema) + .min(1) + .max(FilterValidation.maxFiltersPerGroup) + .optional(), }), // Maintains backwards compatibility -}).transform((req) => { - req.body.collectionId = req.body.collectionId || req.body.collection; - req.body.userId = req.body.userId || req.body.user; - delete req.body.collection; - delete req.body.user; +}) + .transform((req) => { + req.body.collectionId = req.body.collectionId || req.body.collection; + req.body.userId = req.body.userId || req.body.user; + delete req.body.collection; + delete req.body.user; - return req; -}); + return req; + }) + .refine( + (req) => { + if (req.body.filters === undefined) { + return true; + } + return ( + req.body.userId === undefined && + req.body.collectionId === undefined && + req.body.parentDocumentId === undefined && + req.body.statusFilter === undefined + ); + }, + { + message: + "filters cannot be combined with deprecated parameters userId, collectionId, parentDocumentId, or statusFilter", + } + ); export type DocumentsListReq = z.infer; @@ -194,6 +268,26 @@ export const DocumentsRestoreSchema = BaseSchema.extend({ export type DocumentsRestoreReq = z.infer; +const filterIncompatibleWithLegacy = (req: { + body: { + filters?: unknown; + collectionId?: unknown; + userId?: unknown; + documentId?: unknown; + dateFilter?: unknown; + statusFilter?: unknown; + }; +}) => + req.body.filters === undefined || + (req.body.collectionId === undefined && + req.body.userId === undefined && + req.body.documentId === undefined && + req.body.dateFilter === undefined && + req.body.statusFilter === undefined); + +const filterIncompatibleWithLegacyMessage = + "filters cannot be combined with deprecated parameters collectionId, userId, documentId, dateFilter, or statusFilter"; + export const DocumentsSearchSchema = BaseSchema.extend({ body: BaseSearchSchema.extend({ /** Query for search */ @@ -206,7 +300,16 @@ export const DocumentsSearchSchema = BaseSchema.extend({ direction: z .enum(Object.values(DirectionFilter) as [string, ...string[]]) .optional(), + + /** List of filter expressions. Implicit AND between top-level entries. */ + filters: z + .array(documentFilter.FilterSchema) + .min(1) + .max(FilterValidation.maxFiltersPerGroup) + .optional(), }), +}).refine(filterIncompatibleWithLegacy, { + message: filterIncompatibleWithLegacyMessage, }); export type DocumentsSearchReq = z.infer; @@ -223,7 +326,16 @@ export const DocumentsSearchTitlesSchema = BaseSchema.extend({ direction: z .enum(Object.values(DirectionFilter) as [string, ...string[]]) .optional(), + + /** List of filter expressions. Implicit AND between top-level entries. */ + filters: z + .array(documentFilter.FilterSchema) + .min(1) + .max(FilterValidation.maxFiltersPerGroup) + .optional(), }), +}).refine(filterIncompatibleWithLegacy, { + message: filterIncompatibleWithLegacyMessage, }); export type DocumentsSearchTitlesReq = z.infer< diff --git a/server/validation.test.ts b/server/validation.test.ts index 10a282184a..245047d3a7 100644 --- a/server/validation.test.ts +++ b/server/validation.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { Buckets } from "./models/helpers/AttachmentHelper"; -import { ValidateKey } from "./validation"; +import { ValidateKey, isISO8601Duration } from "./validation"; describe("#ValidateKey.isValid", () => { it("should return false if number of key components is incorrect", () => { @@ -48,7 +48,7 @@ describe("#ValidateKey.sanitize", () => { const uuid1 = randomUUID(); const uuid2 = randomUUID(); expect( - ValidateKey.sanitize(`public/${uuid1}/${uuid2}/~.\u0000malicious_key`) + ValidateKey.sanitize(`public/${uuid1}/${uuid2}/~\.\u0000\malicious_key`) ).toEqual(`public/${uuid1}/${uuid2}/~.malicious_key`); }); @@ -68,3 +68,103 @@ describe("#ValidateKey.sanitize", () => { ); }); }); + +describe("#isISO8601Duration", () => { + describe("valid cases", () => { + it.each([ + "P1Y", + "P1M", + "P1D", + "P1W", + "P52W", + "P0D", + "P10Y", + "P365D", + "PT1H", + "PT30M", + "PT45S", + "PT1H30M", + "PT1H30M45S", + "PT24H", + "PT1500S", + "PT0S", + "P1Y2M", + "P1Y2M3D", + "P2M3D", + "P1DT12H", + "P1Y2M3DT4H5M6S", + "-P1D", + "-P7D", + "-PT1H", + "-P1Y2M3DT4H5M6S", + "-P1W", + ])("accepts %s", (value) => { + expect(isISO8601Duration(value)).toBe(true); + }); + }); + + describe("invalid cases", () => { + it.each([ + // Missing prefix + "1D", + "T1H", + "Y1", + // Empty / lone prefix + "", + "P", + "PT", + "-P", + "-PT", + // Missing number + "PD", + "PMW", + "PT H", + // Missing unit + "P1", + "PT1", + "P1Y2", + // Unknown unit + "P1Z", + "P1X", + "PT1Q", + // Wrong section (date vs time) + "P1H", + "PT1Y", + "PT1D", + // Trailing T + "P1DT", + "P1YT", + // Weeks combined with other units + "P1W1D", + "P1Y1W", + // Decimals + "P1.5D", + "P0.5W", + "PT1.5H", + // Sign in wrong place + "P-1D", + "PT-1H", + "--P1D", + "+P1D", + // Whitespace + "P 1D", + "P1D ", + " P1D", + "- P1D", + // Lowercase + "p1d", + "P1d", + "pt1h", + // Quotes / SQL metacharacters + "P1D'", + "P1D; DROP TABLE", + "P1D--", + "P1D OR 1=1", + // Order swap + "P1D1Y", + "P1M1Y", + ])("rejects %j", (value) => { + expect(isISO8601Duration(value)).toBe(false); + }); + }); +}); diff --git a/server/validation.ts b/server/validation.ts index 27aa093f68..c0322cbe80 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -1,4 +1,4 @@ -import { isArrayLike } from "es-toolkit/compat"; +import isArrayLike from "lodash/isArrayLike"; import sanitize from "sanitize-filename"; import type { Primitive } from "utility-types"; import validator from "validator"; @@ -55,11 +55,7 @@ export function assertKeysIn( Object.keys(obj).forEach((key) => assertIn(key, Object.values(type))); } -export const assertSort = ( - value: string, - model: { rawAttributes: Record }, - message?: string -) => { +export const assertSort = (value: string, model: any, message?: string) => { if (!Object.keys(model.rawAttributes).includes(value)) { throw ValidationError( message ?? `${String(value)} is not a valid sort field` @@ -147,6 +143,38 @@ export const assertPositiveInteger = ( } }; +const ISO8601_DURATION_RE = + /^-?P(?:(\d+W)|((?:\d+Y)?(?:\d+M)?(?:\d+D)?)(T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?)$/; + +/** + * Validate a string against the ISO 8601 duration format. + * + * Supported subset: an optional leading `-`, then `P[nY][nM][nW][nD][T[nH][nM][nS]]`. + * The weeks form (`PnW`) is mutually exclusive with year/month/day units. + * Decimals are not supported. + * + * @param value the candidate string. + * @returns true if the string is a syntactically valid ISO 8601 duration. + */ +export function isISO8601Duration(value: string): boolean { + const m = ISO8601_DURATION_RE.exec(value); + if (!m) { + return false; + } + const [, weeks, date, time] = m; + if (weeks) { + return true; + } + // A bare `T` separator with no following time unit is invalid even if a date + // portion is present (e.g. `P1DT` should be rejected). + if (time === "T") { + return false; + } + const hasDate = !!date && date.length > 0; + const hasTime = !!time && time.length > 1; + return hasDate || hasTime; +} + export const assertHexColor = (value: string, message?: string) => { if (!validateColorHex(value)) { throw ValidationError( diff --git a/shared/helpers/FilterHelper.ts b/shared/helpers/FilterHelper.ts new file mode 100644 index 0000000000..dfeefd8ab0 --- /dev/null +++ b/shared/helpers/FilterHelper.ts @@ -0,0 +1,175 @@ +import { z } from "zod"; +import { FilterValidation } from "../validations"; + +export const ComparisonOperator = z.enum([ + "eq", + "neq", + "lt", + "lte", + "gt", + "gte", + "contains", + "startsWith", + "endsWith", + "in", + "notIn", + "isNull", + "isNotNull", +]); +export type ComparisonOperator = z.infer; + +export const LogicalOperator = z.enum(["AND", "OR"]); +export type LogicalOperator = z.infer; + +export const FilterValue = z.union([ + z.string(), + z.number(), + z.boolean(), + z.date(), + z.array(z.string()), + z.array(z.number()), +]); +export type FilterValue = z.infer; + +export interface FilterCondition { + field: F; + operator: ComparisonOperator; + value?: FilterValue; +} + +export interface FilterGroup { + operator: LogicalOperator; + filters: Array | FilterGroup>; +} + +export type Filter = + | FilterCondition + | FilterGroup; + +function isGroup(filter: Filter): filter is FilterGroup { + return "filters" in filter; +} + +function depthOf(filter: Filter): number { + if (isGroup(filter)) { + return 1 + Math.max(...filter.filters.map(depthOf)); + } + return 1; +} + +/** + * Build a zod schema for a typed filter DSL constrained to a field allowlist. + * + * @param fields list of allowed field names for this entity. + * @returns the composed FilterSchema along with its FilterCondition / FilterGroup parts. + */ +export function createFilterSchema( + fields: F +) { + const FieldEnum = z.enum(fields); + + const FilterConditionSchema = z + .object({ + field: FieldEnum, + operator: ComparisonOperator, + value: FilterValue.optional(), + }) + .superRefine((data, ctx) => { + const { operator, value } = data; + const isArrayOp = operator === "in" || operator === "notIn"; + const isNullOp = operator === "isNull" || operator === "isNotNull"; + const isStringOp = + operator === "contains" || + operator === "startsWith" || + operator === "endsWith"; + + if (isNullOp) { + if (value !== undefined) { + ctx.addIssue({ + code: "custom", + message: `value must not be provided for operator '${operator}'`, + path: ["value"], + }); + } + return; + } + + if (value === undefined) { + ctx.addIssue({ + code: "custom", + message: `value is required for operator '${operator}'`, + path: ["value"], + }); + return; + } + + if (isArrayOp) { + if (!Array.isArray(value) || value.length === 0) { + ctx.addIssue({ + code: "custom", + message: `value must be a non-empty array for operator '${operator}'`, + path: ["value"], + }); + return; + } + if (value.length > FilterValidation.maxInValues) { + ctx.addIssue({ + code: "custom", + message: `value must contain at most ${FilterValidation.maxInValues} entries for operator '${operator}'`, + path: ["value"], + }); + } + return; + } + + if (Array.isArray(value)) { + ctx.addIssue({ + code: "custom", + message: `value must not be an array for operator '${operator}'`, + path: ["value"], + }); + return; + } + + if (isStringOp && typeof value !== "string") { + ctx.addIssue({ + code: "custom", + message: `value must be a string for operator '${operator}'`, + path: ["value"], + }); + } + }); + + type Condition = z.infer; + type Group = { + operator: LogicalOperator; + filters: Array; + }; + + const FilterGroupSchema: z.ZodType = z.lazy(() => + z.object({ + operator: LogicalOperator, + filters: z + .array(z.union([FilterConditionSchema, FilterGroupSchema])) + .min(1) + .max(FilterValidation.maxFiltersPerGroup), + }) + ); + + const FilterSchema = z + .union([FilterConditionSchema, FilterGroupSchema]) + .superRefine((data, ctx) => { + if (depthOf(data as Filter) > FilterValidation.maxDepth) { + ctx.addIssue({ + code: "custom", + message: `filter nesting depth exceeds maximum of ${FilterValidation.maxDepth}`, + }); + } + }); + + return { + FilterCondition: FilterConditionSchema, + FilterGroup: FilterGroupSchema, + FilterSchema, + }; +} diff --git a/shared/validations.ts b/shared/validations.ts index d97da1b1e7..28c43e67d5 100644 --- a/shared/validations.ts +++ b/shared/validations.ts @@ -173,6 +173,17 @@ export const WebhookSubscriptionValidation = { maxUrlLength: 255, }; +export const FilterValidation = { + /** The maximum nesting depth of a filter expression */ + maxDepth: 5, + + /** The maximum number of filters in a single group (or top-level array) */ + maxFiltersPerGroup: 50, + + /** The maximum number of values in an `in` / `notIn` array */ + maxInValues: 100, +}; + export const EmojiValidation = { /** The maximum length of the emoji name */ maxNameLength: 25,