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

This commit is contained in:
2026-03-18 11:37:33 +04:00
parent d278ab0458
commit 5a0cf5033b
14 changed files with 212 additions and 28 deletions
+11
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,
+13
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([
+36 -7
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})`;