diff --git a/.env.example b/.env.example index ae64917..2bbfaa0 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # Web app + coordinator DATABASE_URL=postgres://pingql:pingql@localhost:5432/pingql MONITOR_TOKEN=changeme-use-a-random-secret +EMAIL_HMAC_KEY=changeme-use-a-random-secret +# Set to "false" only for local development without HTTPS +# COOKIE_SECURE=false # Rust monitor COORDINATOR_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore index e1abc0e..103ff72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ .env +.env.* +.env.local node_modules/ dist/ target/ *.lock +*.pem +*.key +*.crt +.claude \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 27e5277..a02dc73 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -16,7 +16,42 @@ const CORS_HEADERS = { "access-control-allow-headers": "Content-Type, Authorization", }; +// ── Rate limiter ────────────────────────────────────────────────────── +const rateLimitMap = new Map(); +const RATE_LIMIT_WINDOW = 60_000; // 1 minute + +function rateLimit(ip: string, maxRequests: number): boolean { + const now = Date.now(); + const entry = rateLimitMap.get(ip); + if (!entry || now > entry.resetAt) { + rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW }); + return true; + } + entry.count++; + return entry.count <= maxRequests; +} + +// Cleanup stale entries every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of rateLimitMap) { + if (now > entry.resetAt) rateLimitMap.delete(key); + } +}, 5 * 60_000); + +const SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Strict-Transport-Security": "max-age=63072000; includeSubDomains", + "X-XSS-Protection": "0", + "Referrer-Policy": "strict-origin-when-cross-origin", +}; + const app = new Elysia() + // Security headers on all responses + .onAfterHandle(({ set }) => { + Object.assign(set.headers, SECURITY_HEADERS); + }) .use(cors({ origin: CORS_ORIGIN, credentials: true, diff --git a/apps/api/src/query/index.ts b/apps/api/src/query/index.ts index 81d609b..670c0dd 100644 --- a/apps/api/src/query/index.ts +++ b/apps/api/src/query/index.ts @@ -231,6 +231,7 @@ function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean { case "$regex": { if (typeof fieldVal !== "string" || typeof opVal !== "string") return false; if (opVal.length > 200) return false; + if (isSafeRegex(opVal) === false) return false; try { return new RegExp(opVal).test(fieldVal); } catch { @@ -250,6 +251,18 @@ function toNum(v: unknown): number { return typeof v === "number" ? v : Number(v) || 0; } +/** + * Reject regex patterns likely to cause catastrophic backtracking (ReDoS). + * Blocks nested quantifiers like (a+)+ and star-height > 1 patterns. + */ +function isSafeRegex(pattern: string): boolean { + // Reject nested quantifiers: (x+)+, (x*)+, (x+)*, (x{n,})+, etc. + if (/\([^)]*[+*}]\)[+*{]/.test(pattern)) return false; + // Reject overlapping alternation with quantifiers: (a|a)+ + if (/\([^)]*\|[^)]*\)[+*{]/.test(pattern)) return false; + return true; +} + // ── Validate ─────────────────────────────────────────────────────────── const VALID_OPS = new Set([ diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 5bb0814..66ca71d 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,13 +1,36 @@ import { Elysia, t } from "elysia"; -import { createHash } from "crypto"; +import { createHmac, randomBytes } from "crypto"; import sql from "../db"; +// ── Per-IP rate limiting for auth endpoints ─────────────────────────── +const authRateMap = new Map(); + +function checkAuthRateLimit(ip: string, maxPerMinute: number): boolean { + const now = Date.now(); + const entry = authRateMap.get(ip); + if (!entry || now > entry.resetAt) { + authRateMap.set(ip, { count: 1, resetAt: now + 60_000 }); + return true; + } + entry.count++; + return entry.count <= maxPerMinute; +} + +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of authRateMap) { + if (now > entry.resetAt) authRateMap.delete(key); + } +}, 5 * 60_000); + +const EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-key"; + function generateKey(): string { - return crypto.randomUUID(); + return randomBytes(32).toString("base64url"); } function hashEmail(email: string): string { - return createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); + return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex"); } async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> { @@ -54,16 +77,19 @@ export function requireAuth(app: Elysia) { const COOKIE_OPTS = { httpOnly: true, - secure: process.env.NODE_ENV !== "development", + secure: process.env.COOKIE_SECURE !== "false", sameSite: "lax" as const, path: "/", domain: process.env.COOKIE_DOMAIN ?? ".pingql.com", - maxAge: 60 * 60 * 24 * 365, + maxAge: 60 * 60 * 24 * 30, // 30 days }; export const account = new Elysia({ prefix: "/account" }) - .post("/login", async ({ body, cookie, set }) => { + .post("/login", async ({ body, cookie, set, request, error }) => { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; + if (!checkAuthRateLimit(ip, 10)) return error(429, { error: "Too many login attempts. Try again later." }); + const key = (body.key as string)?.trim(); if (!key) { set.status = 400; return { error: "Key required" }; } @@ -84,7 +110,10 @@ export const account = new Elysia({ prefix: "/account" }) set.redirect = "/dashboard"; }, { detail: { hide: true } }) - .post("/register", async ({ body, cookie }) => { + .post("/register", async ({ body, cookie, request, error }) => { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; + if (!checkAuthRateLimit(ip, 5)) return error(429, { error: "Too many registrations. Try again later." }); + const key = generateKey(); const emailHash = body.email ? hashEmail(body.email) : null; await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`; diff --git a/apps/api/src/routes/internal.ts b/apps/api/src/routes/internal.ts index 0b150c0..373238e 100644 --- a/apps/api/src/routes/internal.ts +++ b/apps/api/src/routes/internal.ts @@ -2,8 +2,17 @@ /// Protected by MONITOR_TOKEN — not exposed to users. import { Elysia } from "elysia"; +import { timingSafeEqual } from "crypto"; import sql from "../db"; +function safeTokenCompare(a: string | undefined, b: string | undefined): boolean { + if (!a || !b) return false; + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + export async function pruneOldPings(retentionDays = 90) { const result = await sql`DELETE FROM pings WHERE checked_at < now() - ${retentionDays + ' days'}::interval`; return result.count; @@ -17,7 +26,7 @@ setInterval(() => { export const internal = new Elysia({ prefix: "/internal", detail: { hide: true } }) .derive(({ headers, error }) => { - if (headers["x-monitor-token"] !== process.env.MONITOR_TOKEN) + if (!safeTokenCompare(headers["x-monitor-token"], process.env.MONITOR_TOKEN)) return error(401, { error: "Unauthorized" }); return {}; }) diff --git a/apps/api/src/routes/monitors.ts b/apps/api/src/routes/monitors.ts index 5993dda..71d77e8 100644 --- a/apps/api/src/routes/monitors.ts +++ b/apps/api/src/routes/monitors.ts @@ -4,11 +4,11 @@ import sql from "../db"; import { validateMonitorUrl } from "../utils/ssrf"; const MonitorBody = t.Object({ - name: t.String({ description: "Human-readable name" }), - url: t.String({ format: "uri", description: "URL to check" }), + name: t.String({ maxLength: 200, description: "Human-readable name" }), + url: t.String({ format: "uri", maxLength: 2048, description: "URL to check" }), method: t.Optional(t.String({ default: "GET", description: "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD" })), request_headers: t.Optional(t.Any({ description: "Request headers as key-value object" })), - request_body: t.Optional(t.Nullable(t.String({ description: "Request body for POST/PUT/PATCH" }))), + request_body: t.Optional(t.Nullable(t.String({ maxLength: 65536, description: "Request body for POST/PUT/PATCH (max 64KB)" }))), timeout_ms: t.Optional(t.Number({ minimum: 1000, maximum: 60000, default: 30000, description: "Request timeout in ms" })), interval_s: t.Optional(t.Number({ minimum: 1, default: 60, description: "Check interval in seconds" })), query: t.Optional(t.Any({ description: "PingQL query — filter conditions for up/down" })), diff --git a/apps/api/src/routes/pings.ts b/apps/api/src/routes/pings.ts index c4ba9b6..193a44f 100644 --- a/apps/api/src/routes/pings.ts +++ b/apps/api/src/routes/pings.ts @@ -1,7 +1,16 @@ import { Elysia, t } from "elysia"; +import { timingSafeEqual } from "crypto"; import sql from "../db"; import { resolveKey } from "./auth"; +function safeTokenCompare(a: string | undefined, b: string | undefined): boolean { + if (!a || !b) return false; + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + // ── SSE bus ─────────────────────────────────────────────────────────────────── type SSEController = ReadableStreamDefaultController; const bus = new Map>(); // keyed by accountId @@ -51,7 +60,11 @@ export const ingest = new Elysia() // Internal: called by Rust monitor runner .post("/internal/ingest", async ({ body, headers, error }) => { const token = headers["x-monitor-token"]; - if (token !== process.env.MONITOR_TOKEN) return error(401, { error: "Unauthorized" }); + if (!safeTokenCompare(token, process.env.MONITOR_TOKEN)) return error(401, { error: "Unauthorized" }); + + // Validate monitor exists + const [monitor_check] = await sql`SELECT id FROM monitors WHERE id = ${body.monitor_id}`; + if (!monitor_check) return error(404, { error: "Monitor not found" }); const meta = body.meta ? { ...body.meta } : {}; if (body.cert_expiry_days != null) meta.cert_expiry_days = body.cert_expiry_days; diff --git a/apps/api/src/utils/ssrf.ts b/apps/api/src/utils/ssrf.ts index 8552cfc..43aa65f 100644 --- a/apps/api/src/utils/ssrf.ts +++ b/apps/api/src/utils/ssrf.ts @@ -21,14 +21,21 @@ function isPrivateIP(ip: string): boolean { if (second >= 16 && second <= 31) return true; } - // IPv6 - if (ip === "::1" || ip === "::") return true; - if (ip.toLowerCase().startsWith("fe80")) return true; // fe80::/10 - if (ip.toLowerCase().startsWith("fd00:ec2::254")) return true; // AWS EC2 metadata - if (ip.toLowerCase() === "::ffff:127.0.0.1") return true; - if (ip.toLowerCase().startsWith("::ffff:")) { + // IPv6 — normalize: strip zone ID (%eth0) and lowercase + const ip6 = ip.replace(/%.*$/, "").toLowerCase(); + if (ip6 === "::1" || ip6 === "::") return true; + if (ip6.startsWith("fe80")) return true; // fe80::/10 link-local + if (ip6.startsWith("fc") || ip6.startsWith("fd")) return true; // fc00::/7 unique-local (ULA) + if (ip6.startsWith("fd00:ec2::")) return true; // AWS EC2 metadata IPv6 + if (ip6 === "::ffff:127.0.0.1") return true; + if (ip6.startsWith("::ffff:")) { // IPv4-mapped IPv6 — extract the IPv4 part and re-check - const v4 = ip.slice(7); + const v4 = ip6.slice(7); + return isPrivateIP(v4); + } + // IPv4-compatible IPv6 (deprecated but still reachable) + if (ip6.startsWith("::") && ip6.includes(".")) { + const v4 = ip6.slice(2); return isPrivateIP(v4); } diff --git a/apps/monitor/src/query.rs b/apps/monitor/src/query.rs index 13a1520..342d899 100644 --- a/apps/monitor/src/query.rs +++ b/apps/monitor/src/query.rs @@ -218,7 +218,11 @@ fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Res } "$regex" => { let pattern = val.as_str().unwrap_or(""); - let re = Regex::new(pattern).unwrap_or_else(|_| Regex::new("$^").unwrap()); + if pattern.len() > 200 { return Ok(false); } + let re = match Regex::new(pattern) { + Ok(r) => r, + Err(_) => return Ok(false), + }; field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false) } "$exists" => { diff --git a/apps/monitor/src/runner.rs b/apps/monitor/src/runner.rs index 40cc023..7a1eba8 100644 --- a/apps/monitor/src/runner.rs +++ b/apps/monitor/src/runner.rs @@ -118,7 +118,19 @@ async fn run_check(client: &reqwest::Client, monitor: &Monitor, scheduled_at: Op .filter_map(|(k, v)| Some((k.to_string(), v.to_str().ok()?.to_string()))) .collect(); - let body = resp.text().await.unwrap_or_default(); + // Limit response body to 10MB to prevent OOM from malicious targets + const MAX_BODY_BYTES: usize = 10 * 1024 * 1024; + let body = { + let content_len = resp.content_length().unwrap_or(0) as usize; + if content_len > MAX_BODY_BYTES { + // Skip reading body entirely if Content-Length exceeds limit + format!("[body truncated: Content-Length {} exceeds 10MB limit]", content_len) + } else { + let bytes = resp.bytes().await.unwrap_or_default(); + let truncated = &bytes[..bytes.len().min(MAX_BODY_BYTES)]; + String::from_utf8_lossy(truncated).into_owned() + } + }; // Evaluate query if present let (up, query_error) = if let Some(q) = &monitor.query { diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index b9a6334..47d6be4 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -6,7 +6,18 @@ import { migrate } from "./db"; await migrate(); +const SECURITY_HEADERS = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Strict-Transport-Security": "max-age=63072000; includeSubDomains", + "X-XSS-Protection": "0", + "Referrer-Policy": "strict-origin-when-cross-origin", +}; + const app = new Elysia() + .onAfterHandle(({ set }) => { + Object.assign(set.headers, SECURITY_HEADERS); + }) .use(cors({ origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"], credentials: true, diff --git a/apps/web/src/query/index.ts b/apps/web/src/query/index.ts index 81d609b..670c0dd 100644 --- a/apps/web/src/query/index.ts +++ b/apps/web/src/query/index.ts @@ -231,6 +231,7 @@ function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean { case "$regex": { if (typeof fieldVal !== "string" || typeof opVal !== "string") return false; if (opVal.length > 200) return false; + if (isSafeRegex(opVal) === false) return false; try { return new RegExp(opVal).test(fieldVal); } catch { @@ -250,6 +251,18 @@ function toNum(v: unknown): number { return typeof v === "number" ? v : Number(v) || 0; } +/** + * Reject regex patterns likely to cause catastrophic backtracking (ReDoS). + * Blocks nested quantifiers like (a+)+ and star-height > 1 patterns. + */ +function isSafeRegex(pattern: string): boolean { + // Reject nested quantifiers: (x+)+, (x*)+, (x+)*, (x{n,})+, etc. + if (/\([^)]*[+*}]\)[+*{]/.test(pattern)) return false; + // Reject overlapping alternation with quantifiers: (a|a)+ + if (/\([^)]*\|[^)]*\)[+*{]/.test(pattern)) return false; + return true; +} + // ── Validate ─────────────────────────────────────────────────────────── const VALID_OPS = new Set([ diff --git a/apps/web/src/routes/auth.ts b/apps/web/src/routes/auth.ts index 5bb0814..b47e0a9 100644 --- a/apps/web/src/routes/auth.ts +++ b/apps/web/src/routes/auth.ts @@ -1,13 +1,36 @@ import { Elysia, t } from "elysia"; -import { createHash } from "crypto"; +import { createHmac, randomBytes } from "crypto"; import sql from "../db"; +const EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-key"; + +// ── Per-IP rate limiting for auth endpoints ─────────────────────────── +const authRateMap = new Map(); + +function checkAuthRateLimit(ip: string, maxPerMinute: number): boolean { + const now = Date.now(); + const entry = authRateMap.get(ip); + if (!entry || now > entry.resetAt) { + authRateMap.set(ip, { count: 1, resetAt: now + 60_000 }); + return true; + } + entry.count++; + return entry.count <= maxPerMinute; +} + +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of authRateMap) { + if (now > entry.resetAt) authRateMap.delete(key); + } +}, 5 * 60_000); + function generateKey(): string { - return crypto.randomUUID(); + return randomBytes(32).toString("base64url"); } function hashEmail(email: string): string { - return createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); + return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex"); } async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> { @@ -54,16 +77,19 @@ export function requireAuth(app: Elysia) { const COOKIE_OPTS = { httpOnly: true, - secure: process.env.NODE_ENV !== "development", + secure: process.env.COOKIE_SECURE !== "false", sameSite: "lax" as const, path: "/", domain: process.env.COOKIE_DOMAIN ?? ".pingql.com", - maxAge: 60 * 60 * 24 * 365, + maxAge: 60 * 60 * 24 * 30, // 30 days }; export const account = new Elysia({ prefix: "/account" }) - .post("/login", async ({ body, cookie, set }) => { + .post("/login", async ({ body, cookie, set, request, error }) => { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; + if (!checkAuthRateLimit(ip, 10)) return error(429, { error: "Too many login attempts. Try again later." }); + const key = (body.key as string)?.trim(); if (!key) { set.status = 400; return { error: "Key required" }; } @@ -84,7 +110,10 @@ export const account = new Elysia({ prefix: "/account" }) set.redirect = "/dashboard"; }, { detail: { hide: true } }) - .post("/register", async ({ body, cookie }) => { + .post("/register", async ({ body, cookie, request, error }) => { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; + if (!checkAuthRateLimit(ip, 5)) return error(429, { error: "Too many registrations. Try again later." }); + const key = generateKey(); const emailHash = body.email ? hashEmail(body.email) : null; await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;