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.