@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,99 @@
1
+ // Test-/Seed-Helper für managed-pages. Legt eine Page direkt über den
2
+ // Event-Store-Executor an — gleicher Pfad wie der echte set-Handler, aber
3
+ // ohne Access-Check. Default ifExists="skip".
4
+
5
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
6
+ import {
7
+ createEventStoreExecutor,
8
+ createTenantDb,
9
+ type DbConnection,
10
+ } from "@cosmicdrift/kumiko-framework/db";
11
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
+ import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
13
+ import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { type PageRow, pageEntity, pagesTable } from "./table";
15
+
16
+ const executor = createEventStoreExecutor(pagesTable, pageEntity, { entityName: "page" });
17
+
18
+ export type SeedPageOptions = {
19
+ readonly tenantId: TenantId;
20
+ readonly slug: string;
21
+ readonly lang: string;
22
+ readonly title: string;
23
+ readonly body?: string | null;
24
+ readonly description?: string | null;
25
+ readonly ogImage?: string | null;
26
+ readonly published?: boolean;
27
+ readonly by?: SessionUser;
28
+ readonly ifExists?: SeedIfExists;
29
+ };
30
+
31
+ export async function seedPage(db: DbConnection, opts: SeedPageOptions): Promise<{ id: string }> {
32
+ // Default-user muss user.tenantId === opts.tenantId haben (sonst landet
33
+ // der event-store-stream im falschen tenant-bucket → version_conflict
34
+ // bei späteren echten writes). TestUsers.systemAdmin ist testTenantId(1).
35
+ const by = opts.by ?? { ...TestUsers.systemAdmin, tenantId: opts.tenantId };
36
+ const tdb = createTenantDb(db, opts.tenantId, "system");
37
+
38
+ const existing = await fetchOne<PageRow>(db, pagesTable, {
39
+ tenantId: opts.tenantId,
40
+ slug: opts.slug,
41
+ lang: opts.lang,
42
+ });
43
+
44
+ const description = opts.description ?? null;
45
+ const ogImage = opts.ogImage ?? null;
46
+ const published = opts.published ?? false;
47
+
48
+ return runEventStoreSeed({
49
+ existing,
50
+ ifExists: opts.ifExists,
51
+ create: async () => {
52
+ const result = await executor.create(
53
+ {
54
+ slug: opts.slug,
55
+ lang: opts.lang,
56
+ title: opts.title,
57
+ body: opts.body ?? null,
58
+ description,
59
+ ogImage,
60
+ published,
61
+ tenantId: opts.tenantId,
62
+ },
63
+ by,
64
+ tdb,
65
+ );
66
+ if (!result.isSuccess) {
67
+ throw new Error(`seedPage create failed: ${JSON.stringify(result)}`);
68
+ }
69
+ // @cast-boundary db-row: executor.create result.data ist die inserted
70
+ // Drizzle-Row (Record<string, unknown>), projected nach RETURNING.
71
+ const data = result.data as Partial<PageRow>;
72
+ if (data.id === undefined) {
73
+ throw new Error("seedPage: executor.create did not return an id");
74
+ }
75
+ return { id: data.id };
76
+ },
77
+ update: async (row) => {
78
+ const result = await executor.update(
79
+ {
80
+ id: row.id,
81
+ version: row.version,
82
+ changes: {
83
+ title: opts.title,
84
+ body: opts.body ?? null,
85
+ description,
86
+ ogImage,
87
+ published,
88
+ },
89
+ },
90
+ by,
91
+ tdb,
92
+ );
93
+ if (!result.isSuccess) {
94
+ throw new Error(`seedPage update failed: ${JSON.stringify(result)}`);
95
+ }
96
+ return { id: row.id };
97
+ },
98
+ });
99
+ }
@@ -0,0 +1,58 @@
1
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ createBooleanField,
4
+ createEntity,
5
+ createTextField,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ // Page — vom Tenant editierbare, server-gerenderte Public-Page (Landing,
9
+ // About, custom). Pro (tenantId, slug, lang) genau eine Row. Body ist
10
+ // Markdown (gehärtet server-gerendert über page-render). `published` gated
11
+ // die Auslieferung an anonyme Besucher: Drafts → 404. description/ogImage
12
+ // für SEO + Social-Preview. SYSTEM_TENANT_ID für app-weite Pages, sonst
13
+ // Tenant-eigene Pages (Host → tenantId via resolveApexTenant am Render-Pfad).
14
+ export const pageEntity = createEntity({
15
+ table: "read_pages",
16
+ fields: {
17
+ slug: createTextField({ required: true, maxLength: 64, sortable: true, searchable: true }),
18
+ lang: createTextField({ required: true, maxLength: 8, sortable: true }),
19
+ title: createTextField({ required: true, maxLength: 200, searchable: true }),
20
+ // Body + description sind vom Tenant-Admin authored Business-Content
21
+ // (Markdown), keine User-Generated-PII. `multiline` → der entityEdit-
22
+ // Renderer gibt ein <textarea> aus (createLongTextField hat aktuell
23
+ // einen Render-Gap: edit.ts/render-field.tsx gaten nur type==="text").
24
+ // maxLengths spiegeln die Handler-Schemas (set.write + Convention-CRUD
25
+ // leiten ihre zod-Caps aus diesen Field-maxLengths ab).
26
+ body: createTextField({
27
+ multiline: { rows: 16 },
28
+ maxLength: 100_000,
29
+ allowPlaintext: "is-business-data",
30
+ }),
31
+ description: createTextField({ maxLength: 500, allowPlaintext: "is-business-data" }),
32
+ ogImage: createTextField({ maxLength: 2000 }),
33
+ published: createBooleanField({ default: false }),
34
+ },
35
+ indexes: [{ unique: true, columns: ["tenantId", "slug", "lang"], name: "read_pages_unique" }],
36
+ });
37
+
38
+ export const pagesTable = buildEntityTable("page", pageEntity);
39
+
40
+ // Concrete Row-Type — single-source für die benannten Werte (statt
41
+ // `row["x"] as Y`-Casts in Handlern). entity.fields + Standard-Spalten
42
+ // (id, version, tenantId, createdAt, updatedAt, createdBy, updatedBy).
43
+ export type PageRow = {
44
+ readonly id: string;
45
+ readonly version: number;
46
+ readonly tenantId: string;
47
+ readonly slug: string;
48
+ readonly lang: string;
49
+ readonly title: string;
50
+ readonly body: string | null;
51
+ readonly description: string | null;
52
+ readonly ogImage: string | null;
53
+ readonly published: boolean;
54
+ readonly createdAt: Date;
55
+ readonly updatedAt: Date;
56
+ readonly createdBy: string;
57
+ readonly updatedBy: string;
58
+ };
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type BrandingTokens,
4
+ brandingHeaderHtml,
5
+ brandingStyleBlock,
6
+ EMPTY_BRANDING,
7
+ } from "../branding";
8
+
9
+ const tokens = (over: Partial<BrandingTokens>): BrandingTokens => ({ ...EMPTY_BRANDING, ...over });
10
+
11
+ // The branding tokens are RAW tenant input (title/description only length-capped
12
+ // at write). These emitters are the safe boundary a custom wrapLayout must use —
13
+ // prove they escape/re-validate, since interpolating branding.title directly
14
+ // would be stored XSS on the anonymous public page.
15
+ describe("brandingHeaderHtml — escapes untrusted tokens", () => {
16
+ test("a <script> in the title is HTML-escaped, not emitted live", () => {
17
+ const html = brandingHeaderHtml(tokens({ title: "<script>alert(1)</script>" }));
18
+ expect(html).not.toContain("<script>");
19
+ expect(html).toContain("&lt;script&gt;");
20
+ });
21
+
22
+ test("tag/quote syntax in the title can't break out of the logo alt", () => {
23
+ const html = brandingHeaderHtml(
24
+ tokens({ logoUrl: "https://cdn.test/l.png", title: '"><img src=x onerror=alert(1)>' }),
25
+ );
26
+ // the title's angle brackets are escaped → the injected tag is inert text
27
+ // in the alt, never a live element.
28
+ expect(html).not.toContain("<img src=x");
29
+ expect(html).toContain("&lt;img src=x");
30
+ expect(html).toContain('<img class="brand-logo"'); // the real logo survives
31
+ });
32
+
33
+ test("a non-https logo URL is dropped (no <img>)", () => {
34
+ expect(brandingHeaderHtml(tokens({ logoUrl: "javascript:alert(1)" }))).toBe("");
35
+ expect(brandingHeaderHtml(tokens({ logoUrl: "http://cdn.test/l.png" }))).toBe("");
36
+ });
37
+
38
+ test("a non-https siteUrl never becomes a home link", () => {
39
+ const html = brandingHeaderHtml(tokens({ title: "Acme", siteUrl: "javascript:alert(1)" }));
40
+ expect(html).toContain("Acme");
41
+ expect(html).not.toContain("<a href");
42
+ expect(html).not.toContain("javascript:");
43
+ });
44
+ });
45
+
46
+ describe("brandingStyleBlock — re-validates theme tokens", () => {
47
+ test("an invalid accent color is dropped (no --accent injected)", () => {
48
+ const css = brandingStyleBlock(tokens({ accentColor: "red;}</style><script>" }));
49
+ expect(css).not.toContain("--accent:red");
50
+ expect(css).not.toContain("</style><script>");
51
+ expect(css).not.toContain("--accent:");
52
+ });
53
+
54
+ test("a valid hex accent color is injected", () => {
55
+ expect(brandingStyleBlock(tokens({ accentColor: "#ff0066" }))).toContain("--accent:#ff0066");
56
+ });
57
+ });
@@ -0,0 +1,215 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { sanitizeTenantCss } from "../css-sanitize";
3
+
4
+ const SCOPE = "[data-tenant-content]";
5
+ const css = (input: string): string => sanitizeTenantCss(input, SCOPE);
6
+
7
+ // Bypass-first: the literal-string attacks (@import, url(javascript:)) are easy
8
+ // to pass; the parser-differential ones (CSS escapes, comma scope-escape,
9
+ // case/whitespace variants) are where a naive blocklist breaks. Lead with those.
10
+ describe("sanitizeTenantCss — bypass vectors", () => {
11
+ test("CSS hex escape \\75rl( can't re-form url() — backslash drops the decl", () => {
12
+ // \75 = 'u'. A literal-`url(` blocklist sees an unknown ident; the browser
13
+ // decodes it to url(. We reject any backslash outright instead.
14
+ const out = css(".x { background-color: \\75rl(javascript:alert(1)); }");
15
+ expect(out).toBe("");
16
+ expect(out).not.toContain("\\");
17
+ });
18
+
19
+ test("escaped @import (@imp\\6frt) is dropped, not executed", () => {
20
+ expect(css("@imp\\6frt url(evil);")).toBe("");
21
+ });
22
+
23
+ test("a lone backslash anywhere drops the whole rule", () => {
24
+ expect(css(".x { color: red\\9; }")).toBe("");
25
+ expect(css(".x\\3a hover { color: red; }")).toBe("");
26
+ });
27
+
28
+ test("backslash in one rule doesn't poison the others", () => {
29
+ const out = css(".ok { color: red; } .bad { width: \\31 0px; }");
30
+ expect(out).toContain("[data-tenant-content] .ok");
31
+ expect(out).toContain("color: red");
32
+ expect(out).not.toContain(".bad");
33
+ expect(out).not.toContain("\\");
34
+ });
35
+
36
+ test("comma selector list is scoped per-segment (no scope-escape)", () => {
37
+ const out = css(".a, .b { color: red; }");
38
+ expect(out).toContain("[data-tenant-content] .a");
39
+ expect(out).toContain("[data-tenant-content] .b");
40
+ // the second segment must NOT survive unscoped
41
+ expect(out).not.toMatch(/(^|,)\s*\.b\s*\{/);
42
+ });
43
+
44
+ test("commas inside :is()/:not() don't break top-level splitting", () => {
45
+ // comma inside the pseudo keeps it one segment; that segment is then
46
+ // rejected (comma not allowed in a single selector) — dropped, not split.
47
+ expect(css(":is(.a, .b) { color: red; }")).toBe("");
48
+ });
49
+
50
+ test("case/whitespace url variants are all caught", () => {
51
+ expect(css(".x { background-color: URL(x); }")).toBe("");
52
+ expect(css(".x { background-color: url (x); }")).toBe("");
53
+ expect(css(".x { background-color: Url( x ); }")).toBe("");
54
+ });
55
+
56
+ test("</style> breakout in trailing text or value never reaches output", () => {
57
+ const trailing = css(".x { color: red; } </style><script>alert(1)</script>");
58
+ expect(trailing).not.toContain("</style");
59
+ expect(trailing).not.toContain("<script");
60
+ expect(trailing).not.toContain("<");
61
+ const inValue = css(".x { color: red</style>; }");
62
+ expect(inValue).not.toContain("<");
63
+ });
64
+
65
+ test("comment-split tokens can't smuggle url(", () => {
66
+ expect(css(".x { background-color: u/**/rl(x); }")).toBe("");
67
+ expect(css(".x { background-color: ur/**/l(evil); }")).toBe("");
68
+ });
69
+ });
70
+
71
+ describe("sanitizeTenantCss — named attack classes", () => {
72
+ test("@import is dropped", () => {
73
+ expect(css("@import url('http://evil.test');")).toBe("");
74
+ expect(css("@import 'x';")).toBe("");
75
+ });
76
+
77
+ test("url(javascript:) / url(data:) are dropped, sibling decls survive", () => {
78
+ const out = css(".x { color: red; background-color: url(javascript:alert(1)); }");
79
+ expect(out).toContain("color: red");
80
+ expect(out).not.toContain("url");
81
+ expect(out).not.toContain("javascript");
82
+ });
83
+
84
+ test("expression() is dropped", () => {
85
+ expect(css(".x { width: expression(alert(1)); }")).toBe("");
86
+ });
87
+
88
+ test("var()/custom-props are dropped in v1", () => {
89
+ expect(css(".x { color: var(--accent); }")).toBe("");
90
+ });
91
+
92
+ test("position:fixed / sticky are denied (clickjacking)", () => {
93
+ expect(css(".x { position: fixed; top: 0; left: 0; }")).toBe("");
94
+ expect(css(".x { position: sticky; }")).toBe("");
95
+ });
96
+
97
+ test("position:absolute / relative are allowed (boxed by scope)", () => {
98
+ expect(css(".x { position: absolute; }")).toContain("position: absolute");
99
+ expect(css(".x { position: relative; }")).toContain("position: relative");
100
+ });
101
+
102
+ test("@media (and any at-rule with a nested block) is dropped whole", () => {
103
+ expect(css("@media screen { .x { color: red; } }")).toBe("");
104
+ expect(css("@font-face { font-family: evil; src: url(x); }")).toBe("");
105
+ });
106
+
107
+ test("content + pseudo-elements are dropped (text injection / defacement)", () => {
108
+ expect(css(".x::before { content: 'hacked'; }")).toBe("");
109
+ expect(css(".x { content: 'hi'; }")).toBe("");
110
+ });
111
+
112
+ test("attribute selectors are dropped (exfil-style selectors)", () => {
113
+ expect(css('.x[href^="http"] { color: red; }')).toBe("");
114
+ });
115
+
116
+ test("-moz-binding / behavior (non-allowlisted props) are dropped", () => {
117
+ expect(css(".x { -moz-binding: url(evil); }")).toBe("");
118
+ expect(css(".x { behavior: url(evil.htc); }")).toBe("");
119
+ });
120
+ });
121
+
122
+ describe("sanitizeTenantCss — structural robustness", () => {
123
+ test("unbalanced braces fail closed (drop the rest)", () => {
124
+ expect(css(".x { color: red;")).toBe("");
125
+ expect(css(".ok { color: red; } .bad { color: blue;")).toContain(".ok");
126
+ expect(css(".ok { color: red; } .bad { color: blue;")).not.toContain(".bad");
127
+ });
128
+
129
+ test("a rule with zero valid declarations is dropped", () => {
130
+ expect(css(".x { unknownprop: 1; }")).toBe("");
131
+ });
132
+ });
133
+
134
+ describe("sanitizeTenantCss — valid CSS passes, scoped", () => {
135
+ test("a plain rule is scoped and preserved", () => {
136
+ const out = css(".note { color: red; font-size: 1.2rem; }");
137
+ expect(out).toBe("[data-tenant-content] .note { color: red; font-size: 1.2rem }");
138
+ });
139
+
140
+ test("allowed functions (rgb/rgba/hsl/calc) pass", () => {
141
+ expect(css(".x { color: rgba(0,0,0,.5); }")).toContain("rgba(0,0,0,.5)");
142
+ expect(css(".x { width: calc(100% - 10px); }")).toContain("calc(100% - 10px)");
143
+ });
144
+
145
+ test("!important is preserved", () => {
146
+ expect(css(".x { color: red !important; }")).toContain("color: red !important");
147
+ });
148
+
149
+ test("multiple rules are each scoped", () => {
150
+ const out = css("h1 { margin: 1rem; } .box { padding: 8px; }");
151
+ expect(out).toContain("[data-tenant-content] h1");
152
+ expect(out).toContain("[data-tenant-content] .box");
153
+ });
154
+
155
+ test("universal + html/body are rendered inert via scoping, not bare", () => {
156
+ expect(css("* { color: red; }")).toBe("[data-tenant-content] * { color: red }");
157
+ // body is scoped (inert: no <body> inside the container), never emitted bare
158
+ const body = css("body { color: red; }");
159
+ expect(body).toBe("[data-tenant-content] body { color: red }");
160
+ expect(body).not.toMatch(/^body/);
161
+ });
162
+
163
+ test("leading comment is stripped, following rule survives", () => {
164
+ expect(css("/* theme */ .x { color: red; }")).toContain("color: red");
165
+ });
166
+
167
+ test("over-length input is dropped wholesale", () => {
168
+ const huge = `.x { color: red; }${" ".repeat(9000)}`;
169
+ expect(css(huge)).toBe("");
170
+ });
171
+ });
172
+
173
+ // Regressions for bypasses surfaced by the adversarial sanitizer workflow.
174
+ describe("sanitizeTenantCss — adversarial regressions", () => {
175
+ test("leading sibling/child combinator can't escape the scope to host chrome", () => {
176
+ expect(css("~ .brand-header { color: red; }")).toBe("");
177
+ expect(css("+ .brand-header { position: absolute; z-index: 999; }")).toBe("");
178
+ expect(css("~ * { position: absolute; top: 0; width: 100%; height: 100%; }")).toBe("");
179
+ expect(css("> .x { color: red; }")).toBe("");
180
+ // one escaping segment in a comma-list drops the whole rule
181
+ expect(css(".a, ~ .b { color: red; }")).toBe("");
182
+ });
183
+
184
+ test("internal combinators stay in-scope and survive (child works end-to-end)", () => {
185
+ expect(css(".a > .b { color: red; }")).toBe("[data-tenant-content] .a > .b { color: red }");
186
+ expect(css(".a ~ .b { color: red; }")).toBe("[data-tenant-content] .a ~ .b { color: red }");
187
+ expect(css(".nav:nth-child(2) { color: red; }")).toContain(
188
+ "[data-tenant-content] .nav:nth-child(2)",
189
+ );
190
+ });
191
+
192
+ test("single-colon pseudo-elements are rejected too (not just ::)", () => {
193
+ expect(css(":before { color: red; }")).toBe("");
194
+ expect(css(".x:before { color: red; }")).toBe("");
195
+ expect(css(".x:after { color: red; }")).toBe("");
196
+ expect(css(".x:first-line { color: red; }")).toBe("");
197
+ expect(css("a:hover:before { color: red; }")).toBe("");
198
+ });
199
+
200
+ test("url()/expression() inside a selector is rejected", () => {
201
+ expect(css(":not(url(x)) { color: red; }")).toBe("");
202
+ expect(css(":is(expression(1)) { color: red; }")).toBe("");
203
+ });
204
+
205
+ test("overlay attacks survive the sanitizer but are clipped by the container", () => {
206
+ // The sanitizer allows presentational props; geometric containment is the
207
+ // container's overflow:hidden (layout.ts), not the sanitizer. Here we prove
208
+ // the output is still SCOPED (so the clip applies) — not unscoped.
209
+ const out = css(
210
+ ".overlay { position: absolute; margin: -100vh; width: 200vw; height: 200vh; z-index: 9999; }",
211
+ );
212
+ expect(out).toContain("[data-tenant-content] .overlay");
213
+ expect(out).not.toMatch(/(^|\n)\s*\.overlay/); // never unscoped
214
+ });
215
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderSafeMarkdown } from "../markdown";
3
+
4
+ describe("renderSafeMarkdown — XSS-Härtung", () => {
5
+ test("block-level <script> wird als Text escaped, nicht durchgereicht", () => {
6
+ const html = renderSafeMarkdown("# Titel\n\n<script>alert(1)</script>");
7
+ expect(html).not.toContain("<script>");
8
+ expect(html).toContain("&lt;script&gt;");
9
+ });
10
+
11
+ test("inline raw HTML (<img onerror>) wird escaped", () => {
12
+ const html = renderSafeMarkdown('Text <img src=x onerror="alert(1)"> mehr');
13
+ expect(html).not.toContain("<img");
14
+ expect(html).toContain("&lt;img");
15
+ });
16
+
17
+ test("javascript:-Link-href wird neutralisiert", () => {
18
+ const html = renderSafeMarkdown("[klick](javascript:alert(1))");
19
+ expect(html.toLowerCase()).not.toContain("javascript:");
20
+ expect(html).toContain('href="#"');
21
+ });
22
+
23
+ test("data:-Image-src wird neutralisiert", () => {
24
+ const html = renderSafeMarkdown("![x](data:text/html;base64,PHNjcmlwdD4=)");
25
+ expect(html.toLowerCase()).not.toContain("data:");
26
+ });
27
+
28
+ test("https/mailto/relative Hrefs bleiben erhalten", () => {
29
+ expect(renderSafeMarkdown("[a](https://example.com)")).toContain('href="https://example.com"');
30
+ expect(renderSafeMarkdown("[b](mailto:x@y.de)")).toContain('href="mailto:x@y.de"');
31
+ expect(renderSafeMarkdown("[c](/impressum)")).toContain('href="/impressum"');
32
+ });
33
+
34
+ test("normale Markdown-Struktur bleibt intakt", () => {
35
+ const html = renderSafeMarkdown("# H1\n\n**fett** und `code`\n\n- a\n- b");
36
+ expect(html).toContain("<h1");
37
+ expect(html).toContain("<strong>fett</strong>");
38
+ expect(html).toContain("<code>code</code>");
39
+ expect(html).toContain("<li>a</li>");
40
+ });
41
+ });
@@ -0,0 +1,99 @@
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
+
3
+ // Tenant-branding tokens resolved at render time (managed-pages reads them
4
+ // from config — see managed-pages/branding.ts). Empty string = "unset, use
5
+ // the base-layout default" (mirrors the publicstatus convention). Every value
6
+ // is tenant-supplied + untrusted, so it is re-validated/escaped HERE before it
7
+ // touches HTML/CSS output — independent of the write-time config `pattern`
8
+ // gate. Defense-in-depth: a value could have been seeded or migrated around
9
+ // the write path, so the render path never trusts the stored string.
10
+ export type BrandingTokens = {
11
+ readonly title: string;
12
+ readonly description: string;
13
+ readonly siteUrl: string;
14
+ readonly accentColor: string;
15
+ readonly logoUrl: string;
16
+ readonly layoutPreset: string;
17
+ // RAW, untrusted tenant CSS — "" unless the custom-css capability is gated on
18
+ // (managed-pages allowCustomCss + the per-tenant toggle). It is NOT pre-
19
+ // sanitized here: the layer that emits it MUST run it through
20
+ // sanitizeTenantCss(value, scopeSelector) with its own scope (the default
21
+ // wrapInLayout does). Scoping is a render concern, so sanitization lives at
22
+ // emit, not in transport.
23
+ readonly customCss: string;
24
+ };
25
+
26
+ export const EMPTY_BRANDING: BrandingTokens = {
27
+ title: "",
28
+ description: "",
29
+ siteUrl: "",
30
+ accentColor: "",
31
+ logoUrl: "",
32
+ layoutPreset: "",
33
+ customCss: "",
34
+ };
35
+
36
+ // CSS hex color: #rgb | #rrggbb | #rrggbbaa, anchored. Contains no `;`/`}`/`<`/
37
+ // whitespace, so a value passing this can never break out of `:root{--x:V}`
38
+ // or the surrounding <style>…</style>. Linear → ReDoS-safe on untrusted input.
39
+ const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
40
+ // https URL, length-bounded, no whitespace/quote/angle chars. Anchored +
41
+ // linear (no backtracking).
42
+ const HTTPS_URL = /^https:\/\/[^\s"'<>]{1,2000}$/;
43
+
44
+ export function isSafeHexColor(value: string): boolean {
45
+ return HEX_COLOR.test(value);
46
+ }
47
+
48
+ export function isSafeHttpsUrl(value: string): boolean {
49
+ return HTTPS_URL.test(value);
50
+ }
51
+
52
+ // Layout preset → body max-width. Unknown/empty → default. Keeps the
53
+ // `branding-layout-preset` config key live (read at render, not a declared-
54
+ // but-unread key).
55
+ const LAYOUT_MAX_WIDTH: Record<string, string> = {
56
+ minimal: "640px",
57
+ centered: "720px",
58
+ wide: "1100px",
59
+ };
60
+
61
+ export function layoutMaxWidth(preset: string): string {
62
+ return LAYOUT_MAX_WIDTH[preset] ?? "720px";
63
+ }
64
+
65
+ // Scoped CSS-variable block, injected after the base <style> so its :root
66
+ // declarations override the defaults. Only emits a var whose source value
67
+ // passes re-validation — an invalid accent color is dropped (base CSS keeps
68
+ // its var() fallback), never injected. The id is stable so an app's custom
69
+ // layout can target/override it.
70
+ export function brandingStyleBlock(tokens: BrandingTokens): string {
71
+ const decls: string[] = [];
72
+ if (isSafeHexColor(tokens.accentColor)) {
73
+ decls.push(`--accent:${tokens.accentColor}`);
74
+ }
75
+ decls.push(`--page-max-width:${layoutMaxWidth(tokens.layoutPreset)}`);
76
+ return `<style id="tenant-theme">:root{${decls.join(";")}}</style>`;
77
+ }
78
+
79
+ // Optional branded page header (logo + title). The logo is only emitted for a
80
+ // re-validated https URL, escaped as an HTML attribute; an invalid/non-https
81
+ // URL is dropped. A re-validated https siteUrl turns the header into a
82
+ // home-link. Returns "" when there is nothing to show.
83
+ export function brandingHeaderHtml(tokens: BrandingTokens): string {
84
+ const parts: string[] = [];
85
+ if (isSafeHttpsUrl(tokens.logoUrl)) {
86
+ const alt = tokens.title ? escapeHtmlAttr(tokens.title) : "logo";
87
+ parts.push(`<img class="brand-logo" src="${escapeHtmlAttr(tokens.logoUrl)}" alt="${alt}">`);
88
+ }
89
+ if (tokens.title) {
90
+ parts.push(`<span class="brand-title">${escapeHtml(tokens.title)}</span>`);
91
+ }
92
+ if (parts.length === 0) return "";
93
+
94
+ const inner = parts.join("\n");
95
+ if (isSafeHttpsUrl(tokens.siteUrl)) {
96
+ return `<header class="brand-header"><a href="${escapeHtmlAttr(tokens.siteUrl)}">${inner}</a></header>`;
97
+ }
98
+ return `<header class="brand-header">${inner}</header>`;
99
+ }