From 70739a2f69f904fa43d984f3b265fd7d5b5e9089 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:57:07 +0530 Subject: [PATCH 01/10] fix(dashboard): allow only safe URL schemes in configured links --- .../src/features/settings/derived.test.ts | 42 ++++++++++++++++++- dashboard/src/features/settings/derived.ts | 35 +++++++++++++--- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/dashboard/src/features/settings/derived.test.ts b/dashboard/src/features/settings/derived.test.ts index 1609feb4..5c57f648 100644 --- a/dashboard/src/features/settings/derived.test.ts +++ b/dashboard/src/features/settings/derived.test.ts @@ -1,5 +1,35 @@ import { describe, expect, it } from "vitest"; -import { applyJobContext, parseExternalLinks } from "./derived"; +import { applyJobContext, isSafeLinkUrl, parseExternalLinks } from "./derived"; + +describe("isSafeLinkUrl", () => { + it("allows http and https URLs", () => { + expect(isSafeLinkUrl("https://grafana.example.com/d/abc")).toBe(true); + expect(isSafeLinkUrl("http://localhost:3000")).toBe(true); + }); + + it("allows same-origin paths but not protocol-relative URLs", () => { + expect(isSafeLinkUrl("/jobs")).toBe(true); + expect(isSafeLinkUrl("//evil.com")).toBe(false); + }); + + it("rejects javascript:, data:, and other schemes", () => { + expect(isSafeLinkUrl("javascript:alert(document.cookie)")).toBe(false); + expect(isSafeLinkUrl("data:text/html,")).toBe(false); + expect(isSafeLinkUrl("vbscript:x")).toBe(false); + expect(isSafeLinkUrl("file:///etc/passwd")).toBe(false); + }); + + it("rejects relative fragments and garbage", () => { + expect(isSafeLinkUrl("jobs")).toBe(false); + expect(isSafeLinkUrl("")).toBe(false); + expect(isSafeLinkUrl(" ")).toBe(false); + }); + + it("tolerates surrounding whitespace", () => { + expect(isSafeLinkUrl(" https://example.com ")).toBe(true); + expect(isSafeLinkUrl(" javascript:alert(1) ")).toBe(false); + }); +}); describe("parseExternalLinks", () => { it("returns [] for undefined or empty input", () => { @@ -45,6 +75,16 @@ describe("parseExternalLinks", () => { const raw = JSON.stringify([{ label: "Docs", url: "/d", danger: " + +
diff --git a/dashboard/public/theme-init.js b/dashboard/public/theme-init.js new file mode 100644 index 00000000..89cbcea3 --- /dev/null +++ b/dashboard/public/theme-init.js @@ -0,0 +1,9 @@ +// Pre-hydration theme bootstrap. Lives in its own file (not inline in +// index.html) so the servers can ship a CSP with `script-src 'self'` — +// an inline script would need a hash that drifts with every edit. +(() => { + const stored = localStorage.getItem("taskito.theme"); + const preferred = + stored ?? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); + document.documentElement.classList.toggle("dark", preferred === "dark"); +})(); From 33ea1e967333b6cbcf57588e9c59cde6ad043c82 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:57:44 +0530 Subject: [PATCH 06/10] feat(python): send security headers on dashboard responses --- sdks/python/taskito/dashboard/server.py | 22 ++++++++++++++++++++++ sdks/python/tests/dashboard/test_auth.py | 16 ++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/sdks/python/taskito/dashboard/server.py b/sdks/python/taskito/dashboard/server.py index 0602ca69..2519f7b9 100644 --- a/sdks/python/taskito/dashboard/server.py +++ b/sdks/python/taskito/dashboard/server.py @@ -91,6 +91,21 @@ # Hard cap on the request body we'll parse for PUT/POST requests. _MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MiB +# Defense-in-depth headers on every response. The CSP assumes a fully +# self-contained SPA bundle (no inline scripts — the theme bootstrap ships as +# /theme-init.js); inline style attributes need 'unsafe-inline' in style-src. +_SECURITY_HEADERS: tuple[tuple[str, str], ...] = ( + ( + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; " + "base-uri 'self'; form-action 'self'; object-src 'none'", + ), + ("X-Content-Type-Options", "nosniff"), + ("X-Frame-Options", "DENY"), + ("Referrer-Policy", "same-origin"), +) + def _safe_path(path: str) -> str: """Return ``path`` with control characters stripped and length capped.""" @@ -195,6 +210,13 @@ def _make_handler( class DashboardHandler(BaseHTTPRequestHandler): # ── Entry points ──────────────────────────────────────────── + def end_headers(self) -> None: + # Single choke point: every response — JSON, assets, probes, + # redirects — carries the security headers. + for name, value in _SECURITY_HEADERS: + self.send_header(name, value) + super().end_headers() + def do_GET(self) -> None: try: self._handle_get() diff --git a/sdks/python/tests/dashboard/test_auth.py b/sdks/python/tests/dashboard/test_auth.py index 9a70f62b..4f6ba17d 100644 --- a/sdks/python/tests/dashboard/test_auth.py +++ b/sdks/python/tests/dashboard/test_auth.py @@ -708,6 +708,22 @@ def test_probe_accepts_metrics_bearer_when_auth_enabled( assert status == 200 +# ── Security headers ─────────────────────────────────────────────────── + + +def test_security_headers_on_every_response(open_dashboard_server: tuple[str, Queue]) -> None: + base, _ = open_dashboard_server + # "/" is skipped: SPA assets may be absent in CI, and the headers are + # emitted from end_headers() so they apply to every response uniformly. + for path in ("/health", "/api/stats"): + resp = urllib.request.urlopen(f"{base}{path}") + headers = resp.headers + assert headers["X-Content-Type-Options"] == "nosniff", path + assert headers["X-Frame-Options"] == "DENY", path + assert headers["Referrer-Policy"] == "same-origin", path + assert "default-src 'self'" in (headers["Content-Security-Policy"] or ""), path + + # ── Auth disabled (the default) ──────────────────────────────────────── From f238822bc1a3fa59a0d677863da150d7b6db3414 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:57:52 +0530 Subject: [PATCH 07/10] feat(node): send security headers on dashboard responses --- sdks/node/src/dashboard/server.ts | 22 ++++++++++++++++++++++ sdks/node/test/dashboard/auth.test.ts | 10 ++++++++++ 2 files changed, 32 insertions(+) diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index d56eb31a..fc5c71eb 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -41,6 +41,23 @@ const log = createLogger("dashboard"); /** Max request body size (1 MiB) — reject larger payloads to bound memory. */ const MAX_BODY_BYTES = 1024 * 1024; +/** + * Defense-in-depth headers on every response. The CSP assumes a fully + * self-contained SPA bundle (no inline scripts — the theme bootstrap ships as + * /theme-init.js); inline style attributes need 'unsafe-inline' in style-src. + */ +const SECURITY_HEADERS: ReadonlyArray = [ + [ + "content-security-policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; " + + "base-uri 'self'; form-action 'self'; object-src 'none'", + ], + ["x-content-type-options", "nosniff"], + ["x-frame-options", "DENY"], + ["referrer-policy", "same-origin"], +]; + /** Options accepted by {@link createDashboardHandler} / {@link createDashboardServer}. */ export interface DashboardHandlerOptions { /** Legacy shared-token gate. When set, the session-auth flow is disabled. */ @@ -76,6 +93,11 @@ export function createDashboardHandler( const assets = new StaticAssets(staticDir); const resolved = normalizeOptions(options); return (req, res) => { + // Single choke point: every response — JSON, assets, probes, redirects — + // carries the security headers. + for (const [name, value] of SECURITY_HEADERS) { + res.setHeader(name, value); + } void dispatch(queue, assets, req, res, resolved).catch((error) => { log.error(() => "dashboard dispatch failed", error); if (!res.headersSent) { diff --git a/sdks/node/test/dashboard/auth.test.ts b/sdks/node/test/dashboard/auth.test.ts index 43e6720c..e263242a 100644 --- a/sdks/node/test/dashboard/auth.test.ts +++ b/sdks/node/test/dashboard/auth.test.ts @@ -85,3 +85,13 @@ it("gates probes behind the legacy token; /health stays public", async () => { const ok = await fetch(`${base}/readiness`, { headers: { "x-taskito-token": TOKEN } }); expect(ok.status).not.toBe(401); }); + +it("sets security headers on every response", async () => { + for (const path of ["/health", "/api/auth/status", "/"]) { + const res = await fetch(`${base}${path}`); + expect(res.headers.get("x-content-type-options"), path).toBe("nosniff"); + expect(res.headers.get("x-frame-options"), path).toBe("DENY"); + expect(res.headers.get("referrer-policy"), path).toBe("same-origin"); + expect(res.headers.get("content-security-policy"), path).toContain("default-src 'self'"); + } +}); From 5715655ea672118d7894e29a8e10f1114d9e8a5f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:57:55 +0530 Subject: [PATCH 08/10] feat(java): send security headers on dashboard responses --- .../taskito/dashboard/DashboardServer.java | 23 ++++++++++++++ .../taskito/dashboard/DashboardTest.java | 31 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java index a9b7d4f0..d73270cb 100644 --- a/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java +++ b/sdks/java/src/main/java/org/byteveda/taskito/dashboard/DashboardServer.java @@ -63,6 +63,24 @@ public final class DashboardServer implements AutoCloseable { private static final TaskitoLogger LOG = TaskitoLogger.create("dashboard"); private static final String METRICS_TOKEN_ENV = "TASKITO_DASHBOARD_METRICS_TOKEN"; + /** + * Defense-in-depth headers on every response. The CSP assumes a fully + * self-contained SPA bundle (no inline scripts — the theme bootstrap ships + * as /theme-init.js); inline style attributes need 'unsafe-inline' in + * style-src. + */ + private static final String[][] SECURITY_HEADERS = { + { + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; " + + "base-uri 'self'; form-action 'self'; object-src 'none'" + }, + {"X-Content-Type-Options", "nosniff"}, + {"X-Frame-Options", "DENY"}, + {"Referrer-Policy", "same-origin"}, + }; + private final HttpServer server; private final Taskito queue; private final Path staticDir; @@ -214,6 +232,11 @@ public void close() { private void dispatch(HttpExchange exchange) throws IOException { try { + // Single choke point: every response — JSON, assets, probes, + // redirects — carries the security headers. + for (String[] header : SECURITY_HEADERS) { + exchange.getResponseHeaders().set(header[0], header[1]); + } String path = exchange.getRequestURI().getPath(); if (path.equals("/health")) { Http.respondJson(exchange, 200, Map.of("status", "ok")); diff --git a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java index 69263003..d4ee83a3 100644 --- a/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java +++ b/sdks/java/src/test/java/org/byteveda/taskito/dashboard/DashboardTest.java @@ -92,6 +92,37 @@ void servesSpaWithoutAStaticPath(@TempDir Path dir) throws Exception { } } + @Test + @Timeout(30) + void setsSecurityHeadersOnEveryResponse(@TempDir Path dir) throws Exception { + try (Taskito queue = Taskito.builder() + .backend("sqlite") + .url(dir.resolve("t.db").toString()) + .open()) { + try (DashboardServer server = DashboardServer.start(queue, 0)) { + for (String path : new String[] {"/health", "/api/stats"}) { + HttpResponse res = get(server.port(), path); + assertEquals( + "nosniff", + res.headers().firstValue("X-Content-Type-Options").orElse(""), + path); + assertEquals( + "DENY", res.headers().firstValue("X-Frame-Options").orElse(""), path); + assertEquals( + "same-origin", + res.headers().firstValue("Referrer-Policy").orElse(""), + path); + assertTrue( + res.headers() + .firstValue("Content-Security-Policy") + .orElse("") + .contains("default-src 'self'"), + path); + } + } + } + } + @Test @Timeout(30) void enforcesToken(@TempDir Path dir) throws Exception { From 668e4f7125d812fc8d4960f34e3d2f5c0962097f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:57:55 +0530 Subject: [PATCH 09/10] docs: document dashboard security headers --- docs/content/docs/java/guides/operations/dashboard.mdx | 5 +++++ docs/content/docs/node/guides/operations/security.mdx | 5 +++++ docs/content/docs/python/guides/operations/security.mdx | 6 ++++++ 3 files changed, 16 insertions(+) diff --git a/docs/content/docs/java/guides/operations/dashboard.mdx b/docs/content/docs/java/guides/operations/dashboard.mdx index aa85cc35..c10f5a6b 100644 --- a/docs/content/docs/java/guides/operations/dashboard.mdx +++ b/docs/content/docs/java/guides/operations/dashboard.mdx @@ -193,6 +193,11 @@ that mode's own credential (a valid session, or the shared token) or a point scrapers at the bearer token. In open mode they stay public unless that env token is set. `/health` always stays open for liveness probes. +Every response — JSON, SPA assets, and probes alike — carries defense-in-depth +headers: a `Content-Security-Policy` locked to the dashboard's own origin, +`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and +`Referrer-Policy: same-origin`. + ## REST API Everything is JSON, fields in snake_case, timestamps in Unix milliseconds — diff --git a/docs/content/docs/node/guides/operations/security.mdx b/docs/content/docs/node/guides/operations/security.mdx index 2b5e9971..c6926bdd 100644 --- a/docs/content/docs/node/guides/operations/security.mdx +++ b/docs/content/docs/node/guides/operations/security.mdx @@ -60,6 +60,11 @@ way, bind it to localhost or a private network, or mount it behind your own auth via the [Express](/node/guides/integrations/express#dashboard) / [Fastify](/node/guides/integrations/fastify) helpers. +Every dashboard response carries defense-in-depth headers: a +`Content-Security-Policy` locked to the dashboard's own origin, +`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` (the dashboard +can't be framed), and `Referrer-Policy: same-origin`. + ## Hardening checklist - [ ] Storage reachable only from trusted services, on a private network. diff --git a/docs/content/docs/python/guides/operations/security.mdx b/docs/content/docs/python/guides/operations/security.mdx index e6181b91..17585e2a 100644 --- a/docs/content/docs/python/guides/operations/security.mdx +++ b/docs/content/docs/python/guides/operations/security.mdx @@ -100,6 +100,12 @@ once at least one user exists; until then every API route returns is always open for liveness probes. - **Settings.** The settings API never exposes or accepts keys under the internal `auth:` namespace (password hashes, sessions, CSRF secret). +- **Security headers.** Every dashboard response carries a + `Content-Security-Policy` locked to the dashboard's own origin, + `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and + `Referrer-Policy: same-origin`. Configured external links and integration + URLs are also scheme-filtered client-side — only `http(s)` and same-origin + paths ever become clickable. The FastAPI/Flask routers in `taskito.contrib` expose job-control endpoints From d5a58f591a7bfe3fc8583bf38864511574a041df Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:10:20 +0530 Subject: [PATCH 10/10] fix(dashboard): reject backslashes in same-origin link paths --- dashboard/src/features/auth/components/login-form.tsx | 4 +++- dashboard/src/features/settings/derived.test.ts | 5 +++++ dashboard/src/features/settings/derived.ts | 6 +++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/dashboard/src/features/auth/components/login-form.tsx b/dashboard/src/features/auth/components/login-form.tsx index c4026ce4..c8752fa7 100644 --- a/dashboard/src/features/auth/components/login-form.tsx +++ b/dashboard/src/features/auth/components/login-form.tsx @@ -22,7 +22,9 @@ const ERROR_MESSAGES: Record = { */ function safeNextPath(next: unknown): string | undefined { if (typeof next !== "string") return undefined; - return next.startsWith("/") && !next.startsWith("//") ? next : undefined; + // Backslashes are rejected too: WHATWG URL parsing normalizes "\" to "/" + // for http(s), so "/\evil.com" would escape the origin. + return next.startsWith("/") && !next.startsWith("//") && !next.includes("\\") ? next : undefined; } export function LoginForm() { diff --git a/dashboard/src/features/settings/derived.test.ts b/dashboard/src/features/settings/derived.test.ts index 5c57f648..e18189c1 100644 --- a/dashboard/src/features/settings/derived.test.ts +++ b/dashboard/src/features/settings/derived.test.ts @@ -12,6 +12,11 @@ describe("isSafeLinkUrl", () => { expect(isSafeLinkUrl("//evil.com")).toBe(false); }); + it("rejects backslashes in paths (URL parsing folds them to slashes)", () => { + expect(isSafeLinkUrl("/\\evil.com")).toBe(false); + expect(isSafeLinkUrl("/jobs\\..\\x")).toBe(false); + }); + it("rejects javascript:, data:, and other schemes", () => { expect(isSafeLinkUrl("javascript:alert(document.cookie)")).toBe(false); expect(isSafeLinkUrl("data:text/html,")).toBe(false); diff --git a/dashboard/src/features/settings/derived.ts b/dashboard/src/features/settings/derived.ts index 5edc0c1b..38acaea6 100644 --- a/dashboard/src/features/settings/derived.ts +++ b/dashboard/src/features/settings/derived.ts @@ -12,7 +12,11 @@ import { type ExternalLink, type IntegrationUrls, SETTING_KEYS } from "./types"; */ export function isSafeLinkUrl(value: string): boolean { const trimmed = value.trim(); - if (trimmed.startsWith("/")) return !trimmed.startsWith("//"); + if (trimmed.startsWith("/")) { + // Reject protocol-relative (//host) and backslashes: WHATWG URL parsing + // normalizes "\" to "/" for http(s), so "/\evil.com" escapes the origin. + return !trimmed.startsWith("//") && !trimmed.includes("\\"); + } try { const protocol = new URL(trimmed).protocol; return protocol === "http:" || protocol === "https:";