diff --git a/next.config.ts b/next.config.ts index 0b78e0b..fe31044 100644 --- a/next.config.ts +++ b/next.config.ts @@ -20,22 +20,6 @@ const nextConfig: NextConfig = { { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, - { - // Self-hosted, fully self-contained app. Allow inline styles/scripts - // (Next injects some) but block framing and third-party origins. - key: "Content-Security-Policy", - value: [ - "default-src 'self'", - "img-src 'self' data: blob:", - "style-src 'self' 'unsafe-inline'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval'", - "connect-src 'self'", - "font-src 'self' data:", - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", - ].join("; "), - }, ], }, ]; diff --git a/prisma/migrations/20260806181000_request_limits_and_webhook_index/migration.sql b/prisma/migrations/20260806181000_request_limits_and_webhook_index/migration.sql new file mode 100644 index 0000000..942ffa4 --- /dev/null +++ b/prisma/migrations/20260806181000_request_limits_and_webhook_index/migration.sql @@ -0,0 +1,59 @@ +CREATE TABLE "RateLimitBucket" ( + "key" TEXT NOT NULL, + "count" INTEGER NOT NULL, + "windowStartedAt" TIMESTAMP(3) NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "RateLimitBucket_pkey" PRIMARY KEY ("key") +); + +CREATE INDEX "RateLimitBucket_expiresAt_idx" ON "RateLimitBucket"("expiresAt"); + +CREATE TABLE "WorkflowWebhook" ( + "token" TEXT NOT NULL, + "workflowId" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "WorkflowWebhook_pkey" PRIMARY KEY ("token") +); + +CREATE UNIQUE INDEX "WorkflowWebhook_workflowId_nodeId_key" + ON "WorkflowWebhook"("workflowId", "nodeId"); +CREATE INDEX "WorkflowWebhook_workflowId_idx" + ON "WorkflowWebhook"("workflowId"); +ALTER TABLE "WorkflowWebhook" ADD CONSTRAINT "WorkflowWebhook_workflowId_fkey" + FOREIGN KEY ("workflowId") REFERENCES "Workflow"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Backfill existing webhook nodes. Tokens are already stored in workflow.graph; +-- the materialized table only makes lookup indexed and bounded. Legacy graphs +-- may contain duplicate tokens/node IDs, so preserve one deterministic row +-- instead of making the migration fail on previously tolerated ambiguous data. +INSERT INTO "WorkflowWebhook" ("token", "workflowId", "nodeId") +SELECT DISTINCT ON (node->'config'->>'token') + node->'config'->>'token', + workflow."id", + node->>'id' +FROM "Workflow" AS workflow +CROSS JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(workflow."graph"->'nodes') = 'array' THEN workflow."graph"->'nodes' + ELSE '[]'::jsonb + END +) AS node +WHERE node->>'kind' = 'trigger.webhook' + AND COALESCE(node->>'id', '') <> '' + AND COALESCE(btrim(node->'config'->>'token'), '') <> '' +ORDER BY node->'config'->>'token', workflow."createdAt", workflow."id", node->>'id' +ON CONFLICT DO NOTHING; + +-- Integration identity resolution compares the first DNS label case-insensitively. +-- Functional partial indexes avoid loading/scanning complete active inventories. +CREATE INDEX "Device_active_normalized_name_idx" + ON "Device" ((lower(split_part(rtrim(btrim("name"), '.'), '.', 1)))) + WHERE "status" <> 'REMOVED'; +CREATE INDEX "VirtualMachine_active_normalized_name_idx" + ON "VirtualMachine" ((lower(split_part(rtrim(btrim("name"), '.'), '.', 1)))) + WHERE "status" <> 'REMOVED'; +CREATE INDEX "Container_active_normalized_name_idx" + ON "Container" ((lower(split_part(rtrim(btrim("name"), '.'), '.', 1)))) + WHERE "status" <> 'REMOVED'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e1ce550..a38e0a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -142,6 +142,18 @@ model AppSetting { updatedAt DateTime @updatedAt } +/// Shared fixed-window counters used for login and public webhook throttling. +/// Rows expire naturally and may be pruned by maintenance without affecting data. +model RateLimitBucket { + key String @id + count Int + windowStartedAt DateTime + expiresAt DateTime + updatedAt DateTime @updatedAt + + @@index([expiresAt]) +} + // ---------- integrations ---------- model IntegrationConfig { @@ -1208,11 +1220,25 @@ model Workflow { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - runs WorkflowRun[] + runs WorkflowRun[] + webhooks WorkflowWebhook[] @@index([name]) } +/// Materialized lookup for public webhook tokens. The source of truth remains +/// the workflow graph; this table is rebuilt transactionally whenever a graph changes. +model WorkflowWebhook { + token String @id + workflowId String + workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade) + nodeId String + createdAt DateTime @default(now()) + + @@unique([workflowId, nodeId]) + @@index([workflowId]) +} + /// One execution of a workflow. Step outputs are persisted with secret output /// keys redacted; secret values only exist in the one-time run response. model WorkflowRun { diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 396a30a..92039fb 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,5 +1,4 @@ import { redirect } from "next/navigation"; -import { LockKeyhole } from "lucide-react"; import { requirePageUser } from "@/lib/auth/guards"; import { isLockedDemoMode } from "@/lib/demo/mode"; import { isMobileView } from "@/lib/device"; @@ -10,6 +9,7 @@ import { PrivacyProvider } from "@/components/privacy/privacy-provider"; import { SidebarNav } from "@/components/shell/sidebar"; import { Topbar } from "@/components/shell/topbar"; import { MobileShell } from "@/components/mobile/shell/mobile-shell"; +import { DemoModeBanner } from "@/components/shell/demo-mode-banner"; export const dynamic = "force-dynamic"; @@ -70,12 +70,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
- {demoLocked && ( -
- Public demo — exploration and - mock AI are enabled; persistent changes are locked. -
- )} + {demoLocked && }
{children}
{aiConfig.enabled && } diff --git a/src/app/(dashboard)/network/access-map/access-map-data.ts b/src/app/(dashboard)/network/access-map/access-map-data.ts index 61bf8f7..77cbd29 100644 --- a/src/app/(dashboard)/network/access-map/access-map-data.ts +++ b/src/app/(dashboard)/network/access-map/access-map-data.ts @@ -1,6 +1,7 @@ import "server-only"; import { prisma } from "@/lib/db"; +import { assertDatasetBudget } from "@/lib/dataset-budget"; import { anonymizeForDisplay } from "@/lib/privacy/server"; import { deriveAccessGraph } from "@/lib/topology/access"; import { resolveObservedAssetAddresses } from "@/lib/topology/address-evidence"; @@ -22,6 +23,23 @@ import { buildTailscaleTailnets, } from "./access-map-providers"; +const ACCESS_MAP_BUDGET = { + networks: 2_000, + firewallRules: 25_000, + aliases: 10_000, + integrationTypes: 100, + portForwards: 25_000, + addresses: 50_000, + guestInterfaces: 50_000, + leases: 50_000, + neighbors: 50_000, + wirelessNetworks: 10_000, + wirelessAps: 10_000, + switchConfigs: 2_000, + switchVlansPerConfig: 4_096, + switchPortsPerConfig: 2_000, +} as const; + /** Load, assemble, and anonymize the complete access-map render model. */ export async function loadAccessMapData() { const [networkRows, rules, aliases, pveIpsets, activeIntegrations, cloudflareSnapshots, tailscaleSnapshots, edgePortForwards] = await Promise.all([ @@ -29,6 +47,7 @@ export async function loadAccessMapData() { where: { status: { not: "REMOVED" } }, orderBy: { name: "asc" }, select: { id: true, name: true, vlanId: true, cidr: true, gateway: true, externalId: true, purpose: true, source: true }, + take: ACCESS_MAP_BUDGET.networks + 1, }), prisma.firewallRule.findMany({ // Gateway-level rules only — Proxmox guest-isolation rules feed the @@ -49,19 +68,23 @@ export async function loadAccessMapData() { metadata: true, source: true, }, + take: ACCESS_MAP_BUDGET.firewallRules + 1, }), prisma.firewallAlias.findMany({ where: { status: { not: "REMOVED" }, aliasType: { notIn: ["pve-ipset", "pve-alias"] } }, select: { name: true, aliasType: true, content: true }, + take: ACCESS_MAP_BUDGET.aliases + 1, }), prisma.firewallAlias.findMany({ where: { status: { not: "REMOVED" }, aliasType: { in: ["pve-ipset", "pve-alias"] } }, select: { name: true, content: true }, + take: ACCESS_MAP_BUDGET.aliases + 1, }), prisma.integrationConfig.findMany({ where: { enabled: true }, select: { type: true }, distinct: ["type"], + take: ACCESS_MAP_BUDGET.integrationTypes + 1, }), listStoredCloudflareSnapshots(), listStoredTailscaleSnapshots(), @@ -79,9 +102,17 @@ export async function loadAccessMapData() { descriptionText: true, source: true, }, + take: ACCESS_MAP_BUDGET.portForwards + 1, }), ]); + assertDatasetBudget("Access-map networks", networkRows, ACCESS_MAP_BUDGET.networks); + assertDatasetBudget("Access-map firewall rules", rules, ACCESS_MAP_BUDGET.firewallRules); + assertDatasetBudget("Access-map firewall aliases", aliases, ACCESS_MAP_BUDGET.aliases); + assertDatasetBudget("Access-map Proxmox address sets", pveIpsets, ACCESS_MAP_BUDGET.aliases); + assertDatasetBudget("Access-map integration types", activeIntegrations, ACCESS_MAP_BUDGET.integrationTypes); + assertDatasetBudget("Access-map edge port forwards", edgePortForwards, ACCESS_MAP_BUDGET.portForwards); + const pveAddressSets = pveIpsets.map((set) => ({ name: set.name, entries: set.content })); const integrationEvidence = buildIntegrationEvidence( activeIntegrations.map(({ type }) => type), @@ -120,6 +151,7 @@ export async function loadAccessMapData() { }, }, }, + take: ACCESS_MAP_BUDGET.addresses + 1, }), prisma.networkInterface.findMany({ where: { @@ -132,17 +164,25 @@ export async function loadAccessMapData() { vm: { select: { id: true, externalId: true, name: true, metadata: true } }, container: { select: { id: true, externalId: true, name: true, metadata: true } }, }, + take: ACCESS_MAP_BUDGET.guestInterfaces + 1, }), prisma.dhcpLease.findMany({ where: { status: { not: "REMOVED" } }, select: { id: true, ipAddress: true, macAddress: true, hostname: true, isStatic: true, networkId: true }, + take: ACCESS_MAP_BUDGET.leases + 1, }), prisma.networkNeighbor.findMany({ where: { status: { not: "REMOVED" }, permanent: false }, select: { id: true, ipAddress: true, macAddress: true, hostname: true, manufacturer: true, networkId: true }, + take: ACCESS_MAP_BUDGET.neighbors + 1, }), ]); + assertDatasetBudget("Access-map IP addresses", ips, ACCESS_MAP_BUDGET.addresses); + assertDatasetBudget("Access-map guest interfaces", guestInterfaces, ACCESS_MAP_BUDGET.guestInterfaces); + assertDatasetBudget("Access-map DHCP leases", leases, ACCESS_MAP_BUDGET.leases); + assertDatasetBudget("Access-map network neighbors", neighbors, ACCESS_MAP_BUDGET.neighbors); + const resolvedGuestObservations = resolveObservedAssetAddresses( guestInterfaces.flatMap((iface) => { const ownerId = iface.vm?.id ?? iface.container?.id; @@ -194,20 +234,28 @@ export async function loadAccessMapData() { vlanId: true, networkId: true, }, + take: ACCESS_MAP_BUDGET.wirelessNetworks + 1, }), prisma.wirelessAp.findMany({ where: { status: { not: "REMOVED" } }, orderBy: { name: "asc" }, select: { id: true, name: true, model: true }, + take: ACCESS_MAP_BUDGET.wirelessAps + 1, }), ]); + assertDatasetBudget("Access-map wireless networks", wifiSsids, ACCESS_MAP_BUDGET.wirelessNetworks); + assertDatasetBudget("Access-map wireless access points", wifiAps, ACCESS_MAP_BUDGET.wirelessAps); const switchConfigs = await prisma.switchConfig.findMany({ include: { device: { select: { id: true, name: true } }, - vlans: { select: { vlanId: true, svIpAddress: true, networkId: true } }, + vlans: { + take: ACCESS_MAP_BUDGET.switchVlansPerConfig + 1, + select: { vlanId: true, svIpAddress: true, networkId: true }, + }, ports: { orderBy: { sortOrder: "asc" }, + take: ACCESS_MAP_BUDGET.switchPortsPerConfig + 1, select: { shortName: true, description: true, @@ -223,7 +271,13 @@ export async function loadAccessMapData() { }, }, }, + take: ACCESS_MAP_BUDGET.switchConfigs + 1, }); + assertDatasetBudget("Access-map switch configurations", switchConfigs, ACCESS_MAP_BUDGET.switchConfigs); + for (const config of switchConfigs) { + assertDatasetBudget("VLANs in one access-map switch", config.vlans, ACCESS_MAP_BUDGET.switchVlansPerConfig); + assertDatasetBudget("Ports in one access-map switch", config.ports, ACCESS_MAP_BUDGET.switchPortsPerConfig); + } const edgeIngressRules = edgePortForwards.map((forward) => ({ id: `edge-nat:${forward.id}`, @@ -395,7 +449,9 @@ export async function loadAccessMapData() { enabled: true, metadata: true, }, + take: ACCESS_MAP_BUDGET.firewallRules + 1, }); + assertDatasetBudget("Access-map Proxmox firewall rules", pveRules, ACCESS_MAP_BUDGET.firewallRules); const { pve, homeNetworkId, groupRuleCount } = buildAccessMapPveData( guestInterfaces, diff --git a/src/app/(dashboard)/network/access-map/page.tsx b/src/app/(dashboard)/network/access-map/page.tsx index 35370b6..ef06e3e 100644 --- a/src/app/(dashboard)/network/access-map/page.tsx +++ b/src/app/(dashboard)/network/access-map/page.tsx @@ -2,12 +2,16 @@ import Link from "next/link"; import { Waypoints } from "lucide-react"; import { requirePageUser } from "@/lib/auth/guards"; import { isMobileView } from "@/lib/device"; +import { DatasetBudgetExceededError } from "@/lib/dataset-budget"; import { PageHeader } from "@/components/shared/page-header"; import { EmptyState } from "@/components/shared/empty-state"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { NetworkAccessMap } from "@/components/topology/network-access-map"; import { MobileAccessMap } from "@/components/mobile/pages/maps/mobile-access-map"; +import { MobilePageHeader } from "@/components/mobile/ui/mobile-page-header"; +import { MobilePage } from "@/components/mobile/ui/mobile-page"; +import { MobileEmpty } from "@/components/mobile/ui/mobile-list"; import { loadAccessMapData } from "./access-map-data"; export const dynamic = "force-dynamic"; @@ -16,6 +20,42 @@ export const metadata = { title: "Access map" }; export default async function AccessMapPage() { const { user } = await requirePageUser(); + const mobile = await isMobileView(); + + let data: Awaited>; + try { + data = await loadAccessMapData(); + } catch (err) { + if (!(err instanceof DatasetBudgetExceededError)) throw err; + const description = `${err.dataset} exceeds the safe processing budget. Archive stale inventory records, then reload this view.`; + if (mobile) { + return ( + <> + + + } + title="Access map paused" + description={description} + /> + + + ); + } + return ( +
+ + +
+ ); + } const { display, @@ -23,9 +63,9 @@ export default async function AccessMapPage() { homeNetworkId, hasSwitchConfigs, empty, - } = await loadAccessMapData(); + } = data; - if (await isMobileView()) { + if (mobile) { return ( ({ input, unavailableReason: null as string | null })) + .catch((err: unknown) => { + if (!(err instanceof DatasetBudgetExceededError)) throw err; + console.warn("[dashboard] topology processing budget exceeded:", err.message); + return { + input: null, + unavailableReason: `${err.message} The inventory counts remain available, but the full map is paused to protect app performance.`, + }; + }); + + const [ + footprintResult, + hosts, + vms, + containers, + networks, + services, + docs, + integrationCount, + rawIntegrations, + rawPools, + ] = await Promise.all([ + footprintPromise, prisma.device.count({ where: notRemoved }), prisma.virtualMachine.count({ where: notRemoved }), prisma.container.count({ where: notRemoved }), prisma.network.count({ where: notRemoved }), prisma.service.count({ where: notRemoved }), prisma.docPage.count(), + prisma.integrationConfig.count(), prisma.integrationConfig.findMany({ select: { id: true, @@ -86,15 +121,24 @@ export default async function DashboardHomePage() { lastSyncError: true, }, orderBy: { createdAt: "asc" }, + take: DASHBOARD_INTEGRATION_LIMIT, }), - prisma.storagePool.findMany({ - where: { ...notRemoved, totalBytes: { not: null } }, - select: { id: true, name: true, type: true, totalBytes: true, usedBytes: true }, - }), + prisma.$queryRaw(Prisma.sql` + SELECT "id", "name", "type", "totalBytes", "usedBytes" + FROM "StoragePool" + WHERE "status" <> 'REMOVED' AND "totalBytes" IS NOT NULL + ORDER BY CASE + WHEN "totalBytes" > 0 THEN COALESCE("usedBytes", 0)::numeric / "totalBytes" + ELSE 0 + END DESC, "name" ASC + LIMIT 6 + `), ]); - const footprint = await anonymizeForDisplay(deriveFootprint(footprintInput)); - const hasFootprint = footprintInput.machines.length > 0; + const footprint = footprintResult.input + ? await anonymizeForDisplay(deriveFootprint(footprintResult.input)) + : null; + const hasFootprint = Boolean(footprintResult.input && footprintResult.input.machines.length > 0); const integrations = await anonymizeForDisplay(rawIntegrations); const pools = await anonymizeForDisplay(rawPools); @@ -122,7 +166,9 @@ export default async function DashboardHomePage() { tiles={tiles} footprint={footprint} hasFootprint={hasFootprint} + footprintUnavailableReason={footprintResult.unavailableReason} integrations={integrations} + integrationCount={integrationCount} integrationIcons={INTEGRATION_ICONS} pools={topPools} isAdmin={isAdmin} @@ -152,7 +198,13 @@ export default async function DashboardHomePage() { {/* Footprint map */} - {hasFootprint ? ( + {footprintResult.unavailableReason ? ( + + ) : hasFootprint && footprint ? ( ) : ( -

- Integrations -

+
+

+ Integrations +

+ {integrationCount > integrations.length && ( +

+ Showing {integrations.length} of {integrationCount} +

+ )} +
{integrations.length === 0 ? ( { + // Parse multipart only after the raw stream has passed the same hard byte + // ceiling as direct uploads. Calling req.formData() first would allow a + // chunked request to grow without a trustworthy Content-Length header. + const boundedRequest = new Request(req.url, { + method: "POST", + headers: { "Content-Type": contentType }, + body: new Uint8Array(requestBody), + }); + let form: FormData; + try { + form = await boundedRequest.formData(); + } catch { + throw new ApiError(400, "invalid_request", "The multipart backup upload is malformed."); + } + + const file = form.get("file"); + if (!(file instanceof File)) { + throw new ApiError(400, "invalid_request", "Expected a backup file in the 'file' form field."); + } + assertBackupFileSize(file.size); + + const confirmField = form.get("confirm"); + const passwordField = form.get("password"); + return { + buffer: Buffer.from(await file.arrayBuffer()), + preview: options.preview || form.get("mode") === "preview", + confirm: options.confirm || confirmField === "true" || confirmField === "1", + password: typeof passwordField === "string" && passwordField.length > 0 ? passwordField : undefined, + }; +} + +async function readBackupUpload(req: NextRequest): Promise { + const options = initialUploadOptions(req); + const requestBody = await readBackupRequestBody(req); + const contentType = req.headers.get("content-type") ?? ""; + if (contentType.includes("multipart/form-data")) { + return parseMultipartUpload(req, requestBody, contentType, options); + } + return { buffer: requestBody, ...options }; +} + +async function decodeRestoreInput(buffer: Buffer, password?: string) { + try { + const decoded = await decodeBackupFileAsync(buffer, password); + return { decoded, archive: prepareBackupForRestore(decoded) }; + } catch (err) { + if (err instanceof BackupLimitError) { + throw new ApiError(413, "backup_too_large", err.message); + } + throw new ApiError(400, "invalid_backup", err instanceof Error ? err.message : "Invalid backup file."); + } +} /** * POST /api/admin/backup/import — restore this PolySIEM instance from a backup @@ -18,52 +97,18 @@ import { decodeBackupFile, prepareBackupForRestore, previewRestore, restoreArchi */ export const POST = handleApi(async (req: NextRequest) => { const { user } = await requireAdmin(); - - const previewParam = new URL(req.url).searchParams.get("preview"); - let preview = previewParam === "1" || previewParam === "true"; - let confirm = req.headers.get("x-confirm-restore") === "true"; - let password: string | undefined; - - let buffer: Buffer; - const contentType = req.headers.get("content-type") ?? ""; - if (contentType.includes("multipart/form-data")) { - const form = await req.formData(); - const file = form.get("file"); - if (!(file instanceof File)) { - throw new ApiError(400, "invalid_request", "Expected a backup file in the 'file' form field."); - } - buffer = Buffer.from(await file.arrayBuffer()); - if (form.get("mode") === "preview") preview = true; - const confirmField = form.get("confirm"); - if (confirmField === "true" || confirmField === "1") confirm = true; - const passwordField = form.get("password"); - if (typeof passwordField === "string" && passwordField.length > 0) password = passwordField; - } else { - buffer = Buffer.from(await req.arrayBuffer()); - } - - if (buffer.byteLength === 0) { + const upload = await readBackupUpload(req); + if (upload.buffer.byteLength === 0) { throw new ApiError(400, "invalid_request", "No backup file was provided."); } - // decodeArchive throws plain, actionable Errors (bad gzip, unsupported - // version, unknown model). Surface those to the client as a 400 rather than - // letting handleApi mask them behind a generic 500 — the operator needs to - // know exactly why their file was rejected. - let decoded; - let archive; - try { - decoded = decodeBackupFile(buffer, password); - archive = prepareBackupForRestore(decoded); - } catch (err) { - throw new ApiError(400, "invalid_backup", err instanceof Error ? err.message : "Invalid backup file."); - } - - if (preview) { + // Decode errors are translated here so operators get an actionable 4xx + // instead of a generic server error for corrupt or incompatible archives. + const { decoded, archive } = await decodeRestoreInput(upload.buffer, upload.password); + if (upload.preview) { return jsonOk(previewRestore(archive, decoded.passwordProtected)); } - - if (!confirm) { + if (!upload.confirm) { throw new ApiError( 400, "confirm_required", diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 5a4134c..61d570f 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -6,11 +6,33 @@ import { createSession, requestMeta, SESSION_COOKIE, sessionCookieOptions } from import { loginSchema } from "@/lib/validators/auth"; import { audit } from "@/lib/audit"; import { THEME_COOKIE, MODE_COOKIE } from "@/lib/theme"; +import { clearRateLimit, consumeRateLimit, pruneExpiredRateLimits, trustedClientIp } from "@/lib/rate-limit"; + +const ACCOUNT_LIMIT = { limit: 10, windowMs: 15 * 60_000 } as const; +const IP_LIMIT = { limit: 60, windowMs: 15 * 60_000 } as const; export const POST = handleApi(async (req: NextRequest) => { const input = loginSchema.parse(await req.json()); + const clientIp = trustedClientIp(req); + await pruneExpiredRateLimits(); const user = await prisma.user.findUnique({ where: { username: input.username } }); + // Unknown usernames share one bucket so arbitrary attacker-controlled names + // cannot create unbounded limiter rows. Valid accounts remain independent, + // so abuse against unknown names cannot globally lock out legitimate users. + const accountIdentity = user?.id ?? "unknown-account"; + const identityLimits = await Promise.all([ + consumeRateLimit("login-account", accountIdentity, ACCOUNT_LIMIT), + ...(clientIp ? [consumeRateLimit("login-ip", clientIp, IP_LIMIT)] : []), + ]); + const blocked = identityLimits.find((limit) => !limit.allowed); + if (blocked) { + return NextResponse.json( + { error: { code: "rate_limited", message: "Too many login attempts. Try again shortly." } }, + { status: 429, headers: { "Retry-After": String(blocked.retryAfterSeconds) } }, + ); + } + // Always verify against a hash to keep timing consistent. const ok = await verifyPassword( input.password, @@ -20,6 +42,11 @@ export const POST = handleApi(async (req: NextRequest) => { return jsonError(401, "invalid_credentials", "Invalid username or password"); } + await Promise.all([ + clearRateLimit("login-account", accountIdentity), + ...(clientIp ? [clearRateLimit("login-ip", clientIp)] : []), + ]); + const { token, expiresAt } = await createSession(user.id, await requestMeta()); await audit({ type: "user", userId: user.id }, "auth.login"); diff --git a/src/app/api/security/route.ts b/src/app/api/security/route.ts index b42d3a8..6a9b02d 100644 --- a/src/app/api/security/route.ts +++ b/src/app/api/security/route.ts @@ -1,12 +1,13 @@ import type { NextRequest } from "next/server"; import { z } from "zod"; -import { handleApi, jsonOk } from "@/lib/api"; +import { ApiError, handleApi, jsonOk } from "@/lib/api"; import { requireAdmin, requireUser } from "@/lib/auth/guards"; import { SETTING_KEYS, getSetting, setSetting } from "@/lib/settings"; import { runSecurityChecks } from "@/lib/security/checks"; import { collectSecuritySnapshot } from "@/lib/security/collect"; import { computeScore } from "@/lib/security/score"; import type { SecurityReport } from "@/lib/security/types"; +import { DatasetBudgetExceededError } from "@/lib/dataset-budget"; export const dynamic = "force-dynamic"; @@ -17,7 +18,19 @@ async function getDismissedIds(): Promise { } async function buildReport(): Promise { - const snapshot = await collectSecuritySnapshot(); + let snapshot: Awaited>; + try { + snapshot = await collectSecuritySnapshot(); + } catch (err) { + if (err instanceof DatasetBudgetExceededError) { + throw new ApiError( + 503, + "dataset_too_large", + `${err.message} Narrow or archive stale inventory before running the security advisor again.`, + ); + } + throw err; + } const all = runSecurityChecks(snapshot); const dismissedSet = new Set(await getDismissedIds()); const findings = all.filter((f) => !dismissedSet.has(f.id)); diff --git a/src/app/api/workflows/hooks/[token]/route.ts b/src/app/api/workflows/hooks/[token]/route.ts index 9f39e9a..4526f95 100644 --- a/src/app/api/workflows/hooks/[token]/route.ts +++ b/src/app/api/workflows/hooks/[token]/route.ts @@ -1,11 +1,10 @@ import type { NextRequest } from "next/server"; import { ApiError, handleApi, jsonOk } from "@/lib/api"; -import { prisma } from "@/lib/db"; import type { AuditActor } from "@/lib/audit"; +import { consumeRateLimit } from "@/lib/rate-limit"; import { validateRunInput, validateTriggerParams } from "@/lib/workflows/engine"; import { executeWorkflow } from "@/lib/workflows/executor"; -import { WEBHOOK_TRIGGER_KIND } from "@/lib/workflows/actions/trigger-webhook"; -import type { WorkflowGraph, WorkflowNodeSpec } from "@/lib/workflows/types"; +import { findIndexedWebhook } from "@/lib/workflows/webhook-index"; type Ctx = { params: Promise<{ token: string }> }; @@ -13,37 +12,7 @@ export const dynamic = "force-dynamic"; /** Webhook runs are started by the outside world, not a session user. */ const SYSTEM_ACTOR: AuditActor = { type: "system" }; - -// --------------------------------------------------------------------------- -// In-memory rate limit: max runs per token per sliding minute. Only known -// tokens are tracked, so the map is bounded by the number of webhook -// workflows; a process restart simply resets the window. -// --------------------------------------------------------------------------- - -const RATE_LIMIT_PER_MINUTE = 30; -const RATE_WINDOW_MS = 60_000; -const hitLog = new Map(); - -function rateLimited(token: string): boolean { - const now = Date.now(); - const hits = (hitLog.get(token) ?? []).filter((t) => now - t < RATE_WINDOW_MS); - if (hits.length >= RATE_LIMIT_PER_MINUTE) { - hitLog.set(token, hits); - return true; - } - hits.push(now); - hitLog.set(token, hits); - return false; -} - -/** The webhook-trigger node of a graph carrying exactly this token. */ -function webhookNodeWithToken(graph: WorkflowGraph, token: string): WorkflowNodeSpec | null { - return ( - graph.nodes?.find?.( - (n) => n.kind === WEBHOOK_TRIGGER_KIND && typeof n.config?.token === "string" && n.config.token === token, - ) ?? null - ); -} +const WEBHOOK_LIMIT = { limit: 30, windowMs: 60_000 } as const; /** * POST /api/workflows/hooks/[token] — PUBLIC entry point for webhook-triggered @@ -59,16 +28,16 @@ export const POST = handleApi(async (req: NextRequest, ctx: Ctx) => { } // Disabled workflows are indistinguishable from unknown tokens on purpose. - const workflows = await prisma.workflow.findMany({ where: { enabled: true } }); - const match = workflows - .map((w) => ({ workflow: w, node: webhookNodeWithToken(w.graph as unknown as WorkflowGraph, token) })) - .find((m) => m.node !== null); - if (!match?.node) { - throw new ApiError(404, "unknown_hook", "unknown hook"); - } - - if (rateLimited(token)) { - throw new ApiError(429, "rate_limited", `This hook is limited to ${RATE_LIMIT_PER_MINUTE} runs per minute — retry shortly`); + const match = await findIndexedWebhook(token); + if (!match) throw new ApiError(404, "unknown_hook", "unknown hook"); + + const limit = await consumeRateLimit("workflow-webhook", token, WEBHOOK_LIMIT); + if (!limit.allowed) { + throw new ApiError( + 429, + "rate_limited", + `This hook is limited to ${WEBHOOK_LIMIT.limit} runs per minute — retry in ${limit.retryAfterSeconds}s`, + ); } const body: unknown = await req.json().catch(() => null); @@ -85,9 +54,7 @@ export const POST = handleApi(async (req: NextRequest, ctx: Ctx) => { throw new ApiError(422, "invalid_input", `Invalid webhook payload: ${errors.join("; ")}`); } - // A graph may hold several webhook triggers; activate the one whose token - // was called, so only its branch runs. - const result = await executeWorkflow(SYSTEM_ACTOR, match.workflow.id, values, { + const result = await executeWorkflow(SYSTEM_ACTOR, match.workflowId, values, { trigger: "webhook", triggerNodeId: match.node.id, }); diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 0af7950..a55249d 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -20,10 +20,13 @@ export default function GlobalError({ error, reset }: { error: Error & { digest? fontFamily: "system-ui, sans-serif", textAlign: "center", padding: "1.5rem", + colorScheme: "light dark", + background: "Canvas", + color: "CanvasText", }} >

Something went wrong

-

+

A critical error occurred. Try reloading the page; if it persists, check the server logs.