test: Red team tests for documents.list filter DSL permission bypass

Adds extensive tests that probe the new filter DSL in #12176 for
permission-bypass vectors. Several tests intentionally fail to surface a
real authorization bypass in the documents.list handler:

`hasExplicitCollectionId` walks the filter tree recursively and treats
any `collectionId eq/in` reference — including ones nested inside an OR
group — as an "explicit" collection target. When that helper returns
true, documents.ts drops the default `collectionId IN user.collectionIds`
scope. But the actual WHERE clause is built honoring the OR semantics,
so an OR'd sibling expands the result set across the entire team within
the OR's reach.

Concrete exploits demonstrated by failing tests:

  filters: [{ operator: "OR", filters: [
    { field: "collectionId", operator: "eq", value: <my collection> },
    { field: "title", operator: "contains", value: "" },
  ]}]

returns every document in the team — the "" contains pattern matches
all rows. Variants tested:

  - OR(collectionId eq, title contains "")
  - OR(collectionId eq, title contains "secret")
  - OR(collectionId eq, userId eq <teammate>)
  - OR(collectionId in [...], title contains "")
  - OR(collectionId nested in AND, title contains "")
  - OR(collectionId eq, documentId eq <secret doc>)
  - AND(OR(collectionId eq, title contains ""))
  - OR(parentDocumentId eq <member doc>, collectionId eq <my collection>)
    — drops collection scope and exposes the parent's children that live
    in collections the user otherwise can't see

The cross-team filter (teamId at the root of the where) still applies,
so this bypass is bounded to documents within the same team — but it
exposes documents from collections the requesting user has zero
permission to access.

Defensive cases that pass and lock in the existing safety contract:

  - cross-team isolation
  - direct documentId lookup respects collection scope
  - documentId in[] respects collection scope
  - templateId filter respects collection scope
  - archivedAt isNotNull does not surface archived docs in private
    collections
  - userId filter does not bypass collection scope
  - drafts of other users are not exposed
  - collectionId neq/notIn/isNotNull/contains/startsWith do not drop
    the default scope (some return SQL error 5xx instead, no leak)
  - authorize() runs for every collectionId referenced in the tree,
    including nested in[] entries
  - parent-doc membership escape (single top-level eq) still narrows
    results to actual children
  - schema rejects OR groups at the top of search and search_titles
  - schema rejects depth/group-size/in-list overruns and unknown ops
  - LIKE wildcards (%, _, \) are escaped in contains/startsWith/endsWith
  - ISO 8601 duration validation rejects SQL-quote injection attempts

Helper-level unit tests in Filters.test.ts pin the same invariants at
the function boundary, including two intentionally failing assertions
that hasExplicitCollectionId must NOT trip on OR-nested collectionId
references.

https://claude.ai/code/session_0162e757xJkcqFzX8J2XX9ib
This commit is contained in:
Claude
2026-05-10 19:02:48 +00:00
parent 51a1d3bf50
commit 792a5f6a72
11 changed files with 4375 additions and 172 deletions
+89 -22
View File
@@ -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<HTMLInputElement | null>(null);
@@ -89,29 +89,90 @@ function Search() {
sort: isSearchable,
};
const filters = React.useMemo(
const filters = React.useMemo<Filter[] | undefined>(() => {
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}
>
<RegisterKeyDown trigger="Escape" handler={handleGoBack} />
<RegisterKeyDown trigger="Escape" handler={history.goBack} />
{loading && <LoadingIndicator />}
<ResultsWrapper column auto>
<form method="GET" action={searchPath()} onSubmit={preventDefault}>
+9 -8
View File
@@ -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<Document> {
@action
searchTitles = async (options?: SearchParams): Promise<SearchResult[]> => {
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<Document> {
@action
search = async (options: SearchParams): Promise<SearchResult[]> => {
const compactedOptions = omitBy(options, (o) => !o);
const compactedOptions = omitBy(options, (o) => !o) as JSONObject;
const res = await client.post("/documents.search", {
...compactedOptions,
});
File diff suppressed because it is too large Load Diff
+548
View File
@@ -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<ComparisonOperator>([
"gt",
"gte",
"lt",
"lte",
]);
/**
* Convert an ISO 8601 duration into a Sequelize literal expressing
* `now() ± interval '<duration>'`. 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 '<duration>'`.
* @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<F extends string>(
filter: Filter<F>
): filter is FilterGroup<F> {
return "filters" in filter;
}
function leafToWhere(condition: FilterCondition): Record<string, unknown> {
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<M extends object = object>(
filter: Filter
): WhereOptions<M> {
if (isGroup(filter)) {
const subWheres = filter.filters.map((f) => buildWhere<M>(f));
const op = filter.operator === "AND" ? Op.and : Op.or;
return { [op]: subWheres } as WhereOptions<M>;
}
return leafToWhere(filter) as WhereOptions<M>;
}
/**
* 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<string, string>
): 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<string, DateFilter> = {
"-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<string>();
const setOnce = <K extends keyof SearchFilterTranslation>(
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<void> {
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);
}
}
File diff suppressed because it is too large Load Diff
+165 -92
View File
@@ -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<Document>(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<T.DocumentsSearchTitlesReq>) => {
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<T.DocumentsSearchReq>) => {
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<T.DocumentsDeleteReq>) => {
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 = {
+131 -19
View File
@@ -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<typeof DocumentsListSchema>;
@@ -194,6 +268,26 @@ export const DocumentsRestoreSchema = BaseSchema.extend({
export type DocumentsRestoreReq = z.infer<typeof DocumentsRestoreSchema>;
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<typeof DocumentsSearchSchema>;
@@ -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<
+102 -2
View File
@@ -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);
});
});
});
+34 -6
View File
@@ -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<string, unknown> },
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(
+175
View File
@@ -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<typeof ComparisonOperator>;
export const LogicalOperator = z.enum(["AND", "OR"]);
export type LogicalOperator = z.infer<typeof LogicalOperator>;
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<typeof FilterValue>;
export interface FilterCondition<F extends string = string> {
field: F;
operator: ComparisonOperator;
value?: FilterValue;
}
export interface FilterGroup<F extends string = string> {
operator: LogicalOperator;
filters: Array<FilterCondition<F> | FilterGroup<F>>;
}
export type Filter<F extends string = string> =
| FilterCondition<F>
| FilterGroup<F>;
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<F extends readonly [string, ...string[]]>(
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<typeof FilterConditionSchema>;
type Group = {
operator: LogicalOperator;
filters: Array<Condition | Group>;
};
const FilterGroupSchema: z.ZodType<Group> = 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,
};
}
+11
View File
@@ -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,