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:
M1 2026-03-17 06:10:10 +04:00
parent 5071e340c7
commit 6bdd76b4f0
10 changed files with 250 additions and 72 deletions

View File

@ -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,18 +47,21 @@ 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,
label TEXT NOT NULL, key_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(), account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
label TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ last_used_at TIMESTAMPTZ
) )
`; `;

View File

@ -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)

View File

@ -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)" });
}
} }
} }
} }

View File

@ -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'" }),

View File

@ -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;

View File

@ -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 };
}); });

View File

@ -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),

View File

@ -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 } });

View File

@ -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;
}

View File

@ -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() {