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,