@thotischner/observability-mcp 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/analysis/history.d.ts +36 -2
  2. package/dist/analysis/history.js +60 -2
  3. package/dist/analysis/history.test.js +46 -0
  4. package/dist/auth/csrf.d.ts +6 -0
  5. package/dist/auth/csrf.js +4 -0
  6. package/dist/auth/csrf.test.js +22 -0
  7. package/dist/auth/lockout.d.ts +72 -0
  8. package/dist/auth/lockout.js +134 -0
  9. package/dist/auth/lockout.test.d.ts +1 -0
  10. package/dist/auth/lockout.test.js +133 -0
  11. package/dist/auth/middleware.d.ts +5 -0
  12. package/dist/auth/middleware.js +6 -1
  13. package/dist/auth/middleware.test.js +31 -0
  14. package/dist/auth/password-policy.d.ts +52 -0
  15. package/dist/auth/password-policy.js +125 -0
  16. package/dist/auth/password-policy.test.d.ts +1 -0
  17. package/dist/auth/password-policy.test.js +111 -0
  18. package/dist/auth/revocation.d.ts +93 -0
  19. package/dist/auth/revocation.js +193 -0
  20. package/dist/auth/revocation.test.d.ts +1 -0
  21. package/dist/auth/revocation.test.js +136 -0
  22. package/dist/auth/session.d.ts +7 -0
  23. package/dist/auth/session.js +6 -0
  24. package/dist/auth/session.test.js +21 -0
  25. package/dist/connectors/interface.d.ts +5 -1
  26. package/dist/connectors/loki.d.ts +45 -1
  27. package/dist/connectors/loki.js +141 -8
  28. package/dist/connectors/loki.test.js +171 -1
  29. package/dist/index.js +217 -3
  30. package/dist/openapi.js +39 -0
  31. package/dist/openapi.test.js +1 -0
  32. package/dist/security/csp.d.ts +64 -0
  33. package/dist/security/csp.js +135 -0
  34. package/dist/security/csp.test.d.ts +1 -0
  35. package/dist/security/csp.test.js +97 -0
  36. package/dist/tools/query-logs.d.ts +40 -0
  37. package/dist/tools/query-logs.js +69 -3
  38. package/dist/tools/validation.d.ts +13 -0
  39. package/dist/tools/validation.js +74 -0
  40. package/dist/tools/validation.test.js +54 -1
  41. package/dist/types.d.ts +48 -0
  42. package/dist/ui/index.html +42 -15
  43. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -19,6 +19,10 @@ import { buildSessionAttacher, buildRequireSession, } from "./auth/middleware.js
19
19
  import { buildRequirePermissionFromEngine, hasPermission, listGrantedPermissions, DEFAULT_POLICY, } from "./auth/rbac.js";
20
20
  import { resolveOidcConfig, buildOidcRuntime } from "./auth/oidc/runtime.js";
21
21
  import { registerOidcRoutes } from "./auth/oidc/endpoints.js";
22
+ import { RevocationStore } from "./auth/revocation.js";
23
+ import { AccountLockout, lockoutConfigFromEnv, lockoutDisabledFromEnv, } from "./auth/lockout.js";
24
+ import { resolveSessionStore } from "./transport/sessionStore.js";
25
+ import { generateNonce, enforcedCsp, reportOnlyCsp, reportingEndpointsHeader, reportToHeader, summariseViolation, cspStrictReportFromEnv, CSP_NONCE_PLACEHOLDER, } from "./security/csp.js";
22
26
  import { createScimStore } from "./scim/store.js";
23
27
  import { registerScimRoutes } from "./scim/routes.js";
24
28
  import { BuiltinPolicyEngine } from "./auth/policy/engine.js";
@@ -723,7 +727,19 @@ async function main() {
723
727
  else if (requestedAuthMode !== "anonymous") {
724
728
  authMisconfig(`unknown OMCP_AUTH=${requestedAuthMode}`);
725
729
  }
726
- const authRuntime = { mode: authMode, session: sessionCfg, secretEphemeral, oidc: oidcRuntime };
730
+ // Session revocation blocklist (Q17). Only meaningful when sessions
731
+ // exist (basic / oidc); anonymous mode leaves it undefined so the
732
+ // middleware check is a pure no-op. OMCP_AUTH_REVOCATION_FILE persists
733
+ // the blocklist across restarts and shares it across replicas when it
734
+ // points at shared storage; unset = in-memory only.
735
+ let revocationStore;
736
+ if (authMode !== "anonymous") {
737
+ revocationStore = await RevocationStore.create({
738
+ path: process.env.OMCP_AUTH_REVOCATION_FILE?.trim() || undefined,
739
+ });
740
+ console.log(`[auth] session revocation blocklist active — backend=${revocationStore.persistent ? `file (${revocationStore.filePath})` : "memory"}, ${revocationStore.size} existing entr${revocationStore.size === 1 ? "y" : "ies"}`);
741
+ }
742
+ const authRuntime = { mode: authMode, session: sessionCfg, secretEphemeral, oidc: oidcRuntime, revocation: revocationStore };
727
743
  // --- HTTP server ---
728
744
  const app = express();
729
745
  // Trust-proxy: when set, Express will read req.ip / req.secure from
@@ -757,13 +773,38 @@ async function main() {
757
773
  // without the wildcard the body silently arrives empty and every
758
774
  // SCIM POST/PATCH 400s. The wildcard also future-proofs other
759
775
  // structured-suffix JSON content types.
760
- app.use(express.json({ limit: "1mb", type: ["application/json", "application/*+json"] }));
776
+ // application/csp-report is the legacy media type browsers use for CSP
777
+ // violation reports (the modern Reporting API uses application/reports+json,
778
+ // already covered by the wildcard). Without it the report body arrives empty.
779
+ app.use(express.json({ limit: "1mb", type: ["application/json", "application/*+json", "application/csp-report"] }));
780
+ // Q20 — resolve the opt-in strict Report-Only CSP toggle once at boot.
781
+ // Default off: with ~200 inline handlers the report-only policy would
782
+ // emit a [Report Only] console message per handler on every page load.
783
+ const cspStrictReport = cspStrictReportFromEnv();
784
+ if (cspStrictReport) {
785
+ console.log("[csp] strict report-only policy ON (OMCP_CSP_STRICT_REPORT) — inline-handler violations will be reported to /api/csp-violations");
786
+ }
761
787
  // Security headers
762
788
  app.use((req, res, next) => {
763
789
  res.setHeader("X-Content-Type-Options", "nosniff");
764
790
  res.setHeader("X-Frame-Options", "DENY");
765
791
  res.setHeader("X-XSS-Protection", "1; mode=block");
766
792
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
793
+ // Q20 — Content-Security-Policy. A per-request nonce is minted and
794
+ // stashed on res.locals so the UI handler can stamp it into the two
795
+ // inline <script> blocks. The enforced policy keeps the UI working
796
+ // (script-src 'unsafe-inline' for the ~200 inline handlers) and is
797
+ // always on; the strict report-only policy is opt-in (it surfaces the
798
+ // inline-handler debt but is console-noisy). Both report to
799
+ // /api/csp-violations.
800
+ const nonce = generateNonce();
801
+ res.locals.cspNonce = nonce;
802
+ res.setHeader("Content-Security-Policy", enforcedCsp());
803
+ if (cspStrictReport) {
804
+ res.setHeader("Content-Security-Policy-Report-Only", reportOnlyCsp(nonce));
805
+ }
806
+ res.setHeader("Reporting-Endpoints", reportingEndpointsHeader());
807
+ res.setHeader("Report-To", reportToHeader());
767
808
  // Dynamic API responses must never be served from the browser/proxy
768
809
  // cache: after a mutation (e.g. installing a connector) the UI
769
810
  // re-fetches these GETs immediately, and a heuristically-cached stale
@@ -814,6 +855,11 @@ async function main() {
814
855
  const csrfCfg = {
815
856
  bypassBearer: csrfBypassFromEnv(),
816
857
  secureCookie: (r) => r.secure || r.headers["x-forwarded-proto"] === "https",
858
+ // CSP violation reports are unauthenticated browser POSTs that by
859
+ // construction carry no cookie + no custom header — exempt them from
860
+ // CSRF. The endpoint only records a sanitised summary, so accepting it
861
+ // cross-site is harmless.
862
+ skip: (r) => r.method === "POST" && (r.path === "/api/csp-violations" || r.originalUrl.split("?")[0] === "/api/csp-violations"),
817
863
  };
818
864
  app.use(buildCsrfIssuer(csrfCfg));
819
865
  app.use("/api", buildCsrfEnforcer(csrfCfg));
@@ -944,6 +990,36 @@ async function main() {
944
990
  .catch((err) => console.warn("AuditLog flushSinks failed:", err));
945
991
  });
946
992
  const audit = (resource, action) => buildAuditMiddleware({ audit: mgmtAudit, resource, action });
993
+ // Q20 — CSP violation report sink. Unauthenticated browser POST (exempt
994
+ // from CSRF via csrfCfg.skip), tightly rate-limited so a misbehaving or
995
+ // hostile client can't flood the audit log, and only a sanitised summary
996
+ // (directive / blocked-uri / document-uri) is recorded. Always 204 so the
997
+ // browser never retries. The report-only strict policy is what drives most
998
+ // of these today (the inline-handler debt) — they roll into mgmtAudit so an
999
+ // operator can watch the migration surface shrink.
1000
+ const cspReportRateLimit = rateLimit({
1001
+ windowMs: 60_000,
1002
+ max: 60,
1003
+ standardHeaders: true,
1004
+ legacyHeaders: false,
1005
+ message: { error: "rate limited" },
1006
+ });
1007
+ app.post("/api/csp-violations", cspReportRateLimit, (req, res) => {
1008
+ const summary = summariseViolation(req.body);
1009
+ if (summary) {
1010
+ void mgmtAudit.record({
1011
+ actor: { sub: "browser:csp" },
1012
+ tenant: "default",
1013
+ resource: "settings",
1014
+ action: "read",
1015
+ method: "POST",
1016
+ path: "/api/csp-violations",
1017
+ status: 204,
1018
+ target: `${summary.directive} blocked ${summary.blockedUri}`.slice(0, 256),
1019
+ }).catch(() => { });
1020
+ }
1021
+ res.status(204).end();
1022
+ });
947
1023
  // Plugin lifecycle hook registry — populated by the loader at boot
948
1024
  // (one entry per manifest `hooks[]` entry) and mutable at runtime
949
1025
  // when a connector is installed via /api/connectors/install. Each
@@ -1106,7 +1182,29 @@ async function main() {
1106
1182
  res.end(await selfRegistry.metrics());
1107
1183
  });
1108
1184
  }
1109
- // Serve Web UI
1185
+ // Serve Web UI. The index page is served dynamically so the per-request
1186
+ // CSP nonce can be stamped into its inline <script> blocks (the rest of
1187
+ // ui/ stays on express.static). Read once at boot; if the file is
1188
+ // missing we fall through to static, which 404s like before.
1189
+ let uiHtmlTemplate = null;
1190
+ try {
1191
+ uiHtmlTemplate = readFileSync(join(__dirname, "ui", "index.html"), "utf8");
1192
+ }
1193
+ catch {
1194
+ uiHtmlTemplate = null;
1195
+ }
1196
+ if (uiHtmlTemplate) {
1197
+ const template = uiHtmlTemplate;
1198
+ const serveIndex = (_req, res) => {
1199
+ const nonce = res.locals.cspNonce ?? "";
1200
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
1201
+ // Index is identity/nonce-specific — never let a proxy cache it.
1202
+ res.setHeader("Cache-Control", "no-store");
1203
+ res.send(template.split(CSP_NONCE_PLACEHOLDER).join(nonce));
1204
+ };
1205
+ app.get("/", serveIndex);
1206
+ app.get("/index.html", serveIndex);
1207
+ }
1110
1208
  app.use(express.static(join(__dirname, "ui")));
1111
1209
  // --- API endpoints for Web UI ---
1112
1210
  // List sources with health status — tenant-scoped.
@@ -1286,6 +1384,10 @@ async function main() {
1286
1384
  },
1287
1385
  permissions: listGrantedPermissions(sess.roles, policyEngineToMap(policyEngine)),
1288
1386
  exp: sess.exp,
1387
+ // The current session's revocation id. Surfaced so an admin can
1388
+ // copy it into POST /api/auth/revocations to kill a specific
1389
+ // session. Absent for legacy cookies issued before sid existed.
1390
+ sid: sess.sid,
1289
1391
  // When the user signed in via OIDC, surface the IdP issuer
1290
1392
  // URL so the UI can render an appropriate badge or link to
1291
1393
  // an IdP-side profile page. Empty / absent in basic mode.
@@ -1810,6 +1912,19 @@ async function main() {
1810
1912
  catch { /* ignore — first login will pick it up */ }
1811
1913
  }
1812
1914
  }
1915
+ // Q18 — per-username failed-login lockout with progressive backoff.
1916
+ // Complements the per-IP loginRateLimit above: that bounds a noisy
1917
+ // single source, this bounds a slow / distributed grind on one
1918
+ // account. Backed by the shared SessionStore so a Redis deployment
1919
+ // locks consistently across replicas (and self-cleans via TTL).
1920
+ // Basic mode only — OIDC delegates auth (and lockout) to the IdP.
1921
+ let lockout;
1922
+ if (authRuntime.mode === "basic" && !lockoutDisabledFromEnv()) {
1923
+ const lockoutStore = await resolveSessionStore();
1924
+ const lockoutCfg = lockoutConfigFromEnv();
1925
+ lockout = new AccountLockout(lockoutStore, lockoutCfg);
1926
+ console.log(`[auth] account lockout active — ${lockoutCfg.maxFailures} failures / ${lockoutCfg.windowSeconds}s → lock ${lockoutCfg.baseLockSeconds}s (×2 up to ${lockoutCfg.maxLockSeconds}s), backend=${lockoutStore.backend}`);
1927
+ }
1813
1928
  app.post("/api/auth/login", loginRateLimit, async (req, res) => {
1814
1929
  if (authRuntime.mode !== "basic" || !sessionCfg || !usersStore) {
1815
1930
  res.status(503).json({ error: "auth mode does not accept logins" });
@@ -1823,11 +1938,57 @@ async function main() {
1823
1938
  res.status(400).json({ error: "username and password are required" });
1824
1939
  return;
1825
1940
  }
1941
+ // Gate on the lock BEFORE the (expensive) scrypt verify so a locked
1942
+ // account can't be used to burn CPU. A locked account is a 429 with
1943
+ // Retry-After, never a credential oracle — the response is identical
1944
+ // whether or not the username exists.
1945
+ if (lockout) {
1946
+ const status = await lockout.check(username);
1947
+ if (status.locked) {
1948
+ res.setHeader("Retry-After", String(status.retryAfterSeconds ?? 0));
1949
+ res.status(429).json({
1950
+ error: "account temporarily locked due to repeated failed logins",
1951
+ retryAfterSeconds: status.retryAfterSeconds,
1952
+ });
1953
+ void mgmtAudit.record({
1954
+ actor: { sub: username },
1955
+ tenant: "default",
1956
+ resource: "users",
1957
+ action: "write",
1958
+ method: "POST",
1959
+ path: "/api/auth/login",
1960
+ status: 429,
1961
+ }).catch(() => { });
1962
+ return;
1963
+ }
1964
+ }
1826
1965
  const user = authenticate(username, password, usersStore);
1827
1966
  if (!user) {
1967
+ if (lockout) {
1968
+ const after = await lockout.recordFailure(username);
1969
+ if (after.locked) {
1970
+ res.setHeader("Retry-After", String(after.retryAfterSeconds ?? 0));
1971
+ res.status(429).json({
1972
+ error: "account temporarily locked due to repeated failed logins",
1973
+ retryAfterSeconds: after.retryAfterSeconds,
1974
+ });
1975
+ void mgmtAudit.record({
1976
+ actor: { sub: username },
1977
+ tenant: "default",
1978
+ resource: "users",
1979
+ action: "write",
1980
+ method: "POST",
1981
+ path: "/api/auth/login",
1982
+ status: 429,
1983
+ }).catch(() => { });
1984
+ return;
1985
+ }
1986
+ }
1828
1987
  res.status(401).json({ error: "invalid credentials" });
1829
1988
  return;
1830
1989
  }
1990
+ if (lockout)
1991
+ await lockout.recordSuccess(user.username);
1831
1992
  const { cookie } = issueSession({ sub: user.username, name: user.name, roles: user.roles, tenant: user.tenant }, sessionCfg);
1832
1993
  const secure = req.secure || (req.headers["x-forwarded-proto"] === "https");
1833
1994
  res.setHeader("Set-Cookie", setCookieHeader(cookie, sessionCfg, { secure }));
@@ -1855,6 +2016,34 @@ async function main() {
1855
2016
  registerOidcRoutes(app, { sessionCfg, oidc: oidcRuntime });
1856
2017
  console.log("[auth] OIDC endpoints registered: /api/auth/oidc/{login,callback,logout}");
1857
2018
  }
2019
+ // Q17 — session revocation blocklist. Admin-gated (same role tier as
2020
+ // user/role management). A revoked-but-unexpired cookie is rejected by
2021
+ // buildSessionAttacher on the next request. Revoke a single session by
2022
+ // `sid` (read it from /api/me or the audit log) or every current
2023
+ // session for a `sub` ("log this user out everywhere"). The blocklist
2024
+ // is the stateful complement to the otherwise-stateless cookie.
2025
+ app.post("/api/auth/revocations", need("users", "delete"), audit("users", "write"), async (req, res) => {
2026
+ if (!revocationStore) {
2027
+ res.status(503).json({ error: "revocation requires an auth mode (basic|oidc)" });
2028
+ return;
2029
+ }
2030
+ const body = (req.body || {});
2031
+ const sid = typeof body.sid === "string" && body.sid.trim() ? body.sid.trim() : undefined;
2032
+ const sub = typeof body.sub === "string" && body.sub.trim() ? body.sub.trim() : undefined;
2033
+ const reason = typeof body.reason === "string" ? body.reason.slice(0, 500) : undefined;
2034
+ if ((sid ? 1 : 0) + (sub ? 1 : 0) !== 1) {
2035
+ res.status(400).json({ error: "exactly one of `sid` or `sub` is required" });
2036
+ return;
2037
+ }
2038
+ const by = req.session?.sub;
2039
+ const entry = sid
2040
+ ? await revocationStore.revokeSession(sid, { reason, by })
2041
+ : await revocationStore.revokeSubject(sub, { reason, by });
2042
+ res.status(201).json({ ok: true, revocation: entry });
2043
+ });
2044
+ app.get("/api/auth/revocations", need("users", "delete"), (_req, res) => {
2045
+ res.json({ revocations: revocationStore ? revocationStore.list() : [] });
2046
+ });
1858
2047
  // Phase F21 / Q6: SCIM 2.0 — opt-in. OMCP_SCIM_TOKEN gates access.
1859
2048
  // The store backend is chosen by createScimStore from
1860
2049
  // OMCP_SCIM_BACKEND (file | redis). file (default) → OMCP_SCIM_STORE
@@ -2618,6 +2807,31 @@ async function main() {
2618
2807
  tools: filteredTools,
2619
2808
  });
2620
2809
  });
2810
+ // Q21 — per-service anomaly-score sparklines for the Health tab. Reads
2811
+ // the in-process ring of the anomaly-history sink (last hour), tenant-
2812
+ // scoped. MUST be registered before "/api/health/:service" so the
2813
+ // literal path isn't captured as a service name. `enabled` is true once
2814
+ // any score exists; the UI falls back to its client-side trend otherwise.
2815
+ app.get("/api/health/anomaly-sparklines", (req, res) => {
2816
+ const sess = req.session;
2817
+ const callerTenant = sess?.tenant || "default";
2818
+ // Anonymous (single-tenant) mode: no tenant filter, see everything.
2819
+ const tenant = sess ? callerTenant : undefined;
2820
+ const records = anomalyHistory.recent({ tenant });
2821
+ const series = {};
2822
+ for (const r of records) {
2823
+ const t = Date.parse(r.ts);
2824
+ if (!Number.isFinite(t))
2825
+ continue;
2826
+ (series[r.service] ??= []).push({ t, score: r.score });
2827
+ }
2828
+ res.json({
2829
+ enabled: records.length > 0,
2830
+ remoteWrite: anomalyHistory.isEnabled(),
2831
+ windowMs: anomalyHistory.windowMs,
2832
+ series,
2833
+ });
2834
+ });
2621
2835
  // Health endpoint for UI dashboard
2622
2836
  app.get("/api/health/:service", async (req, res) => {
2623
2837
  try {
package/dist/openapi.js CHANGED
@@ -394,6 +394,45 @@ export function buildOpenApiSpec(version) {
394
394
  responses: { "204": { description: "Cookie cleared." } },
395
395
  },
396
396
  },
397
+ "/api/auth/revocations": {
398
+ get: {
399
+ tags: ["auth"],
400
+ summary: "List session revocations (admin).",
401
+ description: "Returns the current revocation blocklist. Admin-gated (users:delete). Empty in anonymous mode.",
402
+ responses: {
403
+ "200": { description: "Array of revocation entries." },
404
+ "401": { description: "Authentication required." },
405
+ "403": { description: "Caller lacks the admin permission." },
406
+ },
407
+ },
408
+ post: {
409
+ tags: ["auth"],
410
+ summary: "Revoke a session or all of a subject's sessions (admin).",
411
+ description: "Adds an entry to the on-disk blocklist. Provide exactly one of `sid` (revoke one session — copy it from /api/me) or `sub` (log a user out everywhere — revokes every session issued so far; a fresh login afterwards is unaffected). The next request bearing a revoked cookie is treated as logged out.",
412
+ requestBody: {
413
+ required: true,
414
+ content: {
415
+ "application/json": {
416
+ schema: {
417
+ type: "object",
418
+ properties: {
419
+ sid: { type: "string", description: "Session id to revoke (from /api/me)." },
420
+ sub: { type: "string", description: "Subject whose current sessions to revoke." },
421
+ reason: { type: "string", description: "Optional free-text reason (truncated to 500 chars)." },
422
+ },
423
+ },
424
+ },
425
+ },
426
+ },
427
+ responses: {
428
+ "201": { description: "Revocation recorded; the entry is returned." },
429
+ "400": { description: "Neither or both of sid/sub supplied." },
430
+ "401": { description: "Authentication required." },
431
+ "403": { description: "Caller lacks the admin permission." },
432
+ "503": { description: "Server is in anonymous mode (no sessions to revoke)." },
433
+ },
434
+ },
435
+ },
397
436
  "/api/auth/oidc/login": {
398
437
  get: {
399
438
  tags: ["auth"],
@@ -19,6 +19,7 @@ test("openapi — every user-visible /api path is documented", () => {
19
19
  "/api/me",
20
20
  "/api/auth/login",
21
21
  "/api/auth/logout",
22
+ "/api/auth/revocations",
22
23
  "/api/auth/oidc/login",
23
24
  "/api/auth/oidc/callback",
24
25
  "/api/auth/oidc/logout",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Content-Security-Policy for the management-plane Web UI.
3
+ *
4
+ * Two policies ship together, by design:
5
+ *
6
+ * - **Enforced** (`Content-Security-Policy`): a real, non-breaking policy.
7
+ * It locks down everything the UI doesn't need — no remote scripts
8
+ * (`script-src 'self'`), no plugins (`object-src 'none'`), no `<base>`
9
+ * hijack (`base-uri 'self'`), no framing (`frame-ancestors 'none'`),
10
+ * and same-origin-only XHR via `connect-src 'self'`. It keeps
11
+ * `'unsafe-inline'` for `script-src` because the single-file UI uses
12
+ * ~200 inline event-handler attributes (`onclick=`, …) that a nonce
13
+ * cannot cover — a nonce in `script-src` would *disable* `'unsafe-inline'`
14
+ * in CSP3 and break every button. So the enforced policy is a genuine
15
+ * improvement over no CSP without regressing the UI.
16
+ *
17
+ * - **Report-Only** (`Content-Security-Policy-Report-Only`): the strict
18
+ * target policy — `script-src 'self' 'nonce-…'`, no `'unsafe-inline'`.
19
+ * The two legitimate inline `<script>` blocks carry the per-request
20
+ * nonce, so this policy flags ONLY the inline event-handler debt. It
21
+ * blocks nothing; it just reports, giving an actionable migration list
22
+ * (move the handlers to addEventListener) before a future slice can
23
+ * promote the strict policy to enforced.
24
+ *
25
+ * It is **opt-in** (`OMCP_CSP_STRICT_REPORT=true`): with ~200 inline
26
+ * handlers it would otherwise emit a `[Report Only]` console message
27
+ * per handler on every page load — noise an operator with devtools
28
+ * open shouldn't eat by default. Enable it when you're actively
29
+ * working the migration. The enforced policy + reporting endpoint are
30
+ * always on regardless.
31
+ *
32
+ * Both policies report to `/api/csp-violations` via the modern Reporting
33
+ * API (`Reporting-Endpoints` + `report-to`) and the legacy `report-uri`.
34
+ */
35
+ /** Placeholder substituted with the per-request nonce when serving the UI HTML. */
36
+ export declare const CSP_NONCE_PLACEHOLDER = "__CSP_NONCE__";
37
+ /** The named reporting group used in the Report-To / Reporting-Endpoints headers. */
38
+ export declare const CSP_REPORT_GROUP = "omcp-csp";
39
+ /** Where violation reports are POSTed. */
40
+ export declare const CSP_REPORT_PATH = "/api/csp-violations";
41
+ /** Fresh base64 nonce (128 bits). */
42
+ export declare function generateNonce(): string;
43
+ /** The enforced policy — non-breaking, keeps the UI working. */
44
+ export declare function enforcedCsp(): string;
45
+ /** The strict target policy, run in report-only mode against the nonce. */
46
+ export declare function reportOnlyCsp(nonce: string): string;
47
+ /** Whether the strict Report-Only policy is enabled. Default off — see
48
+ * the module header for why (console noise from ~200 inline handlers). */
49
+ export declare function cspStrictReportFromEnv(env?: NodeJS.ProcessEnv): boolean;
50
+ /** Value for the modern `Reporting-Endpoints` header. */
51
+ export declare function reportingEndpointsHeader(): string;
52
+ /** Value for the legacy `Report-To` header (Reporting API v0). */
53
+ export declare function reportToHeader(): string;
54
+ /**
55
+ * Normalise a posted CSP violation (either the legacy
56
+ * `application/csp-report` `{ "csp-report": {...} }` envelope or a modern
57
+ * Reporting-API `application/reports+json` array element) into a compact,
58
+ * log-safe summary. Returns null when the body isn't a recognisable report.
59
+ */
60
+ export declare function summariseViolation(body: unknown): {
61
+ directive: string;
62
+ blockedUri: string;
63
+ documentUri: string;
64
+ } | null;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Content-Security-Policy for the management-plane Web UI.
3
+ *
4
+ * Two policies ship together, by design:
5
+ *
6
+ * - **Enforced** (`Content-Security-Policy`): a real, non-breaking policy.
7
+ * It locks down everything the UI doesn't need — no remote scripts
8
+ * (`script-src 'self'`), no plugins (`object-src 'none'`), no `<base>`
9
+ * hijack (`base-uri 'self'`), no framing (`frame-ancestors 'none'`),
10
+ * and same-origin-only XHR via `connect-src 'self'`. It keeps
11
+ * `'unsafe-inline'` for `script-src` because the single-file UI uses
12
+ * ~200 inline event-handler attributes (`onclick=`, …) that a nonce
13
+ * cannot cover — a nonce in `script-src` would *disable* `'unsafe-inline'`
14
+ * in CSP3 and break every button. So the enforced policy is a genuine
15
+ * improvement over no CSP without regressing the UI.
16
+ *
17
+ * - **Report-Only** (`Content-Security-Policy-Report-Only`): the strict
18
+ * target policy — `script-src 'self' 'nonce-…'`, no `'unsafe-inline'`.
19
+ * The two legitimate inline `<script>` blocks carry the per-request
20
+ * nonce, so this policy flags ONLY the inline event-handler debt. It
21
+ * blocks nothing; it just reports, giving an actionable migration list
22
+ * (move the handlers to addEventListener) before a future slice can
23
+ * promote the strict policy to enforced.
24
+ *
25
+ * It is **opt-in** (`OMCP_CSP_STRICT_REPORT=true`): with ~200 inline
26
+ * handlers it would otherwise emit a `[Report Only]` console message
27
+ * per handler on every page load — noise an operator with devtools
28
+ * open shouldn't eat by default. Enable it when you're actively
29
+ * working the migration. The enforced policy + reporting endpoint are
30
+ * always on regardless.
31
+ *
32
+ * Both policies report to `/api/csp-violations` via the modern Reporting
33
+ * API (`Reporting-Endpoints` + `report-to`) and the legacy `report-uri`.
34
+ */
35
+ import { randomBytes } from "node:crypto";
36
+ /** Placeholder substituted with the per-request nonce when serving the UI HTML. */
37
+ export const CSP_NONCE_PLACEHOLDER = "__CSP_NONCE__";
38
+ /** The named reporting group used in the Report-To / Reporting-Endpoints headers. */
39
+ export const CSP_REPORT_GROUP = "omcp-csp";
40
+ /** Where violation reports are POSTed. */
41
+ export const CSP_REPORT_PATH = "/api/csp-violations";
42
+ /** Fresh base64 nonce (128 bits). */
43
+ export function generateNonce() {
44
+ return randomBytes(16).toString("base64");
45
+ }
46
+ /** The enforced policy — non-breaking, keeps the UI working. */
47
+ export function enforcedCsp() {
48
+ return [
49
+ "default-src 'self'",
50
+ "base-uri 'self'",
51
+ "object-src 'none'",
52
+ "frame-ancestors 'none'",
53
+ "form-action 'self'",
54
+ "script-src 'self' 'unsafe-inline'",
55
+ "style-src 'self' 'unsafe-inline'",
56
+ "img-src 'self' data:",
57
+ "font-src 'self' data:",
58
+ "connect-src 'self'",
59
+ `report-uri ${CSP_REPORT_PATH}`,
60
+ `report-to ${CSP_REPORT_GROUP}`,
61
+ ].join("; ");
62
+ }
63
+ /** The strict target policy, run in report-only mode against the nonce. */
64
+ export function reportOnlyCsp(nonce) {
65
+ return [
66
+ "default-src 'self'",
67
+ "base-uri 'self'",
68
+ "object-src 'none'",
69
+ "frame-ancestors 'none'",
70
+ "form-action 'self'",
71
+ `script-src 'self' 'nonce-${nonce}'`,
72
+ "style-src 'self' 'unsafe-inline'",
73
+ "img-src 'self' data:",
74
+ "font-src 'self' data:",
75
+ "connect-src 'self'",
76
+ `report-uri ${CSP_REPORT_PATH}`,
77
+ `report-to ${CSP_REPORT_GROUP}`,
78
+ ].join("; ");
79
+ }
80
+ /** Whether the strict Report-Only policy is enabled. Default off — see
81
+ * the module header for why (console noise from ~200 inline handlers). */
82
+ export function cspStrictReportFromEnv(env = process.env) {
83
+ const v = env.OMCP_CSP_STRICT_REPORT?.trim().toLowerCase();
84
+ return v === "1" || v === "true" || v === "yes";
85
+ }
86
+ /** Value for the modern `Reporting-Endpoints` header. */
87
+ export function reportingEndpointsHeader() {
88
+ return `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"`;
89
+ }
90
+ /** Value for the legacy `Report-To` header (Reporting API v0). */
91
+ export function reportToHeader() {
92
+ return JSON.stringify({
93
+ group: CSP_REPORT_GROUP,
94
+ max_age: 10886400,
95
+ endpoints: [{ url: CSP_REPORT_PATH }],
96
+ });
97
+ }
98
+ /**
99
+ * Normalise a posted CSP violation (either the legacy
100
+ * `application/csp-report` `{ "csp-report": {...} }` envelope or a modern
101
+ * Reporting-API `application/reports+json` array element) into a compact,
102
+ * log-safe summary. Returns null when the body isn't a recognisable report.
103
+ */
104
+ export function summariseViolation(body) {
105
+ if (!body || typeof body !== "object")
106
+ return null;
107
+ // Reporting API delivers an array of { type, body: {...} }.
108
+ if (Array.isArray(body)) {
109
+ for (const item of body) {
110
+ const s = summariseViolation(item);
111
+ if (s)
112
+ return s;
113
+ }
114
+ return null;
115
+ }
116
+ const o = body;
117
+ // Reporting-API single report: { type: "csp-violation", body: {...} }.
118
+ const report = (o["csp-report"] ?? o.body ?? o);
119
+ if (!report || typeof report !== "object")
120
+ return null;
121
+ const pick = (...keys) => {
122
+ for (const k of keys) {
123
+ const v = report[k];
124
+ if (typeof v === "string" && v)
125
+ return v.slice(0, 256);
126
+ }
127
+ return "";
128
+ };
129
+ const directive = pick("effective-directive", "effectiveDirective", "violated-directive", "violatedDirective");
130
+ const blockedUri = pick("blocked-uri", "blockedURL", "blockedURI");
131
+ const documentUri = pick("document-uri", "documentURL", "documentURI");
132
+ if (!directive && !blockedUri && !documentUri)
133
+ return null;
134
+ return { directive, blockedUri, documentUri };
135
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,97 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { generateNonce, enforcedCsp, reportOnlyCsp, reportingEndpointsHeader, reportToHeader, summariseViolation, cspStrictReportFromEnv, CSP_NONCE_PLACEHOLDER, CSP_REPORT_GROUP, CSP_REPORT_PATH, } from "./csp.js";
4
+ test("generateNonce returns a fresh base64 value each call", () => {
5
+ const a = generateNonce();
6
+ const b = generateNonce();
7
+ assert.notEqual(a, b);
8
+ assert.match(a, /^[A-Za-z0-9+/]+=*$/);
9
+ // 16 bytes → 24 base64 chars (with padding).
10
+ assert.ok(a.length >= 22);
11
+ });
12
+ test("enforced policy keeps the UI working but locks the rest down", () => {
13
+ const csp = enforcedCsp();
14
+ // Inline handlers survive: unsafe-inline present, NO nonce (which would disable it).
15
+ assert.match(csp, /script-src 'self' 'unsafe-inline'/);
16
+ assert.ok(!csp.includes("nonce-"), "enforced policy must not carry a nonce");
17
+ // Hard locks.
18
+ assert.match(csp, /object-src 'none'/);
19
+ assert.match(csp, /base-uri 'self'/);
20
+ assert.match(csp, /frame-ancestors 'none'/);
21
+ assert.match(csp, /default-src 'self'/);
22
+ assert.match(csp, /connect-src 'self'/);
23
+ // Reporting wired both ways.
24
+ assert.match(csp, new RegExp(`report-uri ${CSP_REPORT_PATH}`));
25
+ assert.match(csp, new RegExp(`report-to ${CSP_REPORT_GROUP}`));
26
+ });
27
+ test("report-only policy is strict and nonce-bound, no unsafe-inline on scripts", () => {
28
+ const nonce = generateNonce();
29
+ const csp = reportOnlyCsp(nonce);
30
+ assert.match(csp, new RegExp(`script-src 'self' 'nonce-${nonce.replace(/[+/]/g, "\\$&")}'`));
31
+ // Strict: the script directive must NOT allow unsafe-inline.
32
+ const scriptDirective = csp.split(";").find((d) => d.trim().startsWith("script-src"));
33
+ assert.ok(!scriptDirective.includes("unsafe-inline"));
34
+ assert.match(csp, /object-src 'none'/);
35
+ });
36
+ test("reporting headers name the same group + endpoint", () => {
37
+ assert.equal(reportingEndpointsHeader(), `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"`);
38
+ const parsed = JSON.parse(reportToHeader());
39
+ assert.equal(parsed.group, CSP_REPORT_GROUP);
40
+ assert.equal(parsed.endpoints[0].url, CSP_REPORT_PATH);
41
+ assert.ok(parsed.max_age > 0);
42
+ });
43
+ test("the nonce placeholder is a stable token", () => {
44
+ assert.equal(CSP_NONCE_PLACEHOLDER, "__CSP_NONCE__");
45
+ });
46
+ test("strict report-only is opt-in (default off)", () => {
47
+ assert.equal(cspStrictReportFromEnv({}), false);
48
+ assert.equal(cspStrictReportFromEnv({ OMCP_CSP_STRICT_REPORT: "true" }), true);
49
+ assert.equal(cspStrictReportFromEnv({ OMCP_CSP_STRICT_REPORT: "1" }), true);
50
+ assert.equal(cspStrictReportFromEnv({ OMCP_CSP_STRICT_REPORT: "no" }), false);
51
+ assert.equal(cspStrictReportFromEnv({ OMCP_CSP_STRICT_REPORT: "false" }), false);
52
+ });
53
+ test("summariseViolation parses the legacy csp-report envelope", () => {
54
+ const s = summariseViolation({
55
+ "csp-report": {
56
+ "effective-directive": "script-src-attr",
57
+ "blocked-uri": "inline",
58
+ "document-uri": "https://gw.example/",
59
+ "extra": "ignored",
60
+ },
61
+ });
62
+ assert.deepEqual(s, {
63
+ directive: "script-src-attr",
64
+ blockedUri: "inline",
65
+ documentUri: "https://gw.example/",
66
+ });
67
+ });
68
+ test("summariseViolation parses a modern Reporting-API array", () => {
69
+ const s = summariseViolation([
70
+ {
71
+ type: "csp-violation",
72
+ body: {
73
+ effectiveDirective: "script-src-elem",
74
+ blockedURL: "https://evil.example/x.js",
75
+ documentURL: "https://gw.example/",
76
+ },
77
+ },
78
+ ]);
79
+ assert.equal(s?.directive, "script-src-elem");
80
+ assert.equal(s?.blockedUri, "https://evil.example/x.js");
81
+ });
82
+ test("summariseViolation falls back to violated-directive", () => {
83
+ const s = summariseViolation({ "csp-report": { "violated-directive": "img-src", "blocked-uri": "data" } });
84
+ assert.equal(s?.directive, "img-src");
85
+ });
86
+ test("summariseViolation returns null for junk", () => {
87
+ assert.equal(summariseViolation(null), null);
88
+ assert.equal(summariseViolation("nope"), null);
89
+ assert.equal(summariseViolation({}), null);
90
+ assert.equal(summariseViolation({ random: "field" }), null);
91
+ });
92
+ test("summariseViolation truncates over-long fields", () => {
93
+ const long = "a".repeat(5000);
94
+ const s = summariseViolation({ "csp-report": { "blocked-uri": long } });
95
+ assert.ok(s);
96
+ assert.ok((s.blockedUri).length <= 256);
97
+ });