@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.
- package/dist/analysis/history.d.ts +36 -2
- package/dist/analysis/history.js +60 -2
- package/dist/analysis/history.test.js +46 -0
- package/dist/auth/csrf.d.ts +6 -0
- package/dist/auth/csrf.js +4 -0
- package/dist/auth/csrf.test.js +22 -0
- package/dist/auth/lockout.d.ts +72 -0
- package/dist/auth/lockout.js +134 -0
- package/dist/auth/lockout.test.d.ts +1 -0
- package/dist/auth/lockout.test.js +133 -0
- package/dist/auth/middleware.d.ts +5 -0
- package/dist/auth/middleware.js +6 -1
- package/dist/auth/middleware.test.js +31 -0
- package/dist/auth/password-policy.d.ts +52 -0
- package/dist/auth/password-policy.js +125 -0
- package/dist/auth/password-policy.test.d.ts +1 -0
- package/dist/auth/password-policy.test.js +111 -0
- package/dist/auth/revocation.d.ts +93 -0
- package/dist/auth/revocation.js +193 -0
- package/dist/auth/revocation.test.d.ts +1 -0
- package/dist/auth/revocation.test.js +136 -0
- package/dist/auth/session.d.ts +7 -0
- package/dist/auth/session.js +6 -0
- package/dist/auth/session.test.js +21 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loki.d.ts +45 -1
- package/dist/connectors/loki.js +141 -8
- package/dist/connectors/loki.test.js +171 -1
- package/dist/index.js +217 -3
- package/dist/openapi.js +39 -0
- package/dist/openapi.test.js +1 -0
- package/dist/security/csp.d.ts +64 -0
- package/dist/security/csp.js +135 -0
- package/dist/security/csp.test.d.ts +1 -0
- package/dist/security/csp.test.js +97 -0
- package/dist/tools/query-logs.d.ts +40 -0
- package/dist/tools/query-logs.js +69 -3
- package/dist/tools/validation.d.ts +13 -0
- package/dist/tools/validation.js +74 -0
- package/dist/tools/validation.test.js +54 -1
- package/dist/types.d.ts +48 -0
- package/dist/ui/index.html +42 -15
- 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
|
-
|
|
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
|
-
|
|
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"],
|
package/dist/openapi.test.js
CHANGED
|
@@ -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
|
+
});
|