Intelligence

Artifacts

Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.

Repository
README.md
CONSTITUTION_COMPLIANCE_AUDIT_V1.mdREADME.md
repositories/aaf-holdings/hq01/lib/uploads/upload.ts
4.9 KB
import fs from "node:fs";
import path from "node:path";
import { CONTENT_ROOT } from "@/lib/content/config";
import {
  ALLOWED_EXTENSIONS,
  MAX_UPLOAD_BYTES,
  getCategory,
} from "./config";

/**
 * Server-side artifact upload. This module performs every security check and is
 * the only thing that writes uploaded bytes to disk. It runs server-side only.
 *
 * Defence in depth:
 *   1. The destination is chosen from a fixed category allowlist — never a path.
 *   2. The filename is reduced to a basename and sanitized (no traversal).
 *   3. The extension must be in the allowlist.
 *   4. The size is capped at 25 MB.
 *   5. The resolved target is asserted to live inside the category directory.
 *   6. Writes use the exclusive flag so an upload never overwrites an existing
 *      file; collisions are de-duplicated with a numeric suffix.
 */

export class UploadError extends Error {
  constructor(
    message: string,
    readonly status = 400,
  ) {
    super(message);
    this.name = "UploadError";
  }
}

/**
 * Reduce an arbitrary client-supplied name to a safe filename.
 * Strips directory components (defeating `../` traversal), removes control and
 * reserved characters, and preserves a lower-cased extension.
 */
export function sanitizeFilename(raw: string): string {
  // Keep only the final path segment — this alone defeats path traversal.
  const base = (raw ?? "").split(/[/\\]/).pop() ?? "";
  const extRaw = path.extname(base);
  const ext = extRaw.toLowerCase();

  let stem = base.slice(0, base.length - extRaw.length);
  stem = stem
    .normalize("NFKD")
    .replace(/[^A-Za-z0-9._-]+/g, "_") // collapse anything unsafe
    .replace(/_{2,}/g, "_")
    .replace(/^[._-]+/, "") // no leading dot/dash/underscore (no hidden files)
    .replace(/[._-]+$/, "");
  if (!stem) stem = "file";
  stem = stem.slice(0, 120);

  return stem + ext;
}

export interface UploadResult {
  /** Filename actually written (after sanitize + de-dup). */
  name: string;
  /** Category key the file was routed to. */
  category: string;
  /** Human label of the category. */
  categoryLabel: string;
  /** Absolute path written. */
  absolutePath: string;
  /** Path relative to the Artifacts explorer root, when inside it. */
  relativePath: string | null;
  /** Bytes written. */
  size: number;
}

export interface SaveUploadInput {
  categoryKey: string;
  filename: string;
  size: number;
  data: Buffer;
}

/** Validate and persist an uploaded file. Throws {@link UploadError} on any rejection. */
export function saveUpload(input: SaveUploadInput): UploadResult {
  const category = getCategory(input.categoryKey);
  if (!category) {
    throw new UploadError(`Unknown destination category: ${input.categoryKey}`);
  }

  if (input.size <= 0 || input.data.length === 0) {
    throw new UploadError("The file is empty.");
  }
  if (input.size > MAX_UPLOAD_BYTES || input.data.length > MAX_UPLOAD_BYTES) {
    throw new UploadError("File exceeds the 25MB limit.", 413);
  }

  const safeName = sanitizeFilename(input.filename);
  const ext = path.extname(safeName).toLowerCase();
  if (!ALLOWED_EXTENSIONS.has(ext)) {
    throw new UploadError(
      `Unsupported file type "${ext || "(none)"}". Allowed: ${[...ALLOWED_EXTENSIONS].join(", ")}.`,
      415,
    );
  }

  // Ensure the destination exists.
  fs.mkdirSync(category.dir, { recursive: true });

  // Defence in depth: the resolved target must stay inside the category dir.
  const dirWithSep = category.dir.endsWith(path.sep)
    ? category.dir
    : category.dir + path.sep;

  const stem = safeName.slice(0, safeName.length - ext.length);
  let attempt = 0;
  let written: string | null = null;
  let finalName = safeName;

  while (attempt < 1000) {
    finalName = attempt === 0 ? safeName : `${stem}-${attempt}${ext}`;
    const target = path.join(category.dir, finalName);

    if (target !== category.dir && !target.startsWith(dirWithSep)) {
      throw new UploadError("Resolved path escapes the destination.", 400);
    }

    try {
      // "wx": create + exclusive — fails if the file already exists.
      const fd = fs.openSync(target, "wx");
      try {
        fs.writeFileSync(fd, input.data);
      } finally {
        fs.closeSync(fd);
      }
      written = target;
      break;
    } catch (err) {
      if ((err as NodeJS.ErrnoException)?.code === "EEXIST") {
        attempt += 1;
        continue;
      }
      throw new UploadError(
        `Failed to write file: ${err instanceof Error ? err.message : String(err)}`,
        500,
      );
    }
  }

  if (!written) {
    throw new UploadError("Could not find a free filename for the upload.", 409);
  }

  const rel = path.relative(CONTENT_ROOT, written);
  const relativePath =
    rel && !rel.startsWith("..") && !path.isAbsolute(rel)
      ? rel.split(path.sep).join("/")
      : null;

  return {
    name: finalName,
    category: category.key,
    categoryLabel: category.label,
    absolutePath: written,
    relativePath,
    size: input.data.length,
  };
}

root · /srv/aaf