vanta-api
Version:
Advanced API features and security configuration for Node.js/MongoDB.
1,068 lines (869 loc) • 27 kB
JavaScript
import mongoose from "mongoose";
import winston from "winston";
import pluralize from "pluralize";
import HandleERROR from "./handleError.js";
import { securityConfig } from "./config.js";
import { ObjectId } from "bson";
const logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [new winston.transports.Console()],
});
const RESERVED_QUERY_KEYS = ["page", "limit", "sort", "fields", "populate", "q"];
const LOGICAL_OPERATORS = ["$and", "$or", "$nor"];
export class ApiFeatures {
constructor(model, query = {}, userRole = "") {
this.model = model;
this.query = { ...query };
this.pipeline = [];
this.manualFilters = {};
this.useCursor = false;
this.userRole =
userRole && securityConfig.accessLevels?.[userRole] ? userRole : "guest";
this._sanitization();
}
filter() {
const queryFilters = this._parseQueryFilters();
const normalizedManualFilters = this._normalizeLogicalOperators(
this.manualFilters
);
const mergedFilters = this._deepMergeFilters(
queryFilters,
normalizedManualFilters
);
const sanitizedFilters = this._sanitizeFilters(mergedFilters);
const safeFilters = this._applySecurityFilters(sanitizedFilters);
if (Object.keys(safeFilters).length) {
this.pipeline.push({ $match: safeFilters });
}
return this;
}
addManualFilters(filters = {}) {
if (filters && typeof filters === "object" && !Array.isArray(filters)) {
const normalizedFilters = this._normalizeLogicalOperators(filters);
this.manualFilters = this._deepMergeFilters(
this.manualFilters,
normalizedFilters
);
}
return this;
}
search(fields = []) {
const q = this.query.q;
if (!q || !Array.isArray(fields) || !fields.length) return this;
const safeQ = this._escapeRegex(String(q).trim());
if (!safeQ) return this;
const conditions = fields
.filter((field) => typeof field === "string" && field.trim())
.map((field) => ({
[field]: { $regex: safeQ, $options: "i" },
}));
if (conditions.length) {
this.pipeline.push({
$match: {
$or: conditions,
},
});
}
return this;
}
sort() {
if (!this.query.sort) return this;
const sortObj = {};
const validFields = new Set(Object.keys(this.model.schema.paths));
String(this.query.sort)
.split(",")
.map((part) => part.trim())
.filter(Boolean)
.forEach((part) => {
const direction = part.startsWith("-") ? -1 : 1;
const field = part.replace(/^[-+]/, "");
if (validFields.has(field)) {
sortObj[field] = direction;
}
});
if (Object.keys(sortObj).length) {
this.pipeline.push({ $sort: sortObj });
}
return this;
}
limitFields(input = "") {
const rawFields = [input, this.query.fields].filter(Boolean).join(",");
if (!rawFields) return this;
const fields = rawFields
.split(",")
.map((field) => field.trim())
.filter(Boolean);
const hasInclude = fields.some((field) => !field.startsWith("-"));
const hasExclude = fields.some((field) => field.startsWith("-"));
if (hasInclude && hasExclude) {
throw new HandleERROR("Cannot mix include and exclude fields", 400);
}
const project = {};
for (const field of fields) {
const cleanField = field.replace(/^-/, "");
if (this._isForbiddenField(cleanField)) continue;
project[cleanField] = field.startsWith("-") ? 0 : 1;
}
if (Object.keys(project).length) {
this.pipeline.push({ $project: project });
}
return this;
}
paginate() {
const access = securityConfig.accessLevels?.[this.userRole] || {};
const maxLimit = access.maxLimit || 100;
const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
const limit = Math.min(
Math.max(parseInt(this.query.limit, 10) || 10, 1),
maxLimit
);
this.pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit });
return this;
}
populate(input = "") {
const populateList = this._normalizePopulateInput(input);
const allowedPopulate =
securityConfig.accessLevels?.[this.userRole]?.allowedPopulate || [];
for (const populateItem of populateList) {
this._addPopulateStages({
populateItem,
parentPath: "",
parentIsArray: false,
schema: this.model.schema,
allowedPopulate,
});
}
return this;
}
async execute(options = {}) {
try {
if (options.useCursor) this.useCursor = true;
if (options.debug) {
logger.info("Pipeline:", this.pipeline);
}
if (this.pipeline.length > (securityConfig.maxPipelineStages || 80)) {
throw new HandleERROR("Too many pipeline stages", 400);
}
const countPipeline = this._buildCountPipeline();
const [countResult] = await this.model.aggregate([
...countPipeline,
{ $count: "total" },
]);
const aggregation = this.model
.aggregate(this.pipeline)
.option({ maxTimeMS: options.maxTimeMS || 10000 });
let data;
if (this.useCursor) {
const cursor = aggregation.cursor({
batchSize: options.batchSize || 100,
});
data = [];
for await (const doc of cursor) {
data.push(doc);
}
} else {
data = await aggregation
.allowDiskUse(Boolean(options.allowDiskUse))
.readConcern(options.readConcern || "majority");
}
return {
success: true,
count: countResult?.total || 0,
data,
};
} catch (err) {
this._handleError(err);
}
}
_addPopulateStages({
populateItem,
parentPath = "",
parentIsArray = false,
schema = null,
allowedPopulate = [],
}) {
if (!populateItem || !populateItem.path) return;
const path = populateItem.path;
const fullPath = parentPath ? `${parentPath}.${path}` : path;
if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
return;
}
const info = this._getPopulateInfo({
schema,
path,
fullPath,
parentPath,
parentIsArray,
populateItem,
});
if (!parentIsArray) {
this.pipeline.push({
$lookup: {
from: info.collection,
localField: info.localField,
foreignField: "_id",
as: info.as,
},
});
if (!info.isArray) {
this.pipeline.push({
$unwind: {
path: `$${info.as}`,
preserveNullAndEmptyArrays: true,
},
});
}
if (populateItem.populate) {
const nestedList = this._normalizeNestedPopulate(populateItem.populate);
for (const nested of nestedList) {
this._addPopulateStages({
populateItem: nested,
parentPath: info.as,
parentIsArray: info.isArray,
schema: info.refSchema,
allowedPopulate,
});
}
}
if (populateItem.select) {
this._applyPopulateSelect({
path: info.as,
select: populateItem.select,
isArray: info.isArray,
});
}
return;
}
const tempLookupName = this._makeTempLookupName(fullPath);
this.pipeline.push({
$lookup: {
from: info.collection,
localField: info.localField,
foreignField: "_id",
as: tempLookupName,
},
});
this.pipeline.push({
$set: {
[parentPath]: {
$map: {
input: { $ifNull: [`$${parentPath}`, []] },
as: "item",
in: {
$mergeObjects: [
"$$item",
{
[path]: {
$first: {
$filter: {
input: `$${tempLookupName}`,
as: "joined",
cond: {
$eq: ["$$joined._id", `$$item.${path}`],
},
},
},
},
},
],
},
},
},
},
});
this.pipeline.push({ $unset: tempLookupName });
if (populateItem.populate) {
const nestedList = this._normalizeNestedPopulate(populateItem.populate);
for (const nested of nestedList) {
this._addNestedPopulateInsideArrayItem({
arrayPath: parentPath,
objectPath: path,
populateItem: nested,
allowedPopulate,
schema: info.refSchema,
});
}
}
if (populateItem.select) {
this._applyNestedObjectSelectInsideArray({
arrayPath: parentPath,
objectPath: path,
select: populateItem.select,
});
}
}
_addNestedPopulateInsideArrayItem({
arrayPath,
objectPath,
populateItem,
allowedPopulate = [],
schema = null,
}) {
if (!populateItem || !populateItem.path) return;
const childPath = populateItem.path;
const fullPath = `${arrayPath}.${objectPath}.${childPath}`;
if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
return;
}
const info = this._getPopulateInfo({
schema,
path: childPath,
fullPath,
parentPath: `${arrayPath}.${objectPath}`,
parentIsArray: true,
populateItem,
});
const tempLookupName = this._makeTempLookupName(fullPath);
this.pipeline.push({
$lookup: {
from: info.collection,
localField: `${arrayPath}.${objectPath}.${childPath}`,
foreignField: "_id",
as: tempLookupName,
},
});
this.pipeline.push({
$set: {
[arrayPath]: {
$map: {
input: { $ifNull: [`$${arrayPath}`, []] },
as: "item",
in: {
$mergeObjects: [
"$$item",
{
[objectPath]: {
$cond: [
{ $ne: [`$$item.${objectPath}`, null] },
{
$mergeObjects: [
`$$item.${objectPath}`,
{
[childPath]: {
$first: {
$filter: {
input: `$${tempLookupName}`,
as: "joined",
cond: {
$eq: [
"$$joined._id",
`$$item.${objectPath}.${childPath}`,
],
},
},
},
},
},
],
},
`$$item.${objectPath}`,
],
},
},
],
},
},
},
},
});
this.pipeline.push({ $unset: tempLookupName });
if (populateItem.populate) {
const nestedList = this._normalizeNestedPopulate(populateItem.populate);
for (const nested of nestedList) {
this._addNestedPopulateInsideArrayItem({
arrayPath,
objectPath: `${objectPath}.${childPath}`,
populateItem: nested,
allowedPopulate,
schema: info.refSchema,
});
}
}
if (populateItem.select) {
this._applyNestedObjectSelectInsideArray({
arrayPath,
objectPath: `${objectPath}.${childPath}`,
select: populateItem.select,
});
}
}
_getPopulateInfo({
schema = null,
path,
fullPath,
parentPath = "",
parentIsArray = false,
populateItem = {},
}) {
const schemaPath = schema?.path?.(path);
const isArray =
populateItem.isArray === true ||
schemaPath?.instance === "Array" ||
Array.isArray(schemaPath?.options?.type);
const refModelName =
populateItem.ref ||
populateItem.modelName ||
schemaPath?.options?.ref ||
schemaPath?.caster?.options?.ref ||
(Array.isArray(schemaPath?.options?.type)
? schemaPath.options.type[0]?.ref
: undefined) ||
this._inferModelNameFromPath(path);
const collection =
populateItem.collection ||
populateItem.from ||
this._resolveCollectionName(refModelName);
const refSchema = this._resolveRegisteredSchema(refModelName);
return {
path,
fullPath,
refModelName,
collection,
refSchema,
isArray,
localField: parentIsArray ? fullPath : fullPath,
as: fullPath,
parentPath,
parentIsArray,
};
}
_applyPopulateSelect({ path, select, isArray }) {
const parsed = this._parseSelect(select);
if (!parsed.fields.length) return;
if (parsed.mode === "exclude") {
this.pipeline.push({
$unset: parsed.fields.map((field) => `${path}.${field}`),
});
return;
}
const includeFields = this._ensureIdField(parsed.fields);
if (isArray) {
const selectedObject = {};
for (const field of includeFields) {
selectedObject[field] = `$$item.${field}`;
}
this.pipeline.push({
$set: {
[path]: {
$map: {
input: { $ifNull: [`$${path}`, []] },
as: "item",
in: selectedObject,
},
},
},
});
return;
}
const selectedObject = {};
for (const field of includeFields) {
selectedObject[field] = `$${path}.${field}`;
}
this.pipeline.push({
$set: {
[path]: {
$cond: [{ $ne: [`$${path}`, null] }, selectedObject, `$${path}`],
},
},
});
}
_applyNestedObjectSelectInsideArray({ arrayPath, objectPath, select }) {
const parsed = this._parseSelect(select);
if (!parsed.fields.length) return;
if (parsed.mode === "exclude") {
this.pipeline.push({
$unset: parsed.fields.map(
(field) => `${arrayPath}.${objectPath}.${field}`
),
});
return;
}
const includeFields = this._ensureIdField(parsed.fields);
const selectedObject = {};
for (const field of includeFields) {
selectedObject[field] = `$$item.${objectPath}.${field}`;
}
this.pipeline.push({
$set: {
[arrayPath]: {
$map: {
input: { $ifNull: [`$${arrayPath}`, []] },
as: "item",
in: {
$mergeObjects: [
"$$item",
{
[objectPath]: {
$cond: [
{ $ne: [`$$item.${objectPath}`, null] },
selectedObject,
`$$item.${objectPath}`,
],
},
},
],
},
},
},
},
});
}
_parseSelect(select = "") {
const fields = String(select)
.split(/\s+/)
.map((field) => field.trim())
.filter(Boolean)
.filter((field) => {
const cleanField = field.replace(/^-/, "");
return !this._isForbiddenField(cleanField);
});
const hasInclude = fields.some((field) => !field.startsWith("-"));
const hasExclude = fields.some((field) => field.startsWith("-"));
if (hasInclude && hasExclude) {
throw new HandleERROR(
"Cannot mix include and exclude in populate select",
400
);
}
return {
mode: hasExclude ? "exclude" : "include",
fields: fields.map((field) => field.replace(/^-/, "")),
};
}
_ensureIdField(fields = []) {
return fields.includes("_id") ? fields : ["_id", ...fields];
}
_sanitization() {
for (const key of Object.keys(this.query)) {
if (
key.startsWith("$") ||
["$where", "$accumulator", "$function"].includes(key)
) {
delete this.query[key];
}
}
["page", "limit"].forEach((field) => {
if (this.query[field] && !/^[0-9]+$/.test(String(this.query[field]))) {
throw new HandleERROR(`Invalid ${field}`, 400);
}
});
}
_parseQueryFilters() {
const obj = { ...this.query };
RESERVED_QUERY_KEYS.forEach((key) => delete obj[key]);
const out = {};
for (const [rawKey, rawVal] of Object.entries(obj)) {
const bracketMatch = rawKey.match(/^(.+)\[\$?(\w+)\]$/);
if (bracketMatch) {
const [, field, op] = bracketMatch;
const cleanOp = op.replace(/^\$/, "");
if (securityConfig.allowedOperators?.includes(cleanOp)) {
out[field] = {
...(out[field] || {}),
[`$${cleanOp}`]: rawVal,
};
}
continue;
}
if (rawVal && typeof rawVal === "object" && !Array.isArray(rawVal)) {
out[rawKey] = out[rawKey] || {};
for (const [op, val] of Object.entries(rawVal)) {
const cleanOp = op.replace(/^\$/, "");
if (securityConfig.allowedOperators?.includes(cleanOp)) {
out[rawKey][`$${cleanOp}`] = val;
}
}
continue;
}
if (typeof rawVal === "string" && rawVal.includes(",")) {
out[rawKey] = rawVal.split(",").map((v) => v.trim());
} else {
out[rawKey] = rawVal;
}
}
return out;
}
_sanitizeFilters(filters = {}) {
const sanitizeNode = (node, key = "") => {
if (node instanceof mongoose.Types.ObjectId || node instanceof ObjectId) {
return node;
}
if (node === null || node === "null") return null;
if (node === "true") return true;
if (node === "false") return false;
if (Array.isArray(node)) {
return node.map((item) => sanitizeNode(item, key));
}
if (node && typeof node === "object") {
const result = {};
for (const [childKey, childVal] of Object.entries(node)) {
result[childKey] = sanitizeNode(childVal, childKey);
}
return result;
}
if (typeof node === "string") {
if (this.#isStrictObjectId(node) && this._shouldConvertToObjectId(key)) {
return new ObjectId(node);
}
if (/^[0-9]+$/.test(node)) {
return node.length > 1 && node.startsWith("0")
? node
: parseInt(node, 10);
}
}
return node;
};
return sanitizeNode(filters);
}
_shouldConvertToObjectId(key = "") {
const cleanKey = String(key).replace(/^\$/, "").toLowerCase();
return (
cleanKey === "_id" ||
cleanKey === "id" ||
cleanKey.endsWith("id") ||
cleanKey === "eq" ||
cleanKey === "ne" ||
cleanKey === "in" ||
cleanKey === "nin"
);
}
_normalizeLogicalOperators(filters = {}) {
if (Array.isArray(filters)) {
return filters.map((item) => this._normalizeLogicalOperators(item));
}
if (
!filters ||
typeof filters !== "object" ||
filters instanceof mongoose.Types.ObjectId ||
filters instanceof ObjectId ||
filters instanceof Date
) {
return filters;
}
const out = {};
for (const [key, value] of Object.entries(filters)) {
const normalizedKey =
key === "and"
? "$and"
: key === "or"
? "$or"
: key === "nor"
? "$nor"
: key;
out[normalizedKey] = this._normalizeLogicalOperators(value);
}
return out;
}
_deepMergeFilters(a = {}, b = {}) {
const out = { ...a };
for (const [key, value] of Object.entries(b)) {
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
out[key] &&
typeof out[key] === "object" &&
!Array.isArray(out[key]) &&
!LOGICAL_OPERATORS.includes(key)
) {
out[key] = this._deepMergeFilters(out[key], value);
} else {
out[key] = value;
}
}
return out;
}
_applySecurityFilters(filters = {}) {
const cleanNode = (node) => {
if (Array.isArray(node)) {
return node.map(cleanNode);
}
if (!node || typeof node !== "object") {
return node;
}
const result = {};
for (const [key, value] of Object.entries(node)) {
if (this._isForbiddenField(key)) continue;
result[key] = cleanNode(value);
}
return result;
};
return cleanNode(filters);
}
_normalizePopulateInput(input = "") {
const raw = [];
if (input) {
if (Array.isArray(input)) raw.push(...input);
else raw.push(input);
}
if (this.query.populate) {
raw.push(...String(this.query.populate).split(","));
}
const normalized = [];
const normalizeOne = (item) => {
if (!item) return;
if (typeof item === "string") {
const trimmed = item.trim();
if (!trimmed) return;
if (trimmed.includes(".")) {
normalized.push(this._dotPathToPopulate(trimmed));
} else {
normalized.push({ path: trimmed });
}
return;
}
if (Array.isArray(item)) {
item.forEach(normalizeOne);
return;
}
if (item && typeof item === "object" && item.path) {
normalized.push(this._normalizePopulateObject(item));
}
};
raw.forEach(normalizeOne);
return this._dedupePopulate(normalized);
}
_normalizePopulateObject(item) {
const normalized = { ...item };
if (typeof normalized.path === "string") {
normalized.path = normalized.path.trim();
}
if (typeof normalized.select === "string") {
normalized.select = normalized.select.trim();
}
if (normalized.populate) {
normalized.populate = this._normalizeNestedPopulate(normalized.populate);
}
return normalized;
}
_normalizeNestedPopulate(input) {
if (!input) return [];
if (typeof input === "string") {
return this._normalizePopulateInput(input);
}
if (Array.isArray(input)) {
return input
.flatMap((item) => {
if (typeof item === "string") return this._normalizePopulateInput(item);
if (item && typeof item === "object" && item.path) {
return [this._normalizePopulateObject(item)];
}
return [];
})
.filter(Boolean);
}
if (typeof input === "object" && input.path) {
return [this._normalizePopulateObject(input)];
}
return [];
}
_dotPathToPopulate(path) {
const parts = path
.split(".")
.map((part) => part.trim())
.filter(Boolean);
const root = { path: parts[0] };
let current = root;
for (const part of parts.slice(1)) {
current.populate = { path: part };
current = current.populate;
}
return root;
}
_dedupePopulate(items) {
const map = new Map();
for (const item of items) {
if (!item?.path) continue;
if (!map.has(item.path)) {
map.set(item.path, item);
continue;
}
const existing = map.get(item.path);
map.set(item.path, this._mergePopulateOptions(existing, item));
}
return [...map.values()];
}
_mergePopulateOptions(a, b) {
const merged = { ...a, ...b };
if (a.populate || b.populate) {
const aList = Array.isArray(a.populate)
? a.populate
: a.populate
? [a.populate]
: [];
const bList = Array.isArray(b.populate)
? b.populate
: b.populate
? [b.populate]
: [];
merged.populate = this._dedupePopulate([...aList, ...bList]);
}
return merged;
}
_isPopulateAllowed(path, allowedPopulate = []) {
return (
allowedPopulate.includes("*") ||
allowedPopulate.includes(path) ||
allowedPopulate.includes(path.split(".")[0])
);
}
_resolveCollectionName(refModelName = "") {
return pluralize(String(refModelName).toLowerCase());
}
_resolveRegisteredSchema(refModelName = "") {
const connection = this.model?.db || mongoose.connection;
const registeredModel =
connection.models?.[refModelName] || mongoose.models?.[refModelName];
return registeredModel?.schema || null;
}
_inferModelNameFromPath(path = "") {
let clean = String(path).split(".").pop();
clean = clean.replace(/Ids$/i, "");
clean = clean.replace(/Id$/i, "");
clean = pluralize.singular(clean);
return clean
.split(/[_-\s]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
_makeTempLookupName(path = "") {
return `__vanta_lookup_${String(path).replace(/[^a-zA-Z0-9]/g, "_")}`;
}
_isForbiddenField(field) {
return (securityConfig.forbiddenFields || []).includes(field);
}
_buildCountPipeline() {
return this.pipeline.filter((stage) => {
return !(
"$skip" in stage ||
"$limit" in stage ||
"$sort" in stage ||
"$project" in stage ||
"$lookup" in stage ||
"$unwind" in stage ||
"$set" in stage ||
"$unset" in stage
);
});
}
_escapeRegex(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
#isStrictObjectId(id) {
return (
typeof id === "string" &&
mongoose.Types.ObjectId.isValid(id) &&
new mongoose.Types.ObjectId(id).toString() === id
);
}
_handleError(err) {
logger.error(`[ApiFeatures] ${err.message}`, { stack: err.stack });
throw err;
}
}
export default ApiFeatures;