Files
outline/shared/validations.ts
T
Claude 792a5f6a72 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
2026-05-10 19:02:48 +00:00

193 lines
4.8 KiB
TypeScript

export const AttachmentValidation = {
/** The limited allowable mime-types for user and team avatars */
avatarContentTypes: ["image/jpg", "image/jpeg", "image/png"],
/** The most widely supported mime-types across modern browsers */
emojiContentTypes: [
"image/png",
"image/webp",
"image/gif",
"image/jpeg",
"image/jpg",
],
/** The maximum file size for emoji uploads */
emojiMaxFileSize: 1 * 1000 * 1000,
/** Image mime-types commonly supported by modern browsers */
imageContentTypes: [
"image/jpg",
"image/jpeg",
"image/pjpeg",
"image/png",
"image/apng",
"image/avif",
"image/gif",
"image/webp",
"image/svg",
"image/svg+xml",
"image/bmp",
"image/tiff",
"image/heic",
],
};
export const ApiKeyValidation = {
/** The minimum length of the API key name */
minNameLength: 3,
/** The maximum length of the API key name */
maxNameLength: 255,
};
export const CollectionValidation = {
/** The maximum length of the collection description */
maxDescriptionLength: 100 * 1000,
/** The maximum length of the collection name */
maxNameLength: 100,
};
export const CommentValidation = {
/** The maximum length of a comment */
maxLength: 1000,
};
export const DocumentValidation = {
/** The maximum length of the document title */
maxTitleLength: 100,
/** The maximum length of the document summary */
maxSummaryLength: 1000,
/** The maximum size of the collaborative document state */
maxStateLength: 1500 * 1024,
/** The maximum recommended size of the document content */
maxRecommendedLength: 250000,
};
export const GroupValidation = {
/** The maximum length of the group name */
maxNameLength: 255,
/** The maximum length of the group description */
maxDescriptionLength: 2000,
};
export const ImportValidation = {
/** The maximum length of the import name */
maxNameLength: 100,
};
export const OAuthClientValidation = {
/** The maximum length of the OAuth client name */
maxNameLength: 100,
/** The maximum length of the OAuth client description */
maxDescriptionLength: 255,
/** The maximum length of the OAuth client developer name */
maxDeveloperNameLength: 100,
/** The maximum length of the OAuth client developer URL */
maxDeveloperUrlLength: 255,
/** The maximum length of the OAuth client avatar URL */
maxAvatarUrlLength: 255,
/** The maximum length of an OAuth client redirect URI */
maxRedirectUriLength: 255,
/** The allowed OAuth client types */
clientTypes: ["confidential", "public"] as const,
};
export const ShareValidation = {
/** The maximum length of the share title */
maxTitleLength: 255,
/** The maximum length of the share iconUrl */
maxIconUrlLength: 4096,
};
export const RevisionValidation = {
minNameLength: 1,
maxNameLength: 255,
};
export const UserPasskeyValidation = {
minNameLength: 1,
maxNameLength: 255,
};
export const PinValidation = {
/** The maximum number of pinned documents on an individual collection or home screen */
max: 8,
};
export const TeamValidation = {
/** The maximum number of domains per team on cloud hosted */
maxDomains: 10,
/** The maximum length of the team name */
maxNameLength: 255,
/** The maximum length of the team description */
maxDescriptionLength: 1000,
/** The minimum length of the team subdomain */
minSubdomainLength: 2,
/** The maximum length of the team subdomain for cloud */
maxSubdomainLength: 32,
/** The maximum length of the team subdomain for self-hosted */
maxSubdomainSelfHostedLength: 255,
/** The maximum length of a team domain */
maxDomainLength: 255,
/** The maximum length of MCP workspace guidance */
maxGuidanceMCPLength: 10000,
/** The recommended length of MCP workspace guidance, beyond which a warning is shown */
warnGuidanceMCPLength: 2000,
};
export const UserValidation = {
/** The maximum number of invites per request */
maxInvitesPerRequest: 20,
/** The maximum length of the user name */
maxNameLength: 255,
/** The maximum length of the user email */
maxEmailLength: 255,
};
export const WebhookSubscriptionValidation = {
/** The maximum number of webhooks per team */
maxSubscriptions: 10,
/** The maximum length of the webhook name */
maxNameLength: 255,
/** The maximum length of the webhook url */
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,
/* the characters allowed in the name */
allowedNameCharacters: /^[a-z0-9_]*$/,
};