@cosmicdrift/kumiko-bundled-features 0.50.0 → 0.52.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/package.json +8 -6
- package/src/config/__tests__/backing-secrets.integration.test.ts +188 -0
- package/src/config/__tests__/config.integration.test.ts +60 -0
- package/src/config/feature.ts +5 -2
- package/src/config/handlers/cascade.query.ts +4 -1
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/config/handlers/reset.write.ts +23 -2
- package/src/config/handlers/set.write.ts +36 -2
- package/src/config/handlers/values.query.ts +5 -1
- package/src/config/resolver.ts +93 -3
- package/src/config/write-helpers.ts +37 -0
- package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +162 -0
- package/src/jobs/feature.ts +13 -0
- package/src/jobs/handlers/projection-rebuild.job.ts +36 -0
- package/src/legal-pages/README.md +16 -13
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +15 -8
- package/src/legal-pages/feature.ts +9 -4
- package/src/legal-pages/markdown.ts +6 -56
- package/src/legal-pages/security-headers.ts +1 -0
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +536 -0
- package/src/managed-pages/branding.ts +142 -0
- package/src/managed-pages/css-gate.ts +24 -0
- package/src/managed-pages/feature.ts +246 -0
- package/src/managed-pages/handlers/branding.query.ts +30 -0
- package/src/managed-pages/handlers/by-slug.query.ts +35 -0
- package/src/managed-pages/handlers/set.write.ts +113 -0
- package/src/managed-pages/index.ts +30 -0
- package/src/managed-pages/screens/branding-screen.ts +85 -0
- package/src/managed-pages/screens/page-screens.ts +82 -0
- package/src/managed-pages/seeding.ts +99 -0
- package/src/managed-pages/table.ts +58 -0
- package/src/page-render/__tests__/branding.test.ts +57 -0
- package/src/page-render/__tests__/css-sanitize.test.ts +215 -0
- package/src/page-render/__tests__/markdown.test.ts +41 -0
- package/src/page-render/branding.ts +99 -0
- package/src/page-render/css-sanitize.ts +344 -0
- package/src/page-render/index.ts +13 -0
- package/src/page-render/layout.ts +100 -0
- package/src/page-render/markdown.ts +39 -0
- package/src/page-render/security-headers.ts +16 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +3 -2
- package/src/subscription-stripe/__tests__/runtime.test.ts +12 -10
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +24 -12
- package/src/subscription-stripe/constants.ts +6 -5
- package/src/subscription-stripe/feature.ts +69 -50
- package/src/subscription-stripe/runtime.ts +29 -14
|
@@ -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("<script>");
|
|
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("<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("<script>");
|
|
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("<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("");
|
|
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
|
+
}
|