@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.
- 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
|
@@ -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
|
+
}
|