Files
outline/server/validation.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

304 lines
7.9 KiB
TypeScript

import isArrayLike from "lodash/isArrayLike";
import sanitize from "sanitize-filename";
import type { Primitive } from "utility-types";
import validator from "validator";
import isIn from "validator/lib/isIn";
import isUUID from "validator/lib/isUUID";
import { CollectionPermission, MentionType } from "@shared/types";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { validateColorHex } from "@shared/utils/color";
import { validateIndexCharacters } from "@shared/utils/indexCharacters";
import parseMentionUrl from "@shared/utils/parseMentionUrl";
import { isUrl } from "@shared/utils/urls";
import { ParamRequiredError, ValidationError } from "./errors";
import { Buckets } from "./models/helpers/AttachmentHelper";
type IncomingValue = Primitive | string[];
export const assertPresent = (value: IncomingValue, message: string) => {
if (value === undefined || value === null || value === "") {
throw ParamRequiredError(message);
}
};
export function assertArray(
value: IncomingValue,
message?: string
): asserts value {
if (!isArrayLike(value)) {
throw ValidationError(message ?? `${String(value)} is not an array`);
}
}
export const assertIn = (
value: string,
options: Primitive[],
message?: string
) => {
if (!options.includes(value)) {
throw ValidationError(message ?? `Must be one of ${options.join(", ")}`);
}
};
/**
* Asserts that an object contains no other keys than specified
* by a type
*
* @param obj The object to check for assertion
* @param type The type to check against
* @throws {ValidationError}
*/
export function assertKeysIn(
obj: Record<string, unknown>,
type: { [key: string]: number | string }
) {
Object.keys(obj).forEach((key) => assertIn(key, Object.values(type)));
}
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`
);
}
};
export function assertNotEmpty(
value: IncomingValue,
message: string
): asserts value {
assertPresent(value, message);
if (typeof value === "string" && value.trim() === "") {
throw ValidationError(message ?? `${String(value)} is empty`);
}
}
export function assertEmail(
value: IncomingValue = "",
message?: string
): asserts value {
if (typeof value !== "string" || !validator.isEmail(value)) {
throw ValidationError(message ?? `${String(value)} is not a valid email`);
}
}
export function assertUrl(
value: IncomingValue = "",
message?: string
): asserts value {
if (
typeof value !== "string" ||
!validator.isURL(value, {
protocols: ["http", "https"],
require_valid_protocol: true,
})
) {
throw ValidationError(message ?? `${String(value)} is an invalid url`);
}
}
/**
* Asserts that the passed value is a valid boolean
*
* @param value The value to check for assertion
* @param [message] The error message to show
* @throws {ValidationError}
*/
export function assertBoolean(
value: IncomingValue,
message?: string
): asserts value {
if (typeof value !== "boolean") {
throw ValidationError(message ?? `${String(value)} is not a boolean`);
}
}
export function assertUuid(
value: IncomingValue,
message?: string
): asserts value {
if (typeof value !== "string") {
throw ValidationError(
message ?? `${String(value)} is not a string, expected UUID`
);
}
if (!validator.isUUID(value)) {
throw ValidationError(message ?? `${String(value)} is not a valid UUID`);
}
}
export const assertPositiveInteger = (
value: IncomingValue,
message?: string
) => {
if (
!validator.isInt(String(value), {
min: 0,
})
) {
throw ValidationError(
message ?? `${String(value)} is not a positive integer`
);
}
};
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(
message ?? `${String(value)} is not a valid hex color`
);
}
};
export const assertValueInArray = (
value: string,
values: string[],
message?: string
) => {
if (!values.includes(value)) {
throw ValidationError(
message ?? `${String(value)} is not in the allowed values`
);
}
};
export const assertIndexCharacters = (
value: string,
message = "index must be between x20 to x7E ASCII"
) => {
if (!validateIndexCharacters(value)) {
throw ValidationError(message ?? `${String(value)} is not a valid index`);
}
};
export const assertCollectionPermission = (
value: string,
message = "Invalid permission"
) => {
assertIn(value, [...Object.values(CollectionPermission), null], message);
};
export class ValidateKey {
/**
* Checks if key is valid. A valid key is of the form
* <bucket>/<uuid>/<uuid>/<name>?
*
* @param key
* @returns true if key is valid, false otherwise
*/
public static isValid = (key: string) => {
let parts = key.split("/");
return (
parts.length >= 3 &&
parts.length <= 4 &&
isIn(parts[0], Object.values(Buckets)) &&
isUUID(parts[1]) &&
isUUID(parts[2])
);
};
/**
* Sanitizes a key by removing any invalid characters
*
* @param key
* @returns sanitized key
*/
public static sanitize = (key: string) => {
const [filename] = key.split("/").slice(-1);
return key
.split("/")
.slice(0, -1)
.filter((part) => part !== "" && part !== ".." && part !== ".")
.join("/")
.concat(`/${sanitize(filename.replace(/#/g, ""))}`);
};
public static message = "Must be of the form <bucket>/<uuid>/<uuid>/<name>?";
}
export class ValidateDocumentId {
/**
* Checks if documentId is valid. A valid documentId is either
* a UUID or a url slug matching a particular regex.
*
* @param documentId
* @returns true if documentId is valid, false otherwise
*/
public static isValid = (documentId: string) =>
isUUID(documentId) || UrlHelper.SLUG_URL_REGEX.test(documentId);
public static message = "Must be uuid or url slug";
}
export class ValidateIndex {
public static regex = new RegExp("^[\x20-\x7E]+$");
public static message = "Must be between x20 to x7E ASCII";
public static maxLength = 256;
}
export class ValidateURL {
public static isValidMentionUrl = (url: string) => {
if (!isUrl(url)) {
return false;
}
try {
const urlObj = new URL(url);
if (urlObj.protocol !== "mention:") {
return false;
}
const { id, mentionType, modelId } = parseMentionUrl(url);
return (
(!id || isUUID(id)) &&
!!mentionType &&
Object.values(MentionType).includes(mentionType as MentionType) &&
!!modelId &&
isUUID(modelId)
);
} catch (_err) {
return false;
}
};
public static message = "Must be a valid url";
}
export class ValidateColor {
public static regex = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i;
public static message = "Must be a hex value (please use format #FFFFFF)";
}