add better 403 screens and start with bun dev mode. (going to rewrite to fit the bun standard in a bit).
This commit is contained in:
@@ -278,6 +278,7 @@ function parseMaxHttpBodyBytes(): number {
|
||||
}
|
||||
|
||||
const MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes();
|
||||
const IS_DEVELOPMENT = String(process.env.NODE_ENV || "").trim().toLowerCase() === "development";
|
||||
|
||||
const pluginLoadedByClient = new Map<string, Set<string>>();
|
||||
const pendingPluginEvents = new Map<string, Array<{ event: string; payload: any }>>();
|
||||
@@ -710,6 +711,13 @@ async function startServer() {
|
||||
port: PORT,
|
||||
hostname: HOST,
|
||||
...(tls ? { tls: tls.tlsOptions } : {}),
|
||||
development: IS_DEVELOPMENT
|
||||
? {
|
||||
hmr: true,
|
||||
console: true,
|
||||
chromeDevToolsAutomaticWorkspaceFolders: true,
|
||||
}
|
||||
: false,
|
||||
idleTimeout: 255,
|
||||
maxRequestBodySize: MAX_HTTP_BODY_BYTES,
|
||||
fetch: createHttpFetchHandler({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { getConfig } from "../../config";
|
||||
import { consumeLoginPageRateLimit } from "../../rateLimit";
|
||||
import { requirePermission, type Permission } from "../../rbac";
|
||||
import { getPermissionDescription, requirePermission, type Permission } from "../../rbac";
|
||||
import { canUserAccessClient, canUserAccessFeature, getUserById, type FeatureName, type UserRole } from "../../users";
|
||||
import { renderAccessDeniedPage, type AccessDeniedKind } from "../ui/access-denied-view";
|
||||
import { htmlResponse } from "../ui/html";
|
||||
|
||||
type PageRouteDeps = {
|
||||
PUBLIC_ROOT: string;
|
||||
@@ -120,23 +122,60 @@ const PATH_CLIENT_PAGES: ClientPageDef[] = [
|
||||
{ pattern: /^\/(.+)\/keylogger$/, file: "keylogger.html", clientIdGroup: 1, feature: "keylogger" },
|
||||
];
|
||||
|
||||
function checkAccess(role: UserRole, access: AccessLevel): Response | null {
|
||||
function accessDeniedResponse(
|
||||
deps: PageRouteDeps,
|
||||
options: {
|
||||
kind: AccessDeniedKind;
|
||||
title?: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
detailLabel?: string;
|
||||
},
|
||||
): Response {
|
||||
return htmlResponse(renderAccessDeniedPage(options), {
|
||||
status: 403,
|
||||
headers: deps.secureHeaders("text/html; charset=utf-8"),
|
||||
});
|
||||
}
|
||||
|
||||
function permissionDeniedResponse(deps: PageRouteDeps, permission: Permission): Response {
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "permission",
|
||||
message: `This page requires permission to ${getPermissionDescription(permission).toLowerCase()}.`,
|
||||
detail: permission,
|
||||
detailLabel: "Missing permission",
|
||||
});
|
||||
}
|
||||
|
||||
function checkAccess(deps: PageRouteDeps, role: UserRole, access: AccessLevel): Response | null {
|
||||
switch (access) {
|
||||
case "any":
|
||||
return null;
|
||||
case "no-viewer":
|
||||
if (role === "viewer") {
|
||||
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "role",
|
||||
message: "Viewer accounts can inspect data, but cannot open interactive control features.",
|
||||
detail: "Operator or administrator role",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
case "admin":
|
||||
if (role !== "admin") {
|
||||
return new Response("Forbidden: Admin access required", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "role",
|
||||
message: "This area is reserved for administrators.",
|
||||
detail: "Administrator role",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
case "admin-or-operator":
|
||||
if (role !== "admin" && role !== "operator") {
|
||||
return new Response("Forbidden: Admin or operator access required", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "role",
|
||||
message: "This interactive area is available to operators and administrators.",
|
||||
detail: "Operator or administrator role",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -195,7 +234,12 @@ export async function handlePageRoutes(
|
||||
if (!user) return serveLoginOrUnauthorized(req, deps);
|
||||
|
||||
if (page.path === "/screenshots" && !getConfig().thumbnails.wallEnabled) {
|
||||
return new Response("Screenshot wall is disabled by the administrator", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "unavailable",
|
||||
message: "The screenshot wall has been disabled in server settings.",
|
||||
detail: "Screenshot wall disabled",
|
||||
detailLabel: "Current status",
|
||||
});
|
||||
}
|
||||
|
||||
if (page.checkPasswordChange) {
|
||||
@@ -203,14 +247,16 @@ export async function handlePageRoutes(
|
||||
if (maybeChange) return maybeChange;
|
||||
}
|
||||
|
||||
const accessDenied = checkAccess(user.role, page.access);
|
||||
const accessDenied = checkAccess(deps, user.role, page.access);
|
||||
if (accessDenied) return accessDenied;
|
||||
|
||||
if (page.permission) {
|
||||
try {
|
||||
requirePermission(user, page.permission);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
if (error instanceof Response && error.status === 403) {
|
||||
return permissionDeniedResponse(deps, page.permission);
|
||||
}
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
@@ -232,18 +278,34 @@ export async function handlePageRoutes(
|
||||
try {
|
||||
requirePermission(user, page.permission);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
if (error instanceof Response && error.status === 403) {
|
||||
return permissionDeniedResponse(deps, page.permission);
|
||||
}
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
|
||||
return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "feature",
|
||||
title: "This feature isn't enabled for you",
|
||||
message: "Your account's feature access does not include this client tool.",
|
||||
detail: page.feature,
|
||||
detailLabel: "Restricted feature",
|
||||
});
|
||||
}
|
||||
|
||||
const clientId = (url.searchParams.get("clientId") || "").trim();
|
||||
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "client",
|
||||
title: "Client access restricted",
|
||||
message: clientId
|
||||
? "This client is outside the scope assigned to your account."
|
||||
: "Choose a client from the dashboard before opening this tool.",
|
||||
detail: clientId || "No client selected",
|
||||
detailLabel: "Client",
|
||||
});
|
||||
}
|
||||
|
||||
return serveFile(deps, page.file);
|
||||
@@ -261,18 +323,32 @@ export async function handlePageRoutes(
|
||||
try {
|
||||
requirePermission(user, page.permission);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error;
|
||||
if (error instanceof Response && error.status === 403) {
|
||||
return permissionDeniedResponse(deps, page.permission);
|
||||
}
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (page.feature && !canUserAccessFeature(user.userId, user.role, page.feature)) {
|
||||
return new Response(`Forbidden: feature access denied for ${page.feature}`, { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "feature",
|
||||
title: "This feature isn't enabled for you",
|
||||
message: "Your account's feature access does not include this client tool.",
|
||||
detail: page.feature,
|
||||
detailLabel: "Restricted feature",
|
||||
});
|
||||
}
|
||||
|
||||
const clientId = match[page.clientIdGroup];
|
||||
if (!canAccessClientPage(user.userId, user.role, clientId)) {
|
||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||
return accessDeniedResponse(deps, {
|
||||
kind: "client",
|
||||
title: "Client access restricted",
|
||||
message: "This client is outside the scope assigned to your account.",
|
||||
detail: clientId,
|
||||
detailLabel: "Client",
|
||||
});
|
||||
}
|
||||
|
||||
return serveFile(deps, page.file);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { renderAccessDeniedPage } from "./access-denied-view";
|
||||
|
||||
describe("renderAccessDeniedPage", () => {
|
||||
test("renders a complete permission screen with recovery actions", () => {
|
||||
const html = renderAccessDeniedPage({
|
||||
kind: "permission",
|
||||
message: "This page requires additional access.",
|
||||
detail: "users:manage",
|
||||
detailLabel: "Missing permission",
|
||||
});
|
||||
|
||||
expect(html).toContain("<!doctype html>");
|
||||
expect(html).toContain("You don't have access");
|
||||
expect(html).toContain("users:manage");
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain('href="/settings"');
|
||||
});
|
||||
|
||||
test("escapes dynamic denial details", () => {
|
||||
const html = renderAccessDeniedPage({
|
||||
kind: "client",
|
||||
title: "<script>bad()</script>",
|
||||
message: "Client <not-allowed>",
|
||||
detail: '"><img src=x onerror=bad()>',
|
||||
});
|
||||
|
||||
expect(html).not.toContain("<script>bad()</script>");
|
||||
expect(html).not.toContain("<img src=x");
|
||||
expect(html).toContain("<script>bad()</script>");
|
||||
expect(html).toContain("<not-allowed>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { escapeHtml } from "./html";
|
||||
|
||||
export type AccessDeniedKind =
|
||||
| "permission"
|
||||
| "role"
|
||||
| "feature"
|
||||
| "client"
|
||||
| "unavailable";
|
||||
|
||||
type AccessDeniedViewOptions = {
|
||||
kind: AccessDeniedKind;
|
||||
title?: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
detailLabel?: string;
|
||||
};
|
||||
|
||||
const ICONS: Record<AccessDeniedKind, string> = {
|
||||
permission: "fa-user-lock",
|
||||
role: "fa-shield-halved",
|
||||
feature: "fa-toggle-off",
|
||||
client: "fa-desktop",
|
||||
unavailable: "fa-circle-pause",
|
||||
};
|
||||
|
||||
export function renderAccessDeniedPage(options: AccessDeniedViewOptions): string {
|
||||
const title = options.title || (
|
||||
options.kind === "unavailable" ? "This feature is unavailable" : "You don't have access"
|
||||
);
|
||||
const detail = options.detail
|
||||
? `
|
||||
<div class="access-detail">
|
||||
<span>${escapeHtml(options.detailLabel || "Required access")}</span>
|
||||
<code>${escapeHtml(options.detail)}</code>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="turbo-visit-control" content="reload" />
|
||||
<title>${escapeHtml(title)} · Overlord</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="stylesheet" href="/vendor/inter/400.css" />
|
||||
<link rel="stylesheet" href="/vendor/inter/600.css" />
|
||||
<link rel="stylesheet" href="/vendor/inter/700.css" />
|
||||
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
|
||||
<link rel="stylesheet" href="/assets/access-denied.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="access-shell">
|
||||
<section class="access-card" aria-labelledby="access-title">
|
||||
<div class="access-status">403 · Access restricted</div>
|
||||
<div class="access-icon" aria-hidden="true">
|
||||
<i class="fa-solid ${ICONS[options.kind]}"></i>
|
||||
</div>
|
||||
<h1 id="access-title">${escapeHtml(title)}</h1>
|
||||
<p class="access-message">${escapeHtml(options.message)}</p>
|
||||
${detail}
|
||||
<p class="access-help">If you believe you should have access, ask an administrator to update your account.</p>
|
||||
<div class="access-actions">
|
||||
<a class="access-primary" href="/">
|
||||
<i class="fa-solid fa-border-all" aria-hidden="true"></i>
|
||||
Return to dashboard
|
||||
</a>
|
||||
<a class="access-secondary" href="/settings">
|
||||
<i class="fa-solid fa-gear" aria-hidden="true"></i>
|
||||
Account settings
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<p class="access-footer"><i class="fa-solid fa-crown" aria-hidden="true"></i> Overlord</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Reference in New Issue
Block a user