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
|
# Web app + coordinator
|
||||||
DATABASE_URL=postgres://pingql:pingql@localhost:5432/pingql
|
DATABASE_URL=postgres://pingql:pingql@localhost:5432/pingql
|
||||||
MONITOR_TOKEN=changeme-use-a-random-secret
|
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
|
# Rust monitor
|
||||||
COORDINATOR_URL=http://localhost:3000
|
COORDINATOR_URL=http://localhost:3000
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
.env.local
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
target/
|
target/
|
||||||
*.lock
|
*.lock
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
.claude
|
||||||
|
|
@ -16,7 +16,42 @@ const CORS_HEADERS = {
|
||||||
"access-control-allow-headers": "Content-Type, Authorization",
|
"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()
|
const app = new Elysia()
|
||||||
|
// Security headers on all responses
|
||||||
|
.onAfterHandle(({ set }) => {
|
||||||
|
Object.assign(set.headers, SECURITY_HEADERS);
|
||||||
|
})
|
||||||
.use(cors({
|
.use(cors({
|
||||||
origin: CORS_ORIGIN,
|
origin: CORS_ORIGIN,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean {
|
||||||
case "$regex": {
|
case "$regex": {
|
||||||
if (typeof fieldVal !== "string" || typeof opVal !== "string") return false;
|
if (typeof fieldVal !== "string" || typeof opVal !== "string") return false;
|
||||||
if (opVal.length > 200) return false;
|
if (opVal.length > 200) return false;
|
||||||
|
if (isSafeRegex(opVal) === false) return false;
|
||||||
try {
|
try {
|
||||||
return new RegExp(opVal).test(fieldVal);
|
return new RegExp(opVal).test(fieldVal);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -250,6 +251,18 @@ function toNum(v: unknown): number {
|
||||||
return typeof v === "number" ? v : Number(v) || 0;
|
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 ───────────────────────────────────────────────────────────
|
// ── Validate ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const VALID_OPS = new Set([
|
const VALID_OPS = new Set([
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,36 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { createHash } from "crypto";
|
import { createHmac, randomBytes } from "crypto";
|
||||||
import sql from "../db";
|
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 {
|
function generateKey(): string {
|
||||||
return crypto.randomUUID();
|
return randomBytes(32).toString("base64url");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashEmail(email: string): string {
|
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> {
|
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> {
|
||||||
|
|
@ -54,16 +77,19 @@ export function requireAuth(app: Elysia) {
|
||||||
|
|
||||||
const COOKIE_OPTS = {
|
const COOKIE_OPTS = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV !== "development",
|
secure: process.env.COOKIE_SECURE !== "false",
|
||||||
sameSite: "lax" as const,
|
sameSite: "lax" as const,
|
||||||
path: "/",
|
path: "/",
|
||||||
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
|
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" })
|
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();
|
const key = (body.key as string)?.trim();
|
||||||
if (!key) { set.status = 400; return { error: "Key required" }; }
|
if (!key) { set.status = 400; return { error: "Key required" }; }
|
||||||
|
|
||||||
|
|
@ -84,7 +110,10 @@ export const account = new Elysia({ prefix: "/account" })
|
||||||
set.redirect = "/dashboard";
|
set.redirect = "/dashboard";
|
||||||
}, { detail: { hide: true } })
|
}, { 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 key = generateKey();
|
||||||
const emailHash = body.email ? hashEmail(body.email) : null;
|
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||||
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,17 @@
|
||||||
/// Protected by MONITOR_TOKEN — not exposed to users.
|
/// Protected by MONITOR_TOKEN — not exposed to users.
|
||||||
|
|
||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
|
import { timingSafeEqual } from "crypto";
|
||||||
import sql from "../db";
|
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) {
|
export async function pruneOldPings(retentionDays = 90) {
|
||||||
const result = await sql`DELETE FROM pings WHERE checked_at < now() - ${retentionDays + ' days'}::interval`;
|
const result = await sql`DELETE FROM pings WHERE checked_at < now() - ${retentionDays + ' days'}::interval`;
|
||||||
return result.count;
|
return result.count;
|
||||||
|
|
@ -17,7 +26,7 @@ setInterval(() => {
|
||||||
|
|
||||||
export const internal = new Elysia({ prefix: "/internal", detail: { hide: true } })
|
export const internal = new Elysia({ prefix: "/internal", detail: { hide: true } })
|
||||||
.derive(({ headers, error }) => {
|
.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 error(401, { error: "Unauthorized" });
|
||||||
return {};
|
return {};
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import sql from "../db";
|
||||||
import { validateMonitorUrl } from "../utils/ssrf";
|
import { validateMonitorUrl } from "../utils/ssrf";
|
||||||
|
|
||||||
const MonitorBody = t.Object({
|
const MonitorBody = t.Object({
|
||||||
name: t.String({ description: "Human-readable name" }),
|
name: t.String({ maxLength: 200, description: "Human-readable name" }),
|
||||||
url: t.String({ format: "uri", description: "URL to check" }),
|
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" })),
|
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_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" })),
|
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" })),
|
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" })),
|
query: t.Optional(t.Any({ description: "PingQL query — filter conditions for up/down" })),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
|
import { timingSafeEqual } from "crypto";
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
import { resolveKey } from "./auth";
|
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 ───────────────────────────────────────────────────────────────────
|
// ── SSE bus ───────────────────────────────────────────────────────────────────
|
||||||
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
||||||
const bus = new Map<string, Set<SSEController>>(); // keyed by accountId
|
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
|
// Internal: called by Rust monitor runner
|
||||||
.post("/internal/ingest", async ({ body, headers, error }) => {
|
.post("/internal/ingest", async ({ body, headers, error }) => {
|
||||||
const token = headers["x-monitor-token"];
|
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 } : {};
|
const meta = body.meta ? { ...body.meta } : {};
|
||||||
if (body.cert_expiry_days != null) meta.cert_expiry_days = body.cert_expiry_days;
|
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;
|
if (second >= 16 && second <= 31) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv6
|
// IPv6 — normalize: strip zone ID (%eth0) and lowercase
|
||||||
if (ip === "::1" || ip === "::") return true;
|
const ip6 = ip.replace(/%.*$/, "").toLowerCase();
|
||||||
if (ip.toLowerCase().startsWith("fe80")) return true; // fe80::/10
|
if (ip6 === "::1" || ip6 === "::") return true;
|
||||||
if (ip.toLowerCase().startsWith("fd00:ec2::254")) return true; // AWS EC2 metadata
|
if (ip6.startsWith("fe80")) return true; // fe80::/10 link-local
|
||||||
if (ip.toLowerCase() === "::ffff:127.0.0.1") return true;
|
if (ip6.startsWith("fc") || ip6.startsWith("fd")) return true; // fc00::/7 unique-local (ULA)
|
||||||
if (ip.toLowerCase().startsWith("::ffff:")) {
|
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
|
// 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);
|
return isPrivateIP(v4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,11 @@ fn eval_op(op: &str, field_val: &Value, val: &Value, response: &Response) -> Res
|
||||||
}
|
}
|
||||||
"$regex" => {
|
"$regex" => {
|
||||||
let pattern = val.as_str().unwrap_or("");
|
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)
|
field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false)
|
||||||
}
|
}
|
||||||
"$exists" => {
|
"$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())))
|
.filter_map(|(k, v)| Some((k.to_string(), v.to_str().ok()?.to_string())))
|
||||||
.collect();
|
.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
|
// Evaluate query if present
|
||||||
let (up, query_error) = if let Some(q) = &monitor.query {
|
let (up, query_error) = if let Some(q) = &monitor.query {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,18 @@ import { migrate } from "./db";
|
||||||
|
|
||||||
await migrate();
|
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()
|
const app = new Elysia()
|
||||||
|
.onAfterHandle(({ set }) => {
|
||||||
|
Object.assign(set.headers, SECURITY_HEADERS);
|
||||||
|
})
|
||||||
.use(cors({
|
.use(cors({
|
||||||
origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"],
|
origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com"],
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean {
|
||||||
case "$regex": {
|
case "$regex": {
|
||||||
if (typeof fieldVal !== "string" || typeof opVal !== "string") return false;
|
if (typeof fieldVal !== "string" || typeof opVal !== "string") return false;
|
||||||
if (opVal.length > 200) return false;
|
if (opVal.length > 200) return false;
|
||||||
|
if (isSafeRegex(opVal) === false) return false;
|
||||||
try {
|
try {
|
||||||
return new RegExp(opVal).test(fieldVal);
|
return new RegExp(opVal).test(fieldVal);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -250,6 +251,18 @@ function toNum(v: unknown): number {
|
||||||
return typeof v === "number" ? v : Number(v) || 0;
|
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 ───────────────────────────────────────────────────────────
|
// ── Validate ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const VALID_OPS = new Set([
|
const VALID_OPS = new Set([
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,36 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { createHash } from "crypto";
|
import { createHmac, randomBytes } from "crypto";
|
||||||
import sql from "../db";
|
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 {
|
function generateKey(): string {
|
||||||
return crypto.randomUUID();
|
return randomBytes(32).toString("base64url");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashEmail(email: string): string {
|
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> {
|
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> {
|
||||||
|
|
@ -54,16 +77,19 @@ export function requireAuth(app: Elysia) {
|
||||||
|
|
||||||
const COOKIE_OPTS = {
|
const COOKIE_OPTS = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV !== "development",
|
secure: process.env.COOKIE_SECURE !== "false",
|
||||||
sameSite: "lax" as const,
|
sameSite: "lax" as const,
|
||||||
path: "/",
|
path: "/",
|
||||||
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
|
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" })
|
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();
|
const key = (body.key as string)?.trim();
|
||||||
if (!key) { set.status = 400; return { error: "Key required" }; }
|
if (!key) { set.status = 400; return { error: "Key required" }; }
|
||||||
|
|
||||||
|
|
@ -84,7 +110,10 @@ export const account = new Elysia({ prefix: "/account" })
|
||||||
set.redirect = "/dashboard";
|
set.redirect = "/dashboard";
|
||||||
}, { detail: { hide: true } })
|
}, { 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 key = generateKey();
|
||||||
const emailHash = body.email ? hashEmail(body.email) : null;
|
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||||
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue