fix: harden auth, SSRF, query engine, and cookie security

This commit is contained in:
nate 2026-03-18 11:37:33 +04:00
parent d278ab0458
commit 5a0cf5033b
14 changed files with 212 additions and 28 deletions

View File

@ -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

6
.gitignore vendored
View File

@ -1,5 +1,11 @@
.env
.env.*
.env.local
node_modules/
dist/
target/
*.lock
*.pem
*.key
*.crt
.claude

View File

@ -16,7 +16,42 @@ const CORS_HEADERS = {
"access-control-allow-headers": "Content-Type, Authorization",
};
// ── Rate limiter ──────────────────────────────────────────────────────
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
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,

View File

@ -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([

View File

@ -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<string, { count: number; resetAt: number }>();
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})`;

View File

@ -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 {};
})

View File

@ -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" })),

View File

@ -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<Uint8Array>;
const bus = new Map<string, Set<SSEController>>(); // 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;

View File

@ -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);
}

View File

@ -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" => {

View File

@ -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 {

View File

@ -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,

View File

@ -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([

View File

@ -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<string, { count: number; resetAt: number }>();
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})`;