fix: harden auth, SSRF, query engine, and cookie security
This commit is contained in:
parent
d278ab0458
commit
5a0cf5033b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
node_modules/
|
||||
dist/
|
||||
target/
|
||||
*.lock
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
.claude
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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})`;
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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" })),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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})`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue