Validate Untrusted Input At A Single Boundary

A boundary parser that validates raw external input once and hands typed, sanitized values to the rest of the program.

TypescriptSecurity Boundaries

Code

export type FieldKind = "string" | "number" | "boolean";

export interface FieldSpec {
  readonly name: string;
  readonly kind: FieldKind;
  readonly required?: boolean;
  readonly min?: number;
  readonly max?: number;
  readonly minLength?: number;
  readonly maxLength?: number;
}

export type BoundaryResult<T> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly violations: string[] };

function checkConstraints(spec: FieldSpec, value: string | number | boolean): string | null {
  if (typeof value === "string") {
    if (spec.minLength !== undefined && value.length < spec.minLength) {
      return spec.name + " shorter than " + spec.minLength + " characters";
    }
    if (spec.maxLength !== undefined && value.length > spec.maxLength) {
      return spec.name + " longer than " + spec.maxLength + " characters";
    }
  }
  if (typeof value === "number") {
    if (spec.min !== undefined && value < spec.min) {
      return spec.name + " below minimum " + spec.min;
    }
    if (spec.max !== undefined && value > spec.max) {
      return spec.name + " above maximum " + spec.max;
    }
  }
  return null;
}

export function parseBoundary<T extends Record<string, unknown>>(
  raw: unknown,
  specs: readonly FieldSpec[],
): BoundaryResult<T> {
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
    return { ok: false, violations: ["payload must be an object"] };
  }
  const record = raw as Record<string, unknown>;
  const violations: string[] = [];
  const out: Record<string, unknown> = {};
  const known = new Set(specs.map((s) => s.name));

  for (const key of Object.keys(record)) {
    if (!known.has(key)) violations.push("unknown field: " + key);
  }

  for (const spec of specs) {
    const value = record[spec.name];
    if (value === undefined || value === null) {
      if (spec.required !== false) violations.push(spec.name + " is required");
      continue;
    }
    if (typeof value !== spec.kind) {
      violations.push(spec.name + " must be a " + spec.kind);
      continue;
    }
    const problem = checkConstraints(spec, value as string | number | boolean);
    if (problem) {
      violations.push(problem);
      continue;
    }
    out[spec.name] = value;
  }

  if (violations.length > 0) return { ok: false, violations };
  return { ok: true, value: out as T };
}