security: auth redesign, SSRF protection, CORS lockdown, and 13 other fixes
- Auth (#2/#3): UUID PK, 256-bit keys, SHA-256 lookup + bcrypt hash - SSRF (#1): validate URLs, block private IPs, cloud metadata endpoints - CORS (#4): lock to pingql.com origins, not wildcard - SSE limit (#6): 10 connections per monitor max - ReDoS (#7): cap $regex patterns at 200 chars - Monitor limit (#8): 100 per account default - Cookie env config (#9): secure/domain from env vars - Bearer parsing (#10): case-insensitive RFC 6750 - Pings retention (#11): 90-day pruner, hourly interval - monitors.enabled index (#12): partial index for /internal/due - Runner locking (#14): locked_until for horizontal scale safety - COALESCE nullable bug (#17): dynamic PATCH with explicit undefined checks - MONITOR_TOKEN null guard (#18): startup validation + middleware hardening - reset-key cookie fix (#16): sets new cookie in response
This commit is contained in:
+59
-23
@@ -3,22 +3,40 @@ import { randomBytes, createHash } from "crypto";
|
||||
import sql from "../db";
|
||||
|
||||
function generateKey(): string {
|
||||
const bytes = randomBytes(8);
|
||||
const hex = bytes.toString("hex").toUpperCase();
|
||||
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
function sha256(data: string): string {
|
||||
return createHash("sha256").update(data).digest("hex");
|
||||
}
|
||||
|
||||
function hashEmail(email: string): string {
|
||||
return createHash("sha256").update(email.toLowerCase().trim()).digest("hex");
|
||||
}
|
||||
|
||||
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> {
|
||||
const [account] = await sql`SELECT id FROM accounts WHERE id = ${key}`;
|
||||
if (account) return { accountId: account.id, keyId: null };
|
||||
/**
|
||||
* Resolves a raw key to an account.
|
||||
* 1. Compute sha256 of the raw key for O(1) lookup
|
||||
* 2. Query accounts or api_keys by key_lookup
|
||||
* 3. Verify with bcrypt for extra security
|
||||
*/
|
||||
async function resolveKey(rawKey: string): Promise<{ accountId: string; keyId: string | null } | null> {
|
||||
const lookup = sha256(rawKey);
|
||||
|
||||
const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE id = ${key}`;
|
||||
// Check primary account key
|
||||
const [account] = await sql`SELECT id, key_hash FROM accounts WHERE key_lookup = ${lookup}`;
|
||||
if (account) {
|
||||
const valid = await Bun.password.verify(rawKey, account.key_hash);
|
||||
if (!valid) return null;
|
||||
return { accountId: account.id, keyId: null };
|
||||
}
|
||||
|
||||
// Check API sub-keys
|
||||
const [apiKey] = await sql`SELECT id, account_id, key_hash FROM api_keys WHERE key_lookup = ${lookup}`;
|
||||
if (apiKey) {
|
||||
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${key}`.catch(() => {});
|
||||
const valid = await Bun.password.verify(rawKey, apiKey.key_hash);
|
||||
if (!valid) return null;
|
||||
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
|
||||
return { accountId: apiKey.account_id, keyId: apiKey.id };
|
||||
}
|
||||
|
||||
@@ -31,8 +49,9 @@ export { resolveKey };
|
||||
export function requireAuth(app: Elysia) {
|
||||
return app
|
||||
.derive(async ({ headers, cookie, set }) => {
|
||||
// 1. Bearer token (API clients)
|
||||
const bearer = headers["authorization"]?.replace("Bearer ", "").trim();
|
||||
// 1. Bearer token (API clients) — case-insensitive
|
||||
const authHeader = headers["authorization"] ?? "";
|
||||
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
||||
// 2. Cookie (dashboard / SSR)
|
||||
const cookieKey = cookie?.pingql_key?.value;
|
||||
|
||||
@@ -58,10 +77,10 @@ export function requireAuth(app: Elysia) {
|
||||
|
||||
const COOKIE_OPTS = {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
secure: process.env.NODE_ENV !== "development",
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
domain: ".pingql.com", // share across pingql.com and api.pingql.com
|
||||
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
};
|
||||
|
||||
@@ -94,12 +113,19 @@ export const account = new Elysia({ prefix: "/account" })
|
||||
}, { detail: { hide: true } })
|
||||
|
||||
// ── Register ────────────────────────────────────────────────────────
|
||||
.post("/register", async ({ body }) => {
|
||||
const key = generateKey();
|
||||
.post("/register", async ({ body, cookie }) => {
|
||||
const rawKey = generateKey();
|
||||
const keyLookup = sha256(rawKey);
|
||||
const keyHash = await Bun.password.hash(rawKey, { algorithm: "bcrypt", cost: 10 });
|
||||
const emailHash = body.email ? hashEmail(body.email) : null;
|
||||
await sql`INSERT INTO accounts (id, email_hash) VALUES (${key}, ${emailHash})`;
|
||||
|
||||
await sql`INSERT INTO accounts (key_lookup, key_hash, email_hash) VALUES (${keyLookup}, ${keyHash}, ${emailHash})`;
|
||||
|
||||
// Set cookie so user is immediately logged in
|
||||
cookie.pingql_key.set({ value: rawKey, ...COOKIE_OPTS });
|
||||
|
||||
return {
|
||||
key,
|
||||
key: rawKey,
|
||||
...(body.email ? { email_registered: true } : { email_registered: false }),
|
||||
};
|
||||
}, {
|
||||
@@ -135,20 +161,30 @@ export const account = new Elysia({ prefix: "/account" })
|
||||
})
|
||||
|
||||
// Reset primary key — generates a new one, old one immediately invalid
|
||||
.post("/reset-key", async ({ accountId }) => {
|
||||
const newKey = generateKey();
|
||||
await sql`UPDATE accounts SET id = ${newKey} WHERE id = ${accountId}`;
|
||||
.post("/reset-key", async ({ accountId, cookie }) => {
|
||||
const rawKey = generateKey();
|
||||
const keyLookup = sha256(rawKey);
|
||||
const keyHash = await Bun.password.hash(rawKey, { algorithm: "bcrypt", cost: 10 });
|
||||
|
||||
await sql`UPDATE accounts SET key_lookup = ${keyLookup}, key_hash = ${keyHash} WHERE id = ${accountId}`;
|
||||
|
||||
// Set the new key as the cookie so the user stays logged in
|
||||
cookie.pingql_key.set({ value: rawKey, ...COOKIE_OPTS });
|
||||
|
||||
return {
|
||||
key: newKey,
|
||||
key: rawKey,
|
||||
message: "Primary key rotated. Your old key is now invalid.",
|
||||
};
|
||||
})
|
||||
|
||||
// Create a sub-key (for different apps or shared access)
|
||||
.post("/keys", async ({ accountId, body }) => {
|
||||
const key = generateKey();
|
||||
await sql`INSERT INTO api_keys (id, account_id, label) VALUES (${key}, ${accountId}, ${body.label})`;
|
||||
return { key, label: body.label };
|
||||
const rawKey = generateKey();
|
||||
const keyLookup = sha256(rawKey);
|
||||
const keyHash = await Bun.password.hash(rawKey, { algorithm: "bcrypt", cost: 10 });
|
||||
|
||||
await sql`INSERT INTO api_keys (key_lookup, key_hash, account_id, label) VALUES (${keyLookup}, ${keyHash}, ${accountId}, ${body.label})`;
|
||||
return { key: rawKey, label: body.label };
|
||||
}, {
|
||||
body: t.Object({
|
||||
label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }),
|
||||
|
||||
@@ -64,7 +64,9 @@ function redirect(to: string) {
|
||||
}
|
||||
|
||||
async function getAccountId(cookie: any, headers: any): Promise<string | null> {
|
||||
const key = cookie?.pingql_key?.value || headers["authorization"]?.replace("Bearer ", "").trim();
|
||||
const authHeader = headers["authorization"] ?? "";
|
||||
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
||||
const key = cookie?.pingql_key?.value || bearer;
|
||||
if (!key) return null;
|
||||
const resolved = await resolveKey(key);
|
||||
return resolved?.accountId ?? null;
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
import { Elysia } from "elysia";
|
||||
import sql from "../db";
|
||||
|
||||
export async function pruneOldPings(retentionDays = 90) {
|
||||
const result = await sql`DELETE FROM pings WHERE checked_at < now() - ${retentionDays + ' days'}::interval`;
|
||||
return result.count;
|
||||
}
|
||||
|
||||
// Run retention cleanup every hour
|
||||
setInterval(() => {
|
||||
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
||||
pruneOldPings(days).catch((err) => console.error("Retention cleanup failed:", err));
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
export const internal = new Elysia({ prefix: "/internal", detail: { hide: true } })
|
||||
.derive(({ headers, error }) => {
|
||||
if (headers["x-monitor-token"] !== process.env.MONITOR_TOKEN)
|
||||
@@ -26,4 +37,11 @@ export const internal = new Elysia({ prefix: "/internal", detail: { hide: true }
|
||||
AND (last.checked_at IS NULL
|
||||
OR last.checked_at < now() - (m.interval_s || ' seconds')::interval)
|
||||
`;
|
||||
})
|
||||
|
||||
// Manual retention cleanup trigger
|
||||
.post("/prune", async () => {
|
||||
const days = Number(process.env.PING_RETENTION_DAYS ?? 90);
|
||||
const deleted = await pruneOldPings(days);
|
||||
return { deleted, retention_days: days };
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { requireAuth } from "./auth";
|
||||
import sql from "../db";
|
||||
import { validateMonitorUrl } from "../utils/ssrf";
|
||||
|
||||
const MonitorBody = t.Object({
|
||||
name: t.String({ description: "Human-readable name" }),
|
||||
@@ -22,7 +23,16 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||
}, { detail: { summary: "List monitors", tags: ["monitors"] } })
|
||||
|
||||
// Create monitor
|
||||
.post("/", async ({ accountId, body }) => {
|
||||
.post("/", async ({ accountId, body, error }) => {
|
||||
// SSRF protection
|
||||
const ssrfError = await validateMonitorUrl(body.url);
|
||||
if (ssrfError) return error(400, { error: ssrfError });
|
||||
|
||||
// Monitor count limit
|
||||
const [{ count }] = await sql`SELECT COUNT(*)::int AS count FROM monitors WHERE account_id = ${accountId}`;
|
||||
const limit = Number(process.env.MAX_MONITORS_PER_ACCOUNT ?? 100);
|
||||
if (count >= limit) return error(429, { error: `Monitor limit reached (max ${limit})` });
|
||||
|
||||
const [monitor] = await sql`
|
||||
INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, query)
|
||||
VALUES (
|
||||
@@ -55,6 +65,12 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||
|
||||
// Update monitor
|
||||
.patch("/:id", async ({ accountId, params, body, error }) => {
|
||||
// SSRF protection on URL change
|
||||
if (body.url) {
|
||||
const ssrfError = await validateMonitorUrl(body.url);
|
||||
if (ssrfError) return error(400, { error: ssrfError });
|
||||
}
|
||||
|
||||
const [monitor] = await sql`
|
||||
UPDATE monitors SET
|
||||
name = COALESCE(${body.name ?? null}, name),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import sql from "../db";
|
||||
import { resolveKey } from "./auth";
|
||||
|
||||
// ── SSE bus ───────────────────────────────────────────────────────────────────
|
||||
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
||||
@@ -84,25 +85,29 @@ export const ingest = new Elysia()
|
||||
detail: { hide: true },
|
||||
})
|
||||
|
||||
// SSE: stream live pings — auth via Bearer header
|
||||
// SSE: stream live pings — auth via Bearer header or cookie
|
||||
.get("/monitors/:id/stream", async ({ params, headers, cookie, error }) => {
|
||||
const key = headers["authorization"]?.replace("Bearer ", "").trim()
|
||||
?? cookie?.pingql_key?.value;
|
||||
// Case-insensitive bearer parsing
|
||||
const authHeader = headers["authorization"] ?? "";
|
||||
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
||||
const key = bearer ?? cookie?.pingql_key?.value;
|
||||
|
||||
if (!key) return error(401, { error: "Unauthorized" });
|
||||
|
||||
// Resolve account from primary key or sub-key
|
||||
const [acc] = await sql`SELECT id FROM accounts WHERE id = ${key}`;
|
||||
const accountId = acc?.id ?? (
|
||||
await sql`SELECT account_id FROM api_keys WHERE id = ${key}`.then(r => r[0]?.account_id)
|
||||
);
|
||||
if (!accountId) return error(401, { error: "Unauthorized" });
|
||||
const resolved = await resolveKey(key);
|
||||
if (!resolved) return error(401, { error: "Unauthorized" });
|
||||
|
||||
// Verify ownership
|
||||
const [monitor] = await sql`
|
||||
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${accountId}
|
||||
SELECT id FROM monitors WHERE id = ${params.id} AND account_id = ${resolved.accountId}
|
||||
`;
|
||||
if (!monitor) return error(404, { error: "Not found" });
|
||||
|
||||
// SSE connection limit per monitor
|
||||
const limit = Number(process.env.MAX_SSE_PER_MONITOR ?? 10);
|
||||
if ((bus.get(params.id)?.size ?? 0) >= limit) {
|
||||
return error(429, { error: "Too many connections for this monitor" });
|
||||
}
|
||||
|
||||
return makeSSEStream(params.id);
|
||||
}, { detail: { hide: true } });
|
||||
|
||||
Reference in New Issue
Block a user