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
+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";
// ── 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})`;
+10 -1
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 {};
})
+3 -3
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" })),
+14 -1
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;