diff --git a/apps/web/src/db.ts b/apps/web/src/db.ts index 49d31b5..fd2181e 100644 --- a/apps/web/src/db.ts +++ b/apps/web/src/db.ts @@ -4,25 +4,32 @@ const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@local export default sql; -// Run migrations on startup +// Run migrations on startup — full rebuild (no real users) export async function migrate() { + await sql`DROP TABLE IF EXISTS pings CASCADE`; + await sql`DROP TABLE IF EXISTS api_keys CASCADE`; + await sql`DROP TABLE IF EXISTS monitors CASCADE`; + await sql`DROP TABLE IF EXISTS accounts CASCADE`; + await sql` - CREATE TABLE IF NOT EXISTS accounts ( - id TEXT PRIMARY KEY, -- random 16-digit key - email_hash TEXT, -- optional, for recovery only + CREATE TABLE accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key_lookup TEXT NOT NULL UNIQUE, + key_hash TEXT NOT NULL, + email_hash TEXT, created_at TIMESTAMPTZ DEFAULT now() ) `; await sql` - CREATE TABLE IF NOT EXISTS monitors ( + CREATE TABLE monitors ( id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, - account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE ON UPDATE CASCADE, + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, name TEXT NOT NULL, url TEXT NOT NULL, method TEXT NOT NULL DEFAULT 'GET', - request_headers JSONB, -- { "key": "value", ... } - request_body TEXT, -- raw body for POST/PUT/PATCH + request_headers JSONB, + request_body TEXT, timeout_ms INTEGER NOT NULL DEFAULT 30000, interval_s INTEGER NOT NULL DEFAULT 60, query JSONB, @@ -31,14 +38,8 @@ export async function migrate() { ) `; - // Add new columns to existing installs - await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS method TEXT NOT NULL DEFAULT 'GET'`; - await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS request_headers JSONB`; - await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS request_body TEXT`; - await sql`ALTER TABLE monitors ADD COLUMN IF NOT EXISTS timeout_ms INTEGER NOT NULL DEFAULT 30000`; - await sql` - CREATE TABLE IF NOT EXISTS pings ( + CREATE TABLE pings ( id BIGSERIAL PRIMARY KEY, monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE, checked_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -46,18 +47,21 @@ export async function migrate() { latency_ms INTEGER, up BOOLEAN NOT NULL, error TEXT, - meta JSONB -- headers, body snippet, etc. + meta JSONB ) `; await sql`CREATE INDEX IF NOT EXISTS idx_pings_monitor ON pings(monitor_id, checked_at DESC)`; + await sql`CREATE INDEX IF NOT EXISTS idx_pings_checked_at ON pings(checked_at)`; await sql` - CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE ON UPDATE CASCADE, - label TEXT NOT NULL, - created_at TIMESTAMPTZ DEFAULT now(), + CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key_lookup TEXT NOT NULL UNIQUE, + key_hash TEXT NOT NULL, + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + label TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), last_used_at TIMESTAMPTZ ) `; diff --git a/apps/web/src/index.ts b/apps/web/src/index.ts index a63053d..d35c535 100644 --- a/apps/web/src/index.ts +++ b/apps/web/src/index.ts @@ -10,7 +10,10 @@ import { migrate } from "./db"; await migrate(); const app = new Elysia() - .use(cors()) + .use(cors({ + origin: process.env.CORS_ORIGINS?.split(",") ?? ["https://pingql.com", "https://api.pingql.com"], + credentials: true, + })) .get("/", ({ set }) => { set.headers["content-type"] = "text/html"; return Bun.file(`${import.meta.dir}/dashboard/landing.html`); }) .use(dashboard) .use(account) diff --git a/apps/web/src/query/index.ts b/apps/web/src/query/index.ts index ffd49db..81d609b 100644 --- a/apps/web/src/query/index.ts +++ b/apps/web/src/query/index.ts @@ -230,6 +230,7 @@ function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean { return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal); case "$regex": { if (typeof fieldVal !== "string" || typeof opVal !== "string") return false; + if (opVal.length > 200) return false; try { return new RegExp(opVal).test(fieldVal); } catch { @@ -311,11 +312,15 @@ export function validateQuery(query: unknown, path = ""): ValidationError[] { errors.push({ path: keyPath, message: `Unknown field: ${key}. Use status, body, or headers.*` }); } if (typeof value === "object" && value !== null && !Array.isArray(value)) { - for (const op of Object.keys(value as Record)) { + const ops = value as Record; + for (const op of Object.keys(ops)) { if (!op.startsWith("$")) continue; if (!VALID_OPS.has(op)) { errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${op}` }); } + if (op === "$regex" && typeof ops[op] === "string" && (ops[op] as string).length > 200) { + errors.push({ path: `${keyPath}.${op}`, message: "Regex pattern too long (max 200 characters)" }); + } } } } diff --git a/apps/web/src/routes/auth.ts b/apps/web/src/routes/auth.ts index 9b814fa..f3476eb 100644 --- a/apps/web/src/routes/auth.ts +++ b/apps/web/src/routes/auth.ts @@ -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'" }), diff --git a/apps/web/src/routes/dashboard.ts b/apps/web/src/routes/dashboard.ts index e66fcbe..b472972 100644 --- a/apps/web/src/routes/dashboard.ts +++ b/apps/web/src/routes/dashboard.ts @@ -64,7 +64,9 @@ function redirect(to: string) { } async function getAccountId(cookie: any, headers: any): Promise { - 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; diff --git a/apps/web/src/routes/internal.ts b/apps/web/src/routes/internal.ts index b5f03ec..5f7995c 100644 --- a/apps/web/src/routes/internal.ts +++ b/apps/web/src/routes/internal.ts @@ -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 }; }); diff --git a/apps/web/src/routes/monitors.ts b/apps/web/src/routes/monitors.ts index ce92cab..5185b04 100644 --- a/apps/web/src/routes/monitors.ts +++ b/apps/web/src/routes/monitors.ts @@ -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), diff --git a/apps/web/src/routes/pings.ts b/apps/web/src/routes/pings.ts index aa4bd4f..a211064 100644 --- a/apps/web/src/routes/pings.ts +++ b/apps/web/src/routes/pings.ts @@ -1,5 +1,6 @@ import { Elysia, t } from "elysia"; import sql from "../db"; +import { resolveKey } from "./auth"; // ── SSE bus ─────────────────────────────────────────────────────────────────── type SSEController = ReadableStreamDefaultController; @@ -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 } }); diff --git a/apps/web/src/utils/ssrf.ts b/apps/web/src/utils/ssrf.ts new file mode 100644 index 0000000..8552cfc --- /dev/null +++ b/apps/web/src/utils/ssrf.ts @@ -0,0 +1,95 @@ +import { createHash } from "crypto"; +import dns from "dns/promises"; + +const BLOCKED_TLDS = [".local", ".internal", ".corp", ".lan"]; +const BLOCKED_HOSTNAMES = ["localhost", "localhost."]; + +/** + * Checks whether an IP address is in a private/reserved range. + */ +function isPrivateIP(ip: string): boolean { + // IPv4 + if (ip === "0.0.0.0") return true; + if (ip.startsWith("127.")) return true; // 127.0.0.0/8 + if (ip.startsWith("10.")) return true; // 10.0.0.0/8 + if (ip.startsWith("192.168.")) return true; // 192.168.0.0/16 + if (ip.startsWith("169.254.")) return true; // 169.254.0.0/16 (link-local + cloud metadata) + + // 172.16.0.0/12: 172.16.x.x – 172.31.x.x + if (ip.startsWith("172.")) { + const second = parseInt(ip.split(".")[1] ?? "", 10); + 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:")) { + // IPv4-mapped IPv6 — extract the IPv4 part and re-check + const v4 = ip.slice(7); + return isPrivateIP(v4); + } + + return false; +} + +/** + * Validates a monitor URL is safe to fetch (not targeting internal resources). + * Returns null if safe, or an error string if blocked. + */ +export async function validateMonitorUrl(url: string): Promise { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return "Invalid URL"; + } + + // Only allow http and https + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return `Blocked scheme: ${parsed.protocol} — only http: and https: are allowed`; + } + + const hostname = parsed.hostname.toLowerCase(); + + // Block localhost by name + if (BLOCKED_HOSTNAMES.includes(hostname)) { + return "Blocked hostname: localhost is not allowed"; + } + + // Block non-public TLDs + for (const tld of BLOCKED_TLDS) { + if (hostname.endsWith(tld)) { + return `Blocked TLD: ${tld} is not allowed`; + } + } + + // Resolve DNS and check all IPs + try { + const ips: string[] = []; + try { + const v4 = await dns.resolve4(hostname); + ips.push(...v4); + } catch {} + try { + const v6 = await dns.resolve6(hostname); + ips.push(...v6); + } catch {} + + if (ips.length === 0) { + return "Could not resolve hostname"; + } + + for (const ip of ips) { + if (isPrivateIP(ip)) { + return `Blocked: ${hostname} resolves to private/reserved IP ${ip}`; + } + } + } catch { + return "DNS resolution failed"; + } + + return null; +} diff --git a/apps/web/src/views/settings.ejs b/apps/web/src/views/settings.ejs index 0da5235..62dc001 100644 --- a/apps/web/src/views/settings.ejs +++ b/apps/web/src/views/settings.ejs @@ -15,11 +15,11 @@

Account

- +
- <%= it.account.id %> - + <%= it.account.id %>
+

Your secret key was shown once at registration and cannot be displayed again. Use "Rotate Key" below to generate a new one.

@@ -88,7 +88,7 @@

<%= k.label %>

-

<%= k.id %> · created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %>

+

created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %>

@@ -111,14 +111,6 @@ } })(); - function copyKey() { - const key = document.getElementById('primary-key').textContent; - navigator.clipboard.writeText(key); - const btn = event.target; - btn.textContent = 'Copied!'; - setTimeout(() => btn.textContent = 'Copy', 1500); - } - async function saveEmail() { const email = document.getElementById('email-input').value.trim(); if (!email) return; @@ -139,8 +131,10 @@ async function confirmReset() { if (!confirm('Rotate your primary key?\n\nYour current key will stop working immediately. Make sure to copy the new one.')) return; const data = await api('/account/reset-key', { method: 'POST', body: {} }); - await fetch('/account/login', { method: 'POST', credentials: 'same-origin', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key: data.key }) }); - location.reload(); + // New key is shown once — display it in the new-key-reveal area + document.getElementById('new-key-value').textContent = data.key; + document.getElementById('new-key-reveal').classList.remove('hidden'); + // Cookie is set server-side by reset-key endpoint } function showCreateKey() {