logo

qbjs

Core Concepts

Security

Protecting your API with qbjs security configuration

Security

qbjs includes a security layer that protects your API from malicious or excessive queries. Security validation happens at the AST level, ensuring consistent protection regardless of which compiler you use.

Why Security Matters

Query string APIs are powerful but can be abused:

  • Data exposure: Users might request fields they shouldn't access
  • Resource exhaustion: Unlimited pagination can overload your database
  • Injection attacks: Malicious operators or field names could exploit vulnerabilities

qbjs's security configuration lets you define exactly what's allowed.

SecurityConfig

The SecurityConfig interface defines your security rules:

interface SecurityConfig {
  allowedFields?: string[];      // Fields that can be queried
  allowedRelations?: string[];   // Relations that can be joined
  maxLimit?: number;             // Maximum items per page
  operators?: FilterOperator[];  // Allowed filter operators
  defaultLimit?: number;         // Default items per page
  defaultPage?: number;          // Default page number
}

Configuration Options

allowedFields

Restrict which fields can be selected, filtered, or sorted:

const config: SecurityConfig = {
  allowedFields: ["id", "title", "status", "createdAt"]
};

When set:

  • Only these fields can appear in fields parameter
  • Only these fields can be used in filters
  • Only these fields can be used for sorting

When empty or undefined, all fields are allowed.

maxLimit

Cap the maximum number of items per page:

const config: SecurityConfig = {
  maxLimit: 100  // Maximum 100 items per request
};

If a request asks for more than maxLimit, the limit is automatically capped and a warning is generated.

Default: 100

operators

Restrict which filter operators can be used:

const config: SecurityConfig = {
  operators: ["eq", "ne", "gt", "gte", "lt", "lte", "in"]
};

This prevents users from using potentially expensive operators like contains or containsi which may not use indexes efficiently.

Default: All operators allowed.

defaultLimit and defaultPage

Set defaults when not specified in the query:

const config: SecurityConfig = {
  defaultLimit: 20,  // 20 items per page by default
  defaultPage: 1     // Start at page 1
};

Using Security Validation

validateSecurity()

The main function for security validation:

import { parseQueryString, validateSecurity } from "@qbjs/core";

const { ast, errors: parseErrors } = parseQueryString(queryString);

if (parseErrors.length > 0 || !ast) {
  return { error: "Invalid query" };
}

const securityConfig: SecurityConfig = {
  allowedFields: ["id", "title", "status", "createdAt"],
  maxLimit: 50,
  operators: ["eq", "ne", "in", "contains"]
};

const { valid, errors, warnings, ast: validatedAst } = validateSecurity(ast, securityConfig);

if (!valid) {
  return { error: "Security validation failed", details: errors };
}

// Use validatedAst (may have capped limit)

Security Validation Result

interface SecurityValidationResult {
  valid: boolean;              // True if no errors
  errors: SecurityError[];     // Blocking errors
  warnings: SecurityWarning[]; // Non-blocking warnings
  ast: QueryAST | null;        // Modified AST (e.g., capped limit)
}

Security Errors

Errors indicate security violations that should block the request:

CodeDescription
FIELD_NOT_ALLOWEDRequested field is not in allowedFields
OPERATOR_NOT_ALLOWEDFilter operator is not in allowed operators
LIMIT_EXCEEDEDLimit exceeds maxLimit (error mode)

Security Warnings

Warnings indicate issues that were automatically corrected:

CodeDescription
LIMIT_CAPPEDLimit was reduced to maxLimit

Complete Example

Here's a complete example with Hono:

import { Hono } from "hono";
import { 
  parseQueryString, 
  validateSecurity, 
  createDrizzlePgCompiler 
} from "@qbjs/core";
import { db } from "./db";
import { posts } from "./schema";

const app = new Hono();
const compiler = createDrizzlePgCompiler();

// Define security config for posts endpoint
const postsSecurityConfig = {
  allowedFields: ["id", "title", "content", "status", "createdAt", "updatedAt"],
  maxLimit: 50,
  operators: ["eq", "ne", "in", "notIn", "contains", "containsi", "gt", "gte", "lt", "lte"],
  defaultLimit: 20
};

app.get("/posts", async (c) => {
  // 1. Parse query string
  const url = new URL(c.req.url);
  const { ast, errors: parseErrors } = parseQueryString(url.search);
  
  if (parseErrors.length > 0 || !ast) {
    return c.json({ error: "Invalid query parameters", details: parseErrors }, 400);
  }
  
  // 2. Validate security
  const { valid, errors: securityErrors, warnings, ast: secureAst } = 
    validateSecurity(ast, postsSecurityConfig);
  
  if (!valid) {
    return c.json({ error: "Security validation failed", details: securityErrors }, 403);
  }
  
  // Log warnings for monitoring
  if (warnings.length > 0) {
    console.warn("Security warnings:", warnings);
  }
  
  // 3. Compile and execute
  const { query, errors: compileErrors } = compiler.compile(secureAst!, posts);
  
  if (compileErrors.length > 0) {
    return c.json({ error: "Query compilation failed", details: compileErrors }, 400);
  }
  
  const results = await db
    .select(query.columns)
    .from(posts)
    .where(query.where)
    .orderBy(...query.orderBy)
    .limit(query.limit)
    .offset(query.offset);
  
  return c.json({ data: results });
});

Best Practices

1. Always Define allowedFields

Don't expose all database columns by default:

// ❌ Bad: Exposes all fields including sensitive ones
const config = { maxLimit: 100 };

// ✅ Good: Explicitly list allowed fields
const config = {
  allowedFields: ["id", "title", "status", "createdAt"],
  maxLimit: 100
};

2. Set Reasonable Limits

Prevent resource exhaustion:

const config = {
  maxLimit: 100,      // Cap at 100 items
  defaultLimit: 20    // Default to 20 items
};

3. Restrict Expensive Operators

Some operators can be slow on large tables:

// For high-traffic endpoints, consider restricting to indexed operations
const config = {
  operators: ["eq", "ne", "in", "notIn", "gt", "gte", "lt", "lte"]
  // Excludes: contains, containsi, startsWith, endsWith
};

4. Different Configs for Different Endpoints

Use stricter configs for public endpoints:

// Public API - strict
const publicConfig = {
  allowedFields: ["id", "title", "summary"],
  maxLimit: 25,
  operators: ["eq", "in"]
};

// Admin API - permissive
const adminConfig = {
  allowedFields: ["id", "title", "content", "status", "authorId", "createdAt", "updatedAt"],
  maxLimit: 100,
  operators: [...FILTER_OPERATORS]  // All operators
};

5. Log Security Warnings

Monitor for potential abuse:

const { warnings } = validateSecurity(ast, config);

if (warnings.length > 0) {
  logger.warn("Security warnings", { 
    warnings, 
    ip: request.ip,
    path: request.path 
  });
}

Default Configuration

If no config is provided, qbjs uses these defaults:

const DEFAULT_SECURITY_CONFIG = {
  allowedFields: [],        // All fields allowed
  allowedRelations: [],     // All relations allowed
  maxLimit: 100,            // Max 100 items
  operators: [...FILTER_OPERATORS],  // All operators
  defaultLimit: 10,         // 10 items by default
  defaultPage: 1            // Start at page 1
};

Next Steps

On this page