@cosmicdrift/kumiko-bundled-features 0.50.0 → 0.51.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 (40) hide show
  1. package/package.json +8 -6
  2. package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
  3. package/src/config/__tests__/config.integration.test.ts +60 -0
  4. package/src/config/feature.ts +5 -2
  5. package/src/config/handlers/cascade.query.ts +4 -1
  6. package/src/config/handlers/readiness.query.ts +1 -0
  7. package/src/config/handlers/reset.write.ts +23 -2
  8. package/src/config/handlers/set.write.ts +36 -2
  9. package/src/config/handlers/values.query.ts +5 -1
  10. package/src/config/resolver.ts +93 -3
  11. package/src/config/write-helpers.ts +37 -0
  12. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
  13. package/src/jobs/feature.ts +13 -0
  14. package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
  15. package/src/legal-pages/README.md +16 -13
  16. package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
  17. package/src/legal-pages/feature.ts +9 -4
  18. package/src/legal-pages/markdown.ts +6 -56
  19. package/src/legal-pages/security-headers.ts +1 -0
  20. package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
  21. package/src/managed-pages/branding.ts +142 -0
  22. package/src/managed-pages/css-gate.ts +24 -0
  23. package/src/managed-pages/feature.ts +246 -0
  24. package/src/managed-pages/handlers/branding.query.ts +30 -0
  25. package/src/managed-pages/handlers/by-slug.query.ts +35 -0
  26. package/src/managed-pages/handlers/set.write.ts +113 -0
  27. package/src/managed-pages/index.ts +30 -0
  28. package/src/managed-pages/screens/branding-screen.ts +85 -0
  29. package/src/managed-pages/screens/page-screens.ts +82 -0
  30. package/src/managed-pages/seeding.ts +99 -0
  31. package/src/managed-pages/table.ts +58 -0
  32. package/src/page-render/__tests__/branding.test.ts +57 -0
  33. package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
  34. package/src/page-render/__tests__/markdown.test.ts +41 -0
  35. package/src/page-render/branding.ts +99 -0
  36. package/src/page-render/css-sanitize.ts +344 -0
  37. package/src/page-render/index.ts +13 -0
  38. package/src/page-render/layout.ts +100 -0
  39. package/src/page-render/markdown.ts +39 -0
  40. package/src/page-render/security-headers.ts +16 -0
@@ -0,0 +1,344 @@
1
+ // Allowlist-based CSS sanitizer for UNTRUSTED tenant-supplied CSS (managed-pages
2
+ // custom-css capability). Security model: allowlist-by-construction — the output
3
+ // is REBUILT from validated tokens (scope-prefixed selector + allowed
4
+ // `property: value` pairs), never a filtered passthrough of the input. A rule
5
+ // that doesn't parse cleanly into that shape is dropped whole (fail-closed).
6
+ // `url()`, `@import`, `expression()` and friends are closed BY CONSTRUCTION:
7
+ // they can never match an allowed value grammar, so we never depend on detecting
8
+ // their literal spelling — which CSS escape sequences like `\75rl(` would defeat.
9
+ //
10
+ // Scoping: every selector is prefixed with `scopeSelector`, so a tenant rule can
11
+ // only style elements INSIDE the page-content container — never `html`/`body`/
12
+ // `:root` (those become inert: no such element is a descendant of the scope) and
13
+ // never another tenant's content. A segment may NOT start with a combinator
14
+ // (`~ X`/`+ X`/`> X` would reach the scope's siblings/parent — the host chrome);
15
+ // internal combinators stay in-scope. The container element itself is unreachable
16
+ // (descendant combinator), so the host-emitted containment styles — including the
17
+ // `overflow` clip and `isolation` in layout.ts — can't be overridden by tenant CSS.
18
+ //
19
+ // Hard rejections (no presentational CSS needs them): any `\` (escape-sequence
20
+ // bypass), any `@`-rule (no `@media`/`@font-face`/`@import`), any function token
21
+ // outside {rgb,rgba,hsl,hsla,calc} (closes `url`/`expression`/`var`/`image-set`),
22
+ // any `[`/`]` (attribute selectors), `::` AND single-colon `:before`/`:after`/
23
+ // `:first-line`/`:first-letter` (pseudo-elements → content/defacement), `url(`/
24
+ // `expression(` in a selector, and `<`/`>`/`"`/`'`/`{`/`}`/`;`/`@` inside a value.
25
+ //
26
+ // Residual (documented, tier-gated): best-effort defense-in-depth for untrusted
27
+ // tenants. The surface is deliberately small — no at-rules, no `url()`, no
28
+ // `var()`/custom-props, no pseudo-elements, no quotes in values, no leading
29
+ // combinators. A scoped element can still RESTYLE/DEFACE its own page area
30
+ // (within the container's `overflow` clip) — that is the tenant's own content,
31
+ // not host chrome. Keep CSS-inject behind the operator/tier gate; this is not a
32
+ // hard isolation boundary (an iframe sandbox would be).
33
+
34
+ const MAX_CSS_LENGTH = 8000;
35
+
36
+ // Presentational properties only. `position` is intentionally NOT here — it is
37
+ // allowed with a value constraint (fixed/sticky enable viewport overlays /
38
+ // clickjacking), handled in sanitizeValue.
39
+ const ALLOWED_PROPERTIES: ReadonlySet<string> = new Set<string>([
40
+ "color",
41
+ "background-color",
42
+ "font-family",
43
+ "font-size",
44
+ "font-weight",
45
+ "font-style",
46
+ "font-variant",
47
+ "line-height",
48
+ "letter-spacing",
49
+ "word-spacing",
50
+ "text-align",
51
+ "text-decoration",
52
+ "text-transform",
53
+ "text-indent",
54
+ "text-shadow",
55
+ "white-space",
56
+ "margin",
57
+ "margin-top",
58
+ "margin-right",
59
+ "margin-bottom",
60
+ "margin-left",
61
+ "padding",
62
+ "padding-top",
63
+ "padding-right",
64
+ "padding-bottom",
65
+ "padding-left",
66
+ "border",
67
+ "border-top",
68
+ "border-right",
69
+ "border-bottom",
70
+ "border-left",
71
+ "border-color",
72
+ "border-width",
73
+ "border-style",
74
+ "border-radius",
75
+ "border-top-left-radius",
76
+ "border-top-right-radius",
77
+ "border-bottom-left-radius",
78
+ "border-bottom-right-radius",
79
+ "box-shadow",
80
+ "outline",
81
+ "outline-color",
82
+ "outline-width",
83
+ "outline-style",
84
+ "width",
85
+ "max-width",
86
+ "min-width",
87
+ "height",
88
+ "max-height",
89
+ "min-height",
90
+ "display",
91
+ "opacity",
92
+ "visibility",
93
+ "list-style",
94
+ "list-style-type",
95
+ "list-style-position",
96
+ "vertical-align",
97
+ "cursor",
98
+ "box-sizing",
99
+ "transition",
100
+ "transition-property",
101
+ "transition-duration",
102
+ "transition-timing-function",
103
+ "transition-delay",
104
+ "transform",
105
+ "transform-origin",
106
+ "z-index",
107
+ ]);
108
+
109
+ // `position` is allowed only with these values — `fixed`/`sticky` (and anything
110
+ // unknown) is dropped to deny viewport-pinned overlays / clickjacking.
111
+ const ALLOWED_POSITION_VALUES: ReadonlySet<string> = new Set<string>([
112
+ "static",
113
+ "relative",
114
+ "absolute",
115
+ ]);
116
+
117
+ const ALLOWED_FUNCTIONS: ReadonlySet<string> = new Set<string>([
118
+ "rgb",
119
+ "rgba",
120
+ "hsl",
121
+ "hsla",
122
+ "calc",
123
+ ]);
124
+
125
+ // Permitted value chars AFTER `!important` is split off. No `\` (escapes), no
126
+ // `<`/`>`/`{`/`}`/`@`/`;` (breakouts), no quotes (string sinks like `content`,
127
+ // quoted `url("…")`). `*` and `/` are kept for `calc()`.
128
+ const VALUE_CHARS = /^[a-zA-Z0-9 \t.,#%()/*+-]+$/;
129
+ const FUNCTION_NAME = /([a-zA-Z][a-zA-Z0-9-]*)\s*\(/g;
130
+ const IMPORTANT_SUFFIX = /\s*!\s*important\s*$/i;
131
+
132
+ // Permitted selector chars. Class/id/element idents, descendant/child/sibling
133
+ // combinators, single-colon pseudo-CLASS, universal, `()` for `:not()`/
134
+ // `:nth-child()`. No `[`/`]` (attribute selectors), no `,` (split earlier), no
135
+ // `\`/quotes/braces/`@`/angle brackets. `::` is rejected separately.
136
+ const SELECTOR_CHARS = /^[a-zA-Z0-9 \t.#:>+~*_()-]+$/;
137
+ // A segment may NOT start with a combinator: `[scope] ~ X` / `[scope] + X` reach
138
+ // the scope container's SIBLINGS (e.g. the host brand-header) and `[scope] > X`
139
+ // its direct children-from-outside — all escape "descendants only". Internal
140
+ // combinators (`.a > .b`, `.a ~ .b`) stay in-scope and are fine.
141
+ const LEADING_COMBINATOR = /^[>+~]/;
142
+ // Single-colon legacy pseudo-elements are pseudo-elements too — the `::` check
143
+ // alone misses them. Reject both syntaxes (no pseudo-elements at all).
144
+ const LEGACY_PSEUDO_ELEMENT = /:(?:before|after|first-line|first-letter)\b/i;
145
+ // `url()`/`expression()` in a SELECTOR (e.g. inside `:not()`) don't fetch/execute
146
+ // — selector context never loads resources — but reject them anyway so no such
147
+ // token ever reaches output (defense-in-depth, avoids engine quirks).
148
+ const SELECTOR_FUNCTION_SINK = /(?:url|expression)\s*\(/i;
149
+
150
+ type RawRule = { readonly prelude: string; readonly block: string };
151
+
152
+ function stripComments(css: string): string {
153
+ let out = "";
154
+ let i = 0;
155
+ const n = css.length;
156
+ while (i < n) {
157
+ if (css[i] === "/" && css[i + 1] === "*") {
158
+ const end = css.indexOf("*/", i + 2);
159
+ if (end === -1) break; // unterminated comment → drop the rest (fail-closed)
160
+ i = end + 2;
161
+ out += " "; // a space so `@im/**/port` can't re-form a single token
162
+ continue;
163
+ }
164
+ out += css[i];
165
+ i++;
166
+ }
167
+ return out;
168
+ }
169
+
170
+ // Brace-depth-aware rule extractor. At depth 0, everything up to `{` is the
171
+ // prelude; the matching `}` (tracking nested braces, e.g. an at-rule body)
172
+ // closes the block. An unbalanced trailing `{` drops the rest (fail-closed).
173
+ function extractRules(css: string): RawRule[] {
174
+ const rules: RawRule[] = [];
175
+ let prelude = "";
176
+ let i = 0;
177
+ const n = css.length;
178
+ while (i < n) {
179
+ const ch = css[i];
180
+ if (ch === "{") {
181
+ let depth = 1;
182
+ let block = "";
183
+ i++;
184
+ while (i < n && depth > 0) {
185
+ const c = css[i];
186
+ if (c === "{") depth++;
187
+ else if (c === "}") {
188
+ depth--;
189
+ if (depth === 0) {
190
+ i++;
191
+ break;
192
+ }
193
+ }
194
+ block += c;
195
+ i++;
196
+ }
197
+ if (depth > 0) return rules; // unbalanced → fail-closed
198
+ rules.push({ prelude, block });
199
+ prelude = "";
200
+ continue;
201
+ }
202
+ if (ch === "}") {
203
+ prelude = ""; // stray close brace → reset
204
+ i++;
205
+ continue;
206
+ }
207
+ prelude += ch;
208
+ i++;
209
+ }
210
+ return rules; // trailing prelude without a block is discarded
211
+ }
212
+
213
+ function splitTopLevel(str: string, delimiter: string): string[] {
214
+ const parts: string[] = [];
215
+ let depth = 0;
216
+ let buf = "";
217
+ for (const ch of str) {
218
+ if (ch === "(") depth++;
219
+ else if (ch === ")") depth = Math.max(0, depth - 1);
220
+ if (ch === delimiter && depth === 0) {
221
+ parts.push(buf);
222
+ buf = "";
223
+ } else {
224
+ buf += ch;
225
+ }
226
+ }
227
+ parts.push(buf);
228
+ return parts;
229
+ }
230
+
231
+ function parensBalanced(s: string): boolean {
232
+ let depth = 0;
233
+ for (const ch of s) {
234
+ if (ch === "(") depth++;
235
+ else if (ch === ")") {
236
+ depth--;
237
+ if (depth < 0) return false;
238
+ }
239
+ }
240
+ return depth === 0;
241
+ }
242
+
243
+ function sanitizeSelector(seg: string, scope: string): string | null {
244
+ const s = seg.trim();
245
+ if (s === "") return null;
246
+ if (s.includes("\\")) return null; // escape-sequence bypass
247
+ if (s.includes("::")) return null; // no pseudo-elements (content/defacement)
248
+ if (LEGACY_PSEUDO_ELEMENT.test(s)) return null; // single-colon pseudo-elements
249
+ if (LEADING_COMBINATOR.test(s)) return null; // scope-escape via leading >/+/~
250
+ if (SELECTOR_FUNCTION_SINK.test(s)) return null; // no url()/expression() in selectors
251
+ if (!SELECTOR_CHARS.test(s)) return null; // rejects [ ] , < > { } @ " '
252
+ if (!parensBalanced(s)) return null;
253
+ return `${scope} ${s}`;
254
+ }
255
+
256
+ function sanitizeValue(prop: string, rawValue: string): string | null {
257
+ let value = rawValue.trim();
258
+ if (value === "") return null;
259
+ if (value.includes("\\")) return null; // escape-sequence bypass
260
+
261
+ let important = "";
262
+ const imp = value.match(IMPORTANT_SUFFIX);
263
+ if (imp) {
264
+ important = " !important";
265
+ value = value.slice(0, value.length - imp[0].length).trim();
266
+ if (value === "") return null;
267
+ }
268
+
269
+ if (!VALUE_CHARS.test(value)) return null;
270
+ if (!parensBalanced(value)) return null;
271
+
272
+ // Every function token must be an allowed function — this is what closes
273
+ // url()/expression()/var()/image-set() without matching their literal names.
274
+ FUNCTION_NAME.lastIndex = 0;
275
+ let m: RegExpExecArray | null = FUNCTION_NAME.exec(value);
276
+ while (m !== null) {
277
+ const fnName = m[1];
278
+ if (fnName === undefined || !ALLOWED_FUNCTIONS.has(fnName.toLowerCase())) return null;
279
+ m = FUNCTION_NAME.exec(value);
280
+ }
281
+
282
+ if (prop === "position" && !ALLOWED_POSITION_VALUES.has(value.toLowerCase())) {
283
+ return null;
284
+ }
285
+ return value + important;
286
+ }
287
+
288
+ function sanitizeDeclarations(block: string): string {
289
+ const out: string[] = [];
290
+ for (const part of block.split(";")) {
291
+ const decl = part.trim();
292
+ if (decl === "") continue;
293
+ if (decl.includes("\\")) continue;
294
+ const colon = decl.indexOf(":");
295
+ if (colon <= 0) continue;
296
+ const prop = decl.slice(0, colon).trim().toLowerCase();
297
+ if (prop !== "position" && !ALLOWED_PROPERTIES.has(prop)) continue;
298
+ const value = sanitizeValue(prop, decl.slice(colon + 1));
299
+ if (value === null) continue;
300
+ out.push(`${prop}: ${value}`);
301
+ }
302
+ return out.join("; ");
303
+ }
304
+
305
+ function sanitizeRule(rule: RawRule, scope: string): string | null {
306
+ const prelude = rule.prelude.trim();
307
+ if (prelude === "") return null;
308
+ if (prelude.startsWith("@")) return null; // no at-rules
309
+ if (prelude.includes("\\")) return null; // escape-sequence bypass
310
+
311
+ const scoped: string[] = [];
312
+ for (const seg of splitTopLevel(prelude, ",")) {
313
+ const s = sanitizeSelector(seg, scope);
314
+ if (s === null) return null; // any bad selector segment → drop the rule
315
+ scoped.push(s);
316
+ }
317
+
318
+ const decls = sanitizeDeclarations(rule.block);
319
+ if (decls === "") return null;
320
+ return `${scoped.join(", ")} { ${decls} }`;
321
+ }
322
+
323
+ // Sanitize untrusted tenant CSS into a scoped, allowlisted stylesheet string
324
+ // safe to drop into `<style>${...}</style>`. `scopeSelector` (e.g.
325
+ // `[data-tenant-content]`) is prefixed onto every selector. Returns "" for
326
+ // empty/over-cap/fully-rejected input — the caller emits no `<style>` block then.
327
+ export function sanitizeTenantCss(css: string, scopeSelector: string): string {
328
+ if (typeof css !== "string") return "";
329
+ if (css.length === 0 || css.length > MAX_CSS_LENGTH) return "";
330
+
331
+ const decommented = stripComments(css);
332
+ const out: string[] = [];
333
+ for (const rule of extractRules(decommented)) {
334
+ const s = sanitizeRule(rule, scopeSelector);
335
+ if (s !== null) out.push(s);
336
+ }
337
+ const result = out.join("\n");
338
+
339
+ // Final breakout assert — reject any `<`, which is the only way to begin the
340
+ // `</style>` sequence that exits the RAWTEXT <style> element. `>` is left
341
+ // intact: it is a valid CSS child combinator and inert inside <style>.
342
+ if (result.includes("<")) return "";
343
+ return result;
344
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ type BrandingTokens,
3
+ brandingHeaderHtml,
4
+ brandingStyleBlock,
5
+ EMPTY_BRANDING,
6
+ isSafeHexColor,
7
+ isSafeHttpsUrl,
8
+ layoutMaxWidth,
9
+ } from "./branding";
10
+ export { sanitizeTenantCss } from "./css-sanitize";
11
+ export { TENANT_CONTENT_ATTR, tenantStyleBlock, wrapInLayout } from "./layout";
12
+ export { renderSafeMarkdown } from "./markdown";
13
+ export { securePageHeaders } from "./security-headers";
@@ -0,0 +1,100 @@
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
+ import { type BrandingTokens, brandingHeaderHtml, brandingStyleBlock } from "./branding";
3
+ import { sanitizeTenantCss } from "./css-sanitize";
4
+
5
+ // Attribute marking the content container. The page body lives in
6
+ // `<main data-tenant-content>`; tenant custom CSS is scoped to its descendants
7
+ // and host containment clips its paint to this box. A custom wrapLayout that
8
+ // enables CSS-inject MUST put this attribute on the element wrapping the body
9
+ // (e.g. `<main ${TENANT_CONTENT_ATTR}>`) and emit `tenantStyleBlock(branding.
10
+ // customCss)` in <head> — otherwise tenant rules attach to nothing and the
11
+ // containment is absent. Exported so the attr and the helper's internal scope
12
+ // can't drift.
13
+ export const TENANT_CONTENT_ATTR = "data-tenant-content";
14
+ const TENANT_SCOPE = `[${TENANT_CONTENT_ATTR}]`;
15
+ // Host-controlled containment, emitted together with (and only alongside)
16
+ // tenant CSS. position+isolation box a tenant `position:absolute`/z-index to
17
+ // the container; overflow:hidden clips tenant paint (negative margins,
18
+ // transform, huge shadows, absolute children) off the host chrome. Tenant
19
+ // rules are scoped descendants (`[data-tenant-content] X`) and can't match the
20
+ // bare container, so they can't override either rule. No tenant CSS → neither
21
+ // is emitted → plain/legal pages render unclipped (normal overflow for wide
22
+ // tables/<pre>).
23
+ const TENANT_CONTAINMENT = `${TENANT_SCOPE}{position:relative;isolation:isolate}`;
24
+ const TENANT_CLIP = `${TENANT_SCOPE}{overflow:hidden}`;
25
+
26
+ // Render the per-tenant custom-CSS <style> block — the single emission path for
27
+ // the default skeleton below AND any custom wrapLayout. Returns "" when the
28
+ // input is empty or fully rejected (no element). Otherwise one `<style
29
+ // data-tenant-css>` carrying host containment + clip + the allowlist-sanitized,
30
+ // scope-prefixed tenant rules. Bakes in the scope so a caller can't mis-scope
31
+ // and silently lose containment; position:fixed/sticky are dropped upstream by
32
+ // the sanitizer.
33
+ export function tenantStyleBlock(customCss: string): string {
34
+ const sanitized = sanitizeTenantCss(customCss, TENANT_SCOPE);
35
+ if (!sanitized) return "";
36
+ return `\n<style data-tenant-css>${TENANT_CONTAINMENT}\n${TENANT_CLIP}\n${sanitized}</style>`;
37
+ }
38
+
39
+ // Minimaler HTML5-Skeleton mit Inline-CSS — Default-`wrapLayout` für
40
+ // server-gerenderte Public-Pages, damit sie auch ohne App-Layout sauber
41
+ // aussehen. Apps die ihr eigenes Marketing-Layout (Header/Footer/Theme)
42
+ // um den Body legen wollen, übergeben ihre eigene Render-Function.
43
+ //
44
+ // Branding (optional): emittiert nach dem Base-`<style>` einen scoped
45
+ // `:root`-Override (Accent-Farbe, Layout-Preset → max-width) plus einen
46
+ // Logo-/Titel-Header. Alle Branding-Werte sind tenant-supplied + untrusted
47
+ // und werden in branding.ts re-validiert/escaped, bevor sie ins Markup gehen.
48
+ export function wrapInLayout(opts: {
49
+ title: string;
50
+ bodyHtml: string;
51
+ lang: string;
52
+ description?: string | null;
53
+ branding?: BrandingTokens;
54
+ }): string {
55
+ const themeStyle = opts.branding ? brandingStyleBlock(opts.branding) : "";
56
+ const header = opts.branding ? brandingHeaderHtml(opts.branding) : "";
57
+ // Untrusted per-tenant CSS — scoped, allowlist-sanitized and host-contained
58
+ // at the render boundary by tenantStyleBlock (same helper a custom wrapLayout
59
+ // calls, so containment can't drift between the two paths). Empty/rejected →
60
+ // no block, plain/legal pages keep normal overflow.
61
+ const tenantStyle = tenantStyleBlock(opts.branding?.customCss ?? "");
62
+ // Page description wins; the tenant's branding description is the site-wide
63
+ // fallback when a page omits its own (keeps branding-description a live key).
64
+ const description =
65
+ opts.description && opts.description.length > 0
66
+ ? opts.description
67
+ : (opts.branding?.description ?? "");
68
+ const metaDescription = description
69
+ ? `\n<meta name="description" content="${escapeHtmlAttr(description)}">`
70
+ : "";
71
+ return `<!doctype html>
72
+ <html lang="${escapeHtmlAttr(opts.lang)}">
73
+ <head>
74
+ <meta charset="utf-8">
75
+ <meta name="viewport" content="width=device-width, initial-scale=1">
76
+ <title>${escapeHtml(opts.title)}</title>${metaDescription}
77
+ <style>
78
+ :root { --accent: #0066cc; --page-max-width: 720px; }
79
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: var(--page-max-width);
80
+ margin: 2rem auto; padding: 0 1rem; line-height: 1.6; color: #222; }
81
+ h1, h2, h3 { line-height: 1.2; margin-top: 2rem; }
82
+ h1 { font-size: 1.8rem; } h2 { font-size: 1.4rem; } h3 { font-size: 1.15rem; }
83
+ a { color: var(--accent); }
84
+ code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
85
+ hr { border: 0; border-top: 1px solid #ddd; margin: 2rem 0; }
86
+ .brand-header { position: relative; z-index: 1; display: flex; align-items: center;
87
+ gap: 0.6rem; margin-bottom: 1.5rem; }
88
+ .brand-header a { display: flex; align-items: center; gap: 0.6rem; color: inherit; text-decoration: none; }
89
+ .brand-logo { height: 2rem; width: auto; }
90
+ .brand-title { font-weight: 600; font-size: 1.1rem; }
91
+ </style>${themeStyle}${tenantStyle}
92
+ </head>
93
+ <body>
94
+ ${header}
95
+ <main data-tenant-content>
96
+ ${opts.bodyHtml}
97
+ </main>
98
+ </body>
99
+ </html>`;
100
+ }
@@ -0,0 +1,39 @@
1
+ import { escapeHtml } from "@cosmicdrift/kumiko-headless";
2
+ import { Marked } from "marked";
3
+
4
+ // Geteilter, gehärteter Markdown→HTML-Kern für server-gerenderte Public-
5
+ // Pages (legal-pages, managed-pages). Annahme: untrusted Tenant-Authoren.
6
+ // Raw-HTML-Tokens werden als Text escaped (kein <script>/<img onerror>-
7
+ // Passthrough), und link/image-hrefs auf http(s)/mailto/relativ beschränkt
8
+ // (kein javascript:/data:). Markdown-Struktur (Headings, Listen, Links,
9
+ // Code) bleibt intakt — das neutralisiert die XSS-Vektoren ohne Sanitizer-
10
+ // Dependency. Defense-in-Depth ergänzt `securePageHeaders` (`script-src
11
+ // 'none'`). GFM aus, breaks aus — strukturierte Pages brauchen keine
12
+ // Tables/Strikethrough/Task-Lists.
13
+ const safeRenderer = new Marked({ gfm: false, breaks: false });
14
+ safeRenderer.use({
15
+ walkTokens(token) {
16
+ if ((token.type === "link" || token.type === "image") && !isSafeHref(token.href)) {
17
+ token.href = "#";
18
+ }
19
+ },
20
+ renderer: {
21
+ html({ text }) {
22
+ return escapeHtml(text);
23
+ },
24
+ },
25
+ });
26
+
27
+ // http(s)/mailto oder schema-los (relativ/anchor) erlaubt; javascript:, data:,
28
+ // vbscript: u.a. abgelehnt. Ein relativer href hat kein `scheme:`-Präfix.
29
+ function isSafeHref(href: string): boolean {
30
+ const trimmed = href.trim().toLowerCase();
31
+ if (!/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return true;
32
+ return /^(?:https?|mailto):/.test(trimmed);
33
+ }
34
+
35
+ export function renderSafeMarkdown(markdown: string): string {
36
+ // @cast-boundary marked.parse return-type ist `string | Promise<string>`;
37
+ // `{ async: false }` garantiert sync (string) — Cast nur API-Vertragsfix.
38
+ return safeRenderer.parse(markdown, { async: false }) as string;
39
+ }
@@ -0,0 +1,16 @@
1
+ // Security-Header für server-gerenderte Public-HTML-Pages (legal-pages,
2
+ // managed-pages). `script-src 'none'` ist Defense-in-Depth: selbst wenn
3
+ // HTML-Injection durchrutscht, läuft kein Script. Bewusst KEIN `default-src`
4
+ // → Styles/Images/Fonts bleiben unrestricted (rückwärtskompatibel zu
5
+ // Inline-<style>-Layouts wie publicstatus' renderLegalLayout).
6
+ // nosniff/SAMEORIGIN/Referrer-Policy sind universell sichere Defaults.
7
+ const PUBLIC_PAGE_SECURITY_HEADERS = {
8
+ "content-security-policy": "script-src 'none'; object-src 'none'; base-uri 'none'",
9
+ "x-content-type-options": "nosniff",
10
+ "x-frame-options": "SAMEORIGIN",
11
+ "referrer-policy": "strict-origin-when-cross-origin",
12
+ } as const;
13
+
14
+ export function securePageHeaders(extra: Record<string, string>): Record<string, string> {
15
+ return { ...PUBLIC_PAGE_SECURITY_HEADERS, ...extra };
16
+ }