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:
parent
5071e340c7
commit
6bdd76b4f0
|
|
@ -4,25 +4,32 @@ const sql = postgres(process.env.DATABASE_URL ?? "postgres://pingql:pingql@local
|
||||||
|
|
||||||
export default sql;
|
export default sql;
|
||||||
|
|
||||||
// Run migrations on startup
|
// Run migrations on startup — full rebuild (no real users)
|
||||||
export async function migrate() {
|
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`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS accounts (
|
CREATE TABLE accounts (
|
||||||
id TEXT PRIMARY KEY, -- random 16-digit key
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
email_hash TEXT, -- optional, for recovery only
|
key_lookup TEXT NOT NULL UNIQUE,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
email_hash TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS monitors (
|
CREATE TABLE monitors (
|
||||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
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,
|
name TEXT NOT NULL,
|
||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
method TEXT NOT NULL DEFAULT 'GET',
|
method TEXT NOT NULL DEFAULT 'GET',
|
||||||
request_headers JSONB, -- { "key": "value", ... }
|
request_headers JSONB,
|
||||||
request_body TEXT, -- raw body for POST/PUT/PATCH
|
request_body TEXT,
|
||||||
timeout_ms INTEGER NOT NULL DEFAULT 30000,
|
timeout_ms INTEGER NOT NULL DEFAULT 30000,
|
||||||
interval_s INTEGER NOT NULL DEFAULT 60,
|
interval_s INTEGER NOT NULL DEFAULT 60,
|
||||||
query JSONB,
|
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`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS pings (
|
CREATE TABLE pings (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||||
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
|
@ -46,16 +47,19 @@ export async function migrate() {
|
||||||
latency_ms INTEGER,
|
latency_ms INTEGER,
|
||||||
up BOOLEAN NOT NULL,
|
up BOOLEAN NOT NULL,
|
||||||
error TEXT,
|
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_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`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE api_keys (
|
||||||
id TEXT PRIMARY KEY,
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
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,
|
label TEXT NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
last_used_at TIMESTAMPTZ
|
last_used_at TIMESTAMPTZ
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ import { migrate } from "./db";
|
||||||
await migrate();
|
await migrate();
|
||||||
|
|
||||||
const app = new Elysia()
|
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`); })
|
.get("/", ({ set }) => { set.headers["content-type"] = "text/html"; return Bun.file(`${import.meta.dir}/dashboard/landing.html`); })
|
||||||
.use(dashboard)
|
.use(dashboard)
|
||||||
.use(account)
|
.use(account)
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,7 @@ function evalOp(op: string, fieldVal: unknown, opVal: unknown): boolean {
|
||||||
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal);
|
return typeof fieldVal === "string" && typeof opVal === "string" && fieldVal.endsWith(opVal);
|
||||||
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;
|
||||||
try {
|
try {
|
||||||
return new RegExp(opVal).test(fieldVal);
|
return new RegExp(opVal).test(fieldVal);
|
||||||
} catch {
|
} 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.*` });
|
errors.push({ path: keyPath, message: `Unknown field: ${key}. Use status, body, or headers.*` });
|
||||||
}
|
}
|
||||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
for (const op of Object.keys(value as Record<string, unknown>)) {
|
const ops = value as Record<string, unknown>;
|
||||||
|
for (const op of Object.keys(ops)) {
|
||||||
if (!op.startsWith("$")) continue;
|
if (!op.startsWith("$")) continue;
|
||||||
if (!VALID_OPS.has(op)) {
|
if (!VALID_OPS.has(op)) {
|
||||||
errors.push({ path: `${keyPath}.${op}`, message: `Unknown operator: ${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)" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,40 @@ import { randomBytes, createHash } from "crypto";
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
|
|
||||||
function generateKey(): string {
|
function generateKey(): string {
|
||||||
const bytes = randomBytes(8);
|
return randomBytes(32).toString("hex");
|
||||||
const hex = bytes.toString("hex").toUpperCase();
|
}
|
||||||
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
|
|
||||||
|
function sha256(data: string): string {
|
||||||
|
return createHash("sha256").update(data).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashEmail(email: string): string {
|
function hashEmail(email: string): string {
|
||||||
return createHash("sha256").update(email.toLowerCase().trim()).digest("hex");
|
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}`;
|
* Resolves a raw key to an account.
|
||||||
if (account) return { accountId: account.id, keyId: null };
|
* 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) {
|
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 };
|
return { accountId: apiKey.account_id, keyId: apiKey.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,8 +49,9 @@ export { resolveKey };
|
||||||
export function requireAuth(app: Elysia) {
|
export function requireAuth(app: Elysia) {
|
||||||
return app
|
return app
|
||||||
.derive(async ({ headers, cookie, set }) => {
|
.derive(async ({ headers, cookie, set }) => {
|
||||||
// 1. Bearer token (API clients)
|
// 1. Bearer token (API clients) — case-insensitive
|
||||||
const bearer = headers["authorization"]?.replace("Bearer ", "").trim();
|
const authHeader = headers["authorization"] ?? "";
|
||||||
|
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
|
||||||
// 2. Cookie (dashboard / SSR)
|
// 2. Cookie (dashboard / SSR)
|
||||||
const cookieKey = cookie?.pingql_key?.value;
|
const cookieKey = cookie?.pingql_key?.value;
|
||||||
|
|
||||||
|
|
@ -58,10 +77,10 @@ export function requireAuth(app: Elysia) {
|
||||||
|
|
||||||
const COOKIE_OPTS = {
|
const COOKIE_OPTS = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: true,
|
secure: process.env.NODE_ENV !== "development",
|
||||||
sameSite: "lax" as const,
|
sameSite: "lax" as const,
|
||||||
path: "/",
|
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
|
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -94,12 +113,19 @@ export const account = new Elysia({ prefix: "/account" })
|
||||||
}, { detail: { hide: true } })
|
}, { detail: { hide: true } })
|
||||||
|
|
||||||
// ── Register ────────────────────────────────────────────────────────
|
// ── Register ────────────────────────────────────────────────────────
|
||||||
.post("/register", async ({ body }) => {
|
.post("/register", async ({ body, cookie }) => {
|
||||||
const key = generateKey();
|
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;
|
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 {
|
return {
|
||||||
key,
|
key: rawKey,
|
||||||
...(body.email ? { email_registered: true } : { email_registered: false }),
|
...(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
|
// Reset primary key — generates a new one, old one immediately invalid
|
||||||
.post("/reset-key", async ({ accountId }) => {
|
.post("/reset-key", async ({ accountId, cookie }) => {
|
||||||
const newKey = generateKey();
|
const rawKey = generateKey();
|
||||||
await sql`UPDATE accounts SET id = ${newKey} WHERE id = ${accountId}`;
|
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 {
|
return {
|
||||||
key: newKey,
|
key: rawKey,
|
||||||
message: "Primary key rotated. Your old key is now invalid.",
|
message: "Primary key rotated. Your old key is now invalid.",
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create a sub-key (for different apps or shared access)
|
// Create a sub-key (for different apps or shared access)
|
||||||
.post("/keys", async ({ accountId, body }) => {
|
.post("/keys", async ({ accountId, body }) => {
|
||||||
const key = generateKey();
|
const rawKey = generateKey();
|
||||||
await sql`INSERT INTO api_keys (id, account_id, label) VALUES (${key}, ${accountId}, ${body.label})`;
|
const keyLookup = sha256(rawKey);
|
||||||
return { key, label: body.label };
|
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({
|
body: t.Object({
|
||||||
label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }),
|
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> {
|
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;
|
if (!key) return null;
|
||||||
const resolved = await resolveKey(key);
|
const resolved = await resolveKey(key);
|
||||||
return resolved?.accountId ?? null;
|
return resolved?.accountId ?? null;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,17 @@
|
||||||
import { Elysia } from "elysia";
|
import { Elysia } from "elysia";
|
||||||
import sql from "../db";
|
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 } })
|
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 (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
|
AND (last.checked_at IS NULL
|
||||||
OR last.checked_at < now() - (m.interval_s || ' seconds')::interval)
|
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 { Elysia, t } from "elysia";
|
||||||
import { requireAuth } from "./auth";
|
import { requireAuth } from "./auth";
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
|
import { validateMonitorUrl } from "../utils/ssrf";
|
||||||
|
|
||||||
const MonitorBody = t.Object({
|
const MonitorBody = t.Object({
|
||||||
name: t.String({ description: "Human-readable name" }),
|
name: t.String({ description: "Human-readable name" }),
|
||||||
|
|
@ -22,7 +23,16 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
}, { detail: { summary: "List monitors", tags: ["monitors"] } })
|
}, { detail: { summary: "List monitors", tags: ["monitors"] } })
|
||||||
|
|
||||||
// Create monitor
|
// 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`
|
const [monitor] = await sql`
|
||||||
INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, query)
|
INSERT INTO monitors (account_id, name, url, method, request_headers, request_body, timeout_ms, interval_s, query)
|
||||||
VALUES (
|
VALUES (
|
||||||
|
|
@ -55,6 +65,12 @@ export const monitors = new Elysia({ prefix: "/monitors" })
|
||||||
|
|
||||||
// Update monitor
|
// Update monitor
|
||||||
.patch("/:id", async ({ accountId, params, body, error }) => {
|
.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`
|
const [monitor] = await sql`
|
||||||
UPDATE monitors SET
|
UPDATE monitors SET
|
||||||
name = COALESCE(${body.name ?? null}, name),
|
name = COALESCE(${body.name ?? null}, name),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import sql from "../db";
|
import sql from "../db";
|
||||||
|
import { resolveKey } from "./auth";
|
||||||
|
|
||||||
// ── SSE bus ───────────────────────────────────────────────────────────────────
|
// ── SSE bus ───────────────────────────────────────────────────────────────────
|
||||||
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
type SSEController = ReadableStreamDefaultController<Uint8Array>;
|
||||||
|
|
@ -84,25 +85,29 @@ export const ingest = new Elysia()
|
||||||
detail: { hide: true },
|
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 }) => {
|
.get("/monitors/:id/stream", async ({ params, headers, cookie, error }) => {
|
||||||
const key = headers["authorization"]?.replace("Bearer ", "").trim()
|
// Case-insensitive bearer parsing
|
||||||
?? cookie?.pingql_key?.value;
|
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" });
|
if (!key) return error(401, { error: "Unauthorized" });
|
||||||
|
|
||||||
// Resolve account from primary key or sub-key
|
const resolved = await resolveKey(key);
|
||||||
const [acc] = await sql`SELECT id FROM accounts WHERE id = ${key}`;
|
if (!resolved) return error(401, { error: "Unauthorized" });
|
||||||
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" });
|
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
const [monitor] = await sql`
|
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" });
|
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);
|
return makeSSEStream(params.id);
|
||||||
}, { detail: { hide: true } });
|
}, { detail: { hide: true } });
|
||||||
|
|
|
||||||
|
|
@ -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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -15,11 +15,11 @@
|
||||||
<h2 class="text-sm font-semibold text-gray-300 mb-4">Account</h2>
|
<h2 class="text-sm font-semibold text-gray-300 mb-4">Account</h2>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-gray-500 mb-1">Primary Key</label>
|
<label class="block text-xs text-gray-500 mb-1">Account ID</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<code id="primary-key" class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-blue-400 text-sm tracking-widest"><%= it.account.id %></code>
|
<code id="account-id" class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-gray-400 text-sm"><%= it.account.id %></code>
|
||||||
<button onclick="copyKey()" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white transition-colors text-xs">Copy</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 mt-1.5">Your secret key was shown once at registration and cannot be displayed again. Use "Rotate Key" below to generate a new one.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-gray-500 mb-1">Member since</label>
|
<label class="block text-xs text-gray-500 mb-1">Member since</label>
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
|
<div class="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg border border-gray-700/50">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-200"><%= k.label %></p>
|
<p class="text-sm text-gray-200"><%= k.label %></p>
|
||||||
<p class="text-xs text-gray-600 mt-0.5 font-mono"><%= k.id %> · created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %></p>
|
<p class="text-xs text-gray-600 mt-0.5">created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %></p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="deleteKey('<%= k.id %>')" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
|
<button onclick="deleteKey('<%= k.id %>')" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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() {
|
async function saveEmail() {
|
||||||
const email = document.getElementById('email-input').value.trim();
|
const email = document.getElementById('email-input').value.trim();
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
|
|
@ -139,8 +131,10 @@
|
||||||
async function confirmReset() {
|
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;
|
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: {} });
|
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 }) });
|
// New key is shown once — display it in the new-key-reveal area
|
||||||
location.reload();
|
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() {
|
function showCreateKey() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue