/**
 * Security utilities for the mailpouch Settings Server
 *
 * Covers:
 *   • Per-IP rate limiting (sliding window, in-memory)
 *   • Safe request body reading (size cap + timeout)
 *   • Origin / Referer validation (defence-in-depth)
 *   • LAN access token (256-bit, single-use per server instance)
 *   • Self-signed TLS certificate generation via system openssl
 *   • Constant-time token comparison to prevent timing attacks
 *   • Input sanitisation helpers shared with the escalation module
 *
 * None of this requires third-party packages — only Node.js built-ins.
 */
import { spawnSync } from "child_process";
import { mkdtempSync, readFileSync, existsSync, rmSync } from "fs";
import { tmpdir, networkInterfaces } from "os";
import { join } from "path";
import { randomBytes, createHash, timingSafeEqual } from "crypto";
// ─── Constants ─────────────────────────────────────────────────────────────────
/** Max POST body size: 64 KiB.  Rejects bodies that could exhaust heap. */
export const MAX_BODY_BYTES = 65_536;
/** Abort reading body after this many ms (Slow Loris / slow POST). */
export const BODY_TIMEOUT_MS = 15_000;
/** Per-IP request rate: general endpoints (req / window). */
export const GENERAL_RATE_LIMIT = 120; // per minute
/** Per-IP request rate: mutating escalation endpoints. */
export const ESCALATION_RATE_LIMIT = 20; // per minute
/** Per-IP request rate: any request when serving over LAN. */
export const LAN_RATE_LIMIT = 200; // per minute (slightly tighter)
// ─── Rate Limiter ──────────────────────────────────────────────────────────────
/** Maximum number of distinct keys the rate-limiter will track simultaneously.
 *  Prevents unbounded Map growth between eviction sweeps when an attacker
 *  rotates source IPs or uses many unique composite keys.  When the cap is
 *  reached the stalest bucket (the first key in insertion order, since V8 Maps
 *  preserve insertion order) is dropped to make room for the new key.
 */
const MAX_RATE_LIMIT_BUCKETS = 10_000;
/**
 * Sliding-window in-memory rate limiter.
 *
 * Keyed by any string (typically the client IP address + optional route tag).
 * Stale buckets are evicted every `windowMs` to prevent unbounded memory growth.
 * A hard cap of MAX_RATE_LIMIT_BUCKETS distinct keys is enforced per instance.
 */
export class RateLimiter {
    maxRequests;
    windowMs;
    buckets = new Map();
    cleanup;
    constructor(maxRequests, windowMs) {
        this.maxRequests = maxRequests;
        this.windowMs = windowMs;
        // Evict stale entries on a 5-minute timer; unref so it does not
        // prevent the process from exiting.
        this.cleanup = setInterval(() => this.evict(), 5 * 60 * 1000);
        this.cleanup.unref();
    }
    /**
     * Consume one slot for `key`.
     * Returns true if the request is allowed; false if the limit is exceeded.
     */
    check(key) {
        const now = Date.now();
        const windowStart = now - this.windowMs;
        let times = this.buckets.get(key) ?? [];
        times = times.filter(t => t > windowStart);
        if (times.length >= this.maxRequests) {
            this.buckets.set(key, times); // write back evicted slice
            return false;
        }
        times.push(now);
        // Enforce the per-instance key cap before inserting a brand-new key.
        // Run eviction first to clear stale buckets, then evict LRU rather than
        // insertion-order: an attacker rotating fake keys faster than the
        // window's eviction sweep would otherwise force out long-lived legitimate
        // clients (DoS-of-DoS).
        if (!this.buckets.has(key)) {
            if (this.buckets.size >= MAX_RATE_LIMIT_BUCKETS) {
                this.evict();
            }
            if (this.buckets.size >= MAX_RATE_LIMIT_BUCKETS) {
                // Find the bucket whose most-recent activity is oldest.
                let oldestKey;
                let oldestT = Infinity;
                for (const [k, ts] of this.buckets) {
                    const last = ts.length === 0 ? 0 : ts[ts.length - 1];
                    if (last < oldestT) {
                        oldestT = last;
                        oldestKey = k;
                    }
                }
                if (oldestKey !== undefined)
                    this.buckets.delete(oldestKey);
            }
        }
        this.buckets.set(key, times);
        return true;
    }
    /** Current slot count for a key (does not consume a slot). */
    count(key) {
        const windowStart = Date.now() - this.windowMs;
        return (this.buckets.get(key) ?? []).filter(t => t > windowStart).length;
    }
    dispose() { clearInterval(this.cleanup); }
    evict() {
        const threshold = Date.now() - this.windowMs;
        for (const [key, times] of this.buckets) {
            if (times.every(t => t <= threshold))
                this.buckets.delete(key);
        }
    }
}
// ─── Safe body reader ──────────────────────────────────────────────────────────
/**
 * Reads an HTTP request body with strict guards:
 *   • Aborts and destroys the socket if the body exceeds `maxBytes`.
 *   • Aborts after `timeoutMs` with no data (Slow Loris / slow-POST).
 *
 * Callers should still validate/sanitise the returned string.
 */
export function readBodySafe(req, maxBytes = MAX_BODY_BYTES, timeoutMs = BODY_TIMEOUT_MS) {
    return new Promise((resolve, reject) => {
        const chunks = [];
        let totalBytes = 0;
        const timer = setTimeout(() => {
            req.destroy();
            reject(Object.assign(new Error("Request body timeout"), { code: "TIMEOUT" }));
        }, timeoutMs);
        const onData = (chunk) => {
            totalBytes += chunk.length;
            if (totalBytes > maxBytes) {
                clearTimeout(timer);
                req.destroy();
                reject(Object.assign(new Error(`Request body too large (max ${maxBytes} bytes)`), { code: "TOO_LARGE" }));
                return;
            }
            chunks.push(chunk);
        };
        req.on("data", onData);
        req.on("end", () => { clearTimeout(timer); resolve(Buffer.concat(chunks).toString("utf-8")); });
        req.on("error", (err) => { clearTimeout(timer); reject(err); });
    });
}
// ─── Origin / Referer validation ───────────────────────────────────────────────
/**
 * Returns true if the request's `Origin` or `Referer` header is consistent
 * with the settings server itself.
 *
 * This is defence-in-depth alongside CSRF tokens — neither alone is
 * sufficient, together they raise the bar significantly.
 *
 * Requests with no Origin/Referer are allowed (some legitimate browser
 * environments omit them); the CSRF check is the authoritative gate.
 *
 * @param scheme  "http" or "https" — the actual scheme the server is using.
 *                This is required to accept the correct origin in TLS mode,
 *                where browsers send `https://` rather than `http://` origins.
 */
export function isValidOrigin(req, port, lan, scheme = "http") {
    const origin = req.headers["origin"];
    const referer = req.headers["referer"];
    const header = origin ?? referer;
    if (!header)
        return true; // absent → defer to CSRF
    // Build the set of permitted origin prefixes for this server instance.
    // Always include both http:// and https:// for localhost to handle
    // environments where the scheme differs from what we expected.
    const valid = [
        `http://localhost:${port}`,
        `http://127.0.0.1:${port}`,
        `https://localhost:${port}`,
        `https://127.0.0.1:${port}`,
    ];
    if (lan) {
        const lanIP = getPrimaryLanIP();
        if (lanIP) {
            valid.push(`http://${lanIP}:${port}`);
            valid.push(`https://${lanIP}:${port}`);
        }
        // Accept RFC-1918 ranges in LAN mode; include both schemes.
        // Use a regex to avoid prefix spoofing (e.g. "http://192.168.evil.com").
        // Each alternative must produce exactly 3 octets so that the outer
        // `\.\d{1,3}` brings the total to 4, matching real IPv4 RFC-1918 addresses:
        //   192.168.X   → 192.168.X.Y  (4 octets ✓)
        //   10.X.Y      → 10.X.Y.Z     (4 octets ✓)   ← was 10.X (3 octets ✗)
        //   172.[16-31].X → 172.Z.X.Y  (4 octets ✓)
        const RFC1918_RE = /^https?:\/\/(?:192\.168\.\d{1,3}|10\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3})\.\d{1,3}(?::\d+)?(?:\/|$)/;
        if (RFC1918_RE.test(header))
            return true;
        // IPv6 equivalents of RFC-1918: ULA (fc00::/7 → fc00:– / fd00:–) and
        // link-local (fe80::/10). Origin/referer URLs bracket the IPv6 address.
        // We don't try to validate the full IPv6 grammar — just the leading
        // prefix bytes, which is enough to admit private-range traffic without
        // letting public IPv6 origins through.
        const IPV6_PRIVATE_RE = /^https?:\/\/\[(?:f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:)[0-9a-f:]*\](?::\d+)?(?:\/|$)/i;
        if (IPV6_PRIVATE_RE.test(header))
            return true;
    }
    // Use exact match (for Origin headers, which carry no path) or require a
    // "/" separator (for Referer headers, which include a path).  A bare
    // startsWith("http://localhost:3000") would incorrectly accept
    // "http://localhost:30001/..." due to the numeric prefix overlap.
    return valid.some(v => header === v || header.startsWith(v + "/"));
}
/** Generate a fresh 256-bit access token for LAN mode. */
export function generateAccessToken() {
    const value = randomBytes(32).toString("hex");
    const fingerprint = createHash("sha256")
        .update(value)
        .digest("hex")
        .slice(0, 16)
        .toUpperCase()
        .replace(/(.{4})/g, "$1-")
        .slice(0, -1); // format: ABCD-EFGH-IJKL-MNOP
    return { value, fingerprint };
}
/**
 * Check whether an incoming request carries the valid access token.
 * Uses `timingSafeEqual` to prevent timing-based enumeration.
 *
 * Only the `X-Access-Token` header is accepted. Query-string tokens leak
 * into browser history, referer headers to outbound links, proxy access
 * logs, and devtools network tab; not worth the marginal UX benefit.
 */
export function hasValidAccessToken(req, _url, token) {
    const expected = Buffer.from(token.value, "utf-8");
    const candidate = req.headers["x-access-token"];
    if (!candidate)
        return false;
    if (candidate.length !== token.value.length)
        return false;
    try {
        return timingSafeEqual(Buffer.from(candidate, "utf-8"), expected);
    }
    catch {
        return false;
    }
}
/**
 * UI-014: bootstrap-only token check for the shell HTML served on "/".
 *
 * The shell page is loaded by a top-level browser *navigation*, which cannot
 * carry a custom `X-Access-Token` header — so the documented LAN bootstrap URL
 * passes the token as `?token=…`. The API endpoints stay header-only (query
 * tokens leak into history / referer / proxy logs), but "/" must still be gated
 * in LAN mode or any device on the network could fetch the page and read the
 * embedded per-process CSRF token. This accepts the token from EITHER the
 * header (SPA re-fetch) or the `?token=` query (initial navigation), compared
 * constant-time.
 */
export function hasValidBootstrapToken(req, url, token) {
    if (hasValidAccessToken(req, url, token))
        return true;
    const q = url.searchParams.get("token");
    if (!q || q.length !== token.value.length)
        return false;
    try {
        return timingSafeEqual(Buffer.from(q, "utf-8"), Buffer.from(token.value, "utf-8"));
    }
    catch {
        return false;
    }
}
/**
 * Attempt to generate a self-signed TLS certificate via the system `openssl`
 * binary.  Returns `null` if openssl is unavailable or fails.
 *
 * The cert is written to a temporary directory and loaded into memory;
 * the files are left on disk in `/tmp` and will be cleaned by the OS.
 *
 * The resulting credentials are suitable for `https.createServer()`.
 */
export function tryGenerateSelfSignedCert() {
    let dir = null;
    try {
        dir = mkdtempSync(join(tmpdir(), "protonmcp-tls-"));
        const keyFile = join(dir, "key.pem");
        const certFile = join(dir, "cert.pem");
        const result = spawnSync("openssl", [
            "req", "-x509",
            "-newkey", "rsa:2048",
            "-keyout", keyFile,
            "-out", certFile,
            "-days", "365",
            "-nodes",
            "-batch",
            "-subj", "/CN=mailpouch Settings/O=Local/C=US",
        ], { stdio: "pipe", timeout: 30_000 });
        if (result.status !== 0 || !existsSync(certFile))
            return null;
        const key = readFileSync(keyFile);
        const cert = readFileSync(certFile);
        // Compute SHA-256 fingerprint from cert DER bytes
        const pemBody = cert.toString("utf-8")
            .replace(/-----BEGIN CERTIFICATE-----|-----END CERTIFICATE-----|\s/g, "");
        const der = Buffer.from(pemBody, "base64");
        const fingerprint = createHash("sha256")
            .update(der)
            .digest("hex")
            .match(/.{2}/g)
            .join(":")
            .toUpperCase();
        return { key, cert, fingerprint };
    }
    catch {
        return null;
    }
    finally {
        // The cert + private key only ever needed to be read once — they're now
        // in-memory buffers. Wipe the on-disk copy immediately so a crash or
        // forensic capture of /tmp doesn't surface the private key.
        if (dir) {
            try {
                rmSync(dir, { recursive: true, force: true });
            }
            catch { /* best-effort */ }
        }
    }
}
// ─── Input sanitisers ─────────────────────────────────────────────────────────
/** Hex challenge IDs are exactly 32 chars (128-bit randomBytes). */
const CHALLENGE_ID_RE = /^[0-9a-f]{32}$/;
export function isValidChallengeId(id) {
    return typeof id === "string" && CHALLENGE_ID_RE.test(id);
}
/** Strip C0/C1 control characters (except tab/LF/CR) and NUL. */
const CONTROL_CHARS_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\x80-\x9f]/g;
export function sanitizeText(text, maxLen = 500) {
    if (typeof text !== "string")
        return "";
    return text.replace(CONTROL_CHARS_RE, "").slice(0, maxLen).trim();
}
/**
 * Presets an agent is allowed to request escalation to.
 * Intentionally excludes "read_only" (you can't escalate downward) and
 * "custom" (custom presets are human-configured, not agent-requested).
 */
const VALID_ESCALATION_TARGETS = new Set(["send_only", "supervised", "full"]);
export function isValidEscalationTarget(preset) {
    return typeof preset === "string" && VALID_ESCALATION_TARGETS.has(preset);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Get the machine's primary non-loopback IPv4 address. */
export function getPrimaryLanIP() {
    try {
        for (const ifaces of Object.values(networkInterfaces())) {
            for (const iface of ifaces ?? []) {
                if (iface.family === "IPv4" && !iface.internal)
                    return iface.address;
            }
        }
    }
    catch { /* ignore */ }
    return "";
}
/**
 * Extract the remote IP from a Node.js HTTP request, preferring
 * socket.remoteAddress over any forwarded headers (we do not sit behind
 * a proxy so forwarded headers should never be trusted here).
 */
export function clientIP(req) {
    return req.socket?.remoteAddress ?? "unknown";
}
//# sourceMappingURL=security.js.map