@pramen/cms 0.0.17 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.js +51 -6
- package/package.json +3 -2
- package/src/index.ts +50 -5
package/dist/index.d.ts
CHANGED
|
@@ -460,6 +460,9 @@ export interface ValidateOpts {
|
|
|
460
460
|
requireRequired?: boolean;
|
|
461
461
|
}
|
|
462
462
|
export declare function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path?: string, opts?: ValidateOpts): void;
|
|
463
|
+
/** Deep-sanitize the richtext fields in a values object against a field schema (recursing
|
|
464
|
+
* into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
|
|
465
|
+
export declare function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: Record<string, unknown>): Record<string, unknown>;
|
|
463
466
|
export interface RenderedBlock {
|
|
464
467
|
/** The placement id (cms_page_blocks) — stable per position; used for reorder/remove. */
|
|
465
468
|
id: string;
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// role("editor", [...cmsPolicies().editor]) ];
|
|
26
26
|
// const app = { schema, handlers, acl, tasks: { ...cmsTasks } };
|
|
27
27
|
import { Entity, query, mutation, primaryKey, generated, notNull, unique, indexed, defaultTo, expr, policy, allow, BadRequest, Forbidden, PramenError, } from "@pramen/server";
|
|
28
|
+
import { filterXSS } from "xss";
|
|
28
29
|
/** Declare a typed block type. Pass `fields as const` to preserve the literals so
|
|
29
30
|
* `BlockFieldsOf<typeof def>` infers the field shape:
|
|
30
31
|
*
|
|
@@ -283,6 +284,43 @@ export function validateFields(schema, values, path = "", opts = {}) {
|
|
|
283
284
|
}
|
|
284
285
|
}
|
|
285
286
|
}
|
|
287
|
+
// --- rich-text sanitization (server-side — the real XSS boundary) -------------
|
|
288
|
+
//
|
|
289
|
+
// richtext fields are HTML the site renders with set:html, so they MUST be sanitized
|
|
290
|
+
// before persistence. Client-side scrubbing is not a boundary — a caller can POST any
|
|
291
|
+
// value straight to these handlers. We sanitize on write against a strict tag/attribute
|
|
292
|
+
// allow-list with js-xss (`xss`), which is SYNCHRONOUS and pure-JS — this matters because
|
|
293
|
+
// sanitize runs inside the DO's storage.transaction(), where async stream I/O (e.g.
|
|
294
|
+
// HTMLRewriter) deadlocks. js-xss drops disallowed tags/attributes and blanks
|
|
295
|
+
// javascript:/data: URLs in href/src by default.
|
|
296
|
+
const RT_WHITELIST = {
|
|
297
|
+
p: [], br: [], hr: [], blockquote: [], pre: [], code: [],
|
|
298
|
+
strong: [], b: [], em: [], i: [], u: [], s: [], strike: [], del: [], ins: [], mark: [], sub: [], sup: [],
|
|
299
|
+
h2: [], h3: [], h4: [], ul: [], ol: [], li: [], a: ["href", "title"],
|
|
300
|
+
};
|
|
301
|
+
const RT_XSS_OPTS = { whiteList: RT_WHITELIST, stripIgnoreTag: true, stripIgnoreTagBody: ["script", "style"] };
|
|
302
|
+
/** Sanitize one richtext HTML string to the allow-list. Synchronous by design. */
|
|
303
|
+
function sanitizeRichText(html) {
|
|
304
|
+
return html ? filterXSS(html, RT_XSS_OPTS) : html;
|
|
305
|
+
}
|
|
306
|
+
/** Deep-sanitize the richtext fields in a values object against a field schema (recursing
|
|
307
|
+
* into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
|
|
308
|
+
export function sanitizeFields(schema, values) {
|
|
309
|
+
const defs = Array.isArray(schema) ? schema : [];
|
|
310
|
+
const out = { ...values };
|
|
311
|
+
for (const def of defs) {
|
|
312
|
+
const v = out[def.name];
|
|
313
|
+
if (v == null)
|
|
314
|
+
continue;
|
|
315
|
+
if (def.type === "richtext" && typeof v === "string")
|
|
316
|
+
out[def.name] = sanitizeRichText(v);
|
|
317
|
+
else if (def.type === "group" && typeof v === "object" && !Array.isArray(v))
|
|
318
|
+
out[def.name] = sanitizeFields(def.fields, v);
|
|
319
|
+
else if (def.type === "repeater" && Array.isArray(v))
|
|
320
|
+
out[def.name] = v.map((it) => (it && typeof it === "object" ? sanitizeFields(def.fields, it) : it));
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
286
324
|
/** The public serving path for a media blob (relative; the client resolves it against
|
|
287
325
|
* its base). Served by the Worker's public `GET /media/<key>` route. */
|
|
288
326
|
export function mediaPath(key) {
|
|
@@ -773,6 +811,7 @@ export function createCmsHandlers(opts = {}) {
|
|
|
773
811
|
if (!ct)
|
|
774
812
|
throw new BadRequest("unknown content type");
|
|
775
813
|
validateFields(ct.fieldsSchema, input.fields ?? {}, "page.fields", { requireRequired: false });
|
|
814
|
+
const cleanPageFields = await sanitizeFields(ct.fieldsSchema, input.fields ?? {});
|
|
776
815
|
const locale = input.locale ?? defaultLocale;
|
|
777
816
|
await assertSlugFree(db, input.slug, locale);
|
|
778
817
|
const page = await db.insert("cms_pages", {
|
|
@@ -780,7 +819,7 @@ export function createCmsHandlers(opts = {}) {
|
|
|
780
819
|
title: input.title,
|
|
781
820
|
slug: input.slug,
|
|
782
821
|
locale,
|
|
783
|
-
fields:
|
|
822
|
+
fields: cleanPageFields,
|
|
784
823
|
status: "draft",
|
|
785
824
|
});
|
|
786
825
|
const defaults = ct.defaultBlocks ?? [];
|
|
@@ -795,7 +834,8 @@ export function createCmsHandlers(opts = {}) {
|
|
|
795
834
|
throw new BadRequest(`unknown block type '${d.blockTypeSlug}'`);
|
|
796
835
|
await assertRegionAllows(db, page, d.region, d.blockTypeSlug);
|
|
797
836
|
validateFields(bts[0].fieldsSchema, d.fields ?? {}, "", { requireRequired: false });
|
|
798
|
-
const
|
|
837
|
+
const cleanDefault = await sanitizeFields(bts[0].fieldsSchema, d.fields ?? {});
|
|
838
|
+
const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: cleanDefault });
|
|
799
839
|
const position = await nextPosition(db, String(page.id), d.region);
|
|
800
840
|
await db.insert("cms_page_blocks", { pageId: page.id, blockId: block.id, region: d.region, position });
|
|
801
841
|
}
|
|
@@ -898,10 +938,11 @@ export function createCmsHandlers(opts = {}) {
|
|
|
898
938
|
const bt = await loadBlockTypeBySlug(db, input.blockTypeSlug);
|
|
899
939
|
await assertRegionAllows(db, page, input.region, input.blockTypeSlug);
|
|
900
940
|
validateFields(bt.fieldsSchema, input.fields ?? {}, "", { requireRequired: false });
|
|
941
|
+
const cleanFields = await sanitizeFields(bt.fieldsSchema, input.fields ?? {});
|
|
901
942
|
const block = await db.insert("cms_blocks", {
|
|
902
943
|
typeId: bt.id,
|
|
903
944
|
title: input.title ?? null,
|
|
904
|
-
fields:
|
|
945
|
+
fields: cleanFields,
|
|
905
946
|
isReusable: input.isReusable ?? false,
|
|
906
947
|
});
|
|
907
948
|
const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
|
|
@@ -942,8 +983,10 @@ export function createCmsHandlers(opts = {}) {
|
|
|
942
983
|
const bts = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
|
|
943
984
|
const slug = String(bts[0]?.slug ?? "");
|
|
944
985
|
await assertRegionAllows(db, page, input.region, slug);
|
|
986
|
+
let cleanOverrides = input.overrides ?? null;
|
|
945
987
|
if (input.overrides !== undefined) {
|
|
946
988
|
validateFields(bts[0]?.fieldsSchema, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false });
|
|
989
|
+
cleanOverrides = await sanitizeFields(bts[0]?.fieldsSchema, input.overrides);
|
|
947
990
|
}
|
|
948
991
|
const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
|
|
949
992
|
return db.insert("cms_page_blocks", {
|
|
@@ -952,7 +995,7 @@ export function createCmsHandlers(opts = {}) {
|
|
|
952
995
|
region: input.region,
|
|
953
996
|
position,
|
|
954
997
|
isShared: true,
|
|
955
|
-
overrides:
|
|
998
|
+
overrides: cleanOverrides,
|
|
956
999
|
});
|
|
957
1000
|
}, {
|
|
958
1001
|
...editor,
|
|
@@ -984,13 +1027,15 @@ export function createCmsHandlers(opts = {}) {
|
|
|
984
1027
|
const block = rows[0];
|
|
985
1028
|
if (!block)
|
|
986
1029
|
throw notFound("block");
|
|
1030
|
+
let cleanFields = input.fields;
|
|
987
1031
|
if (input.fields !== undefined) {
|
|
988
1032
|
const bt = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
|
|
989
1033
|
validateFields(bt[0]?.fieldsSchema, input.fields, "", { requireRequired: false });
|
|
1034
|
+
cleanFields = await sanitizeFields(bt[0]?.fieldsSchema, input.fields);
|
|
990
1035
|
}
|
|
991
1036
|
const patch = { updatedAt: nowStamp() };
|
|
992
|
-
if (
|
|
993
|
-
patch.fields =
|
|
1037
|
+
if (cleanFields !== undefined)
|
|
1038
|
+
patch.fields = cleanFields;
|
|
994
1039
|
if (input.title !== undefined)
|
|
995
1040
|
patch.title = input.title;
|
|
996
1041
|
return db.update("cms_blocks", input.blockId, patch);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/cms",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"description": "Optional block/page builder for pramen — Drupal-Paragraphs-style typed blocks in named regions, reusable blocks, scheduled publishing, built entirely from pramen primitives.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@pramen/server": "0.0.
|
|
44
|
+
"@pramen/server": "0.0.18",
|
|
45
|
+
"xss": "^1.0.15"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
48
|
"react": ">=18"
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
PramenError,
|
|
44
44
|
} from "@pramen/server";
|
|
45
45
|
import type { HandlerContext, Policy, FileRef } from "@pramen/server";
|
|
46
|
+
import { filterXSS } from "xss";
|
|
46
47
|
|
|
47
48
|
// --- field schema DSL (the block-editor field language) ---------------------
|
|
48
49
|
|
|
@@ -428,6 +429,43 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
|
|
|
428
429
|
}
|
|
429
430
|
}
|
|
430
431
|
|
|
432
|
+
// --- rich-text sanitization (server-side — the real XSS boundary) -------------
|
|
433
|
+
//
|
|
434
|
+
// richtext fields are HTML the site renders with set:html, so they MUST be sanitized
|
|
435
|
+
// before persistence. Client-side scrubbing is not a boundary — a caller can POST any
|
|
436
|
+
// value straight to these handlers. We sanitize on write against a strict tag/attribute
|
|
437
|
+
// allow-list with js-xss (`xss`), which is SYNCHRONOUS and pure-JS — this matters because
|
|
438
|
+
// sanitize runs inside the DO's storage.transaction(), where async stream I/O (e.g.
|
|
439
|
+
// HTMLRewriter) deadlocks. js-xss drops disallowed tags/attributes and blanks
|
|
440
|
+
// javascript:/data: URLs in href/src by default.
|
|
441
|
+
|
|
442
|
+
const RT_WHITELIST: Record<string, string[]> = {
|
|
443
|
+
p: [], br: [], hr: [], blockquote: [], pre: [], code: [],
|
|
444
|
+
strong: [], b: [], em: [], i: [], u: [], s: [], strike: [], del: [], ins: [], mark: [], sub: [], sup: [],
|
|
445
|
+
h2: [], h3: [], h4: [], ul: [], ol: [], li: [], a: ["href", "title"],
|
|
446
|
+
};
|
|
447
|
+
const RT_XSS_OPTS = { whiteList: RT_WHITELIST, stripIgnoreTag: true, stripIgnoreTagBody: ["script", "style"] as string[] };
|
|
448
|
+
|
|
449
|
+
/** Sanitize one richtext HTML string to the allow-list. Synchronous by design. */
|
|
450
|
+
function sanitizeRichText(html: string): string {
|
|
451
|
+
return html ? filterXSS(html, RT_XSS_OPTS) : html;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Deep-sanitize the richtext fields in a values object against a field schema (recursing
|
|
455
|
+
* into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
|
|
456
|
+
export function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: Record<string, unknown>): Record<string, unknown> {
|
|
457
|
+
const defs = Array.isArray(schema) ? schema : [];
|
|
458
|
+
const out: Record<string, unknown> = { ...values };
|
|
459
|
+
for (const def of defs) {
|
|
460
|
+
const v = out[def.name];
|
|
461
|
+
if (v == null) continue;
|
|
462
|
+
if (def.type === "richtext" && typeof v === "string") out[def.name] = sanitizeRichText(v);
|
|
463
|
+
else if (def.type === "group" && typeof v === "object" && !Array.isArray(v)) out[def.name] = sanitizeFields(def.fields, v as Record<string, unknown>);
|
|
464
|
+
else if (def.type === "repeater" && Array.isArray(v)) out[def.name] = v.map((it) => (it && typeof it === "object" ? sanitizeFields(def.fields, it as Record<string, unknown>) : it));
|
|
465
|
+
}
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
|
|
431
469
|
// --- assembled-page shape (the content-API result + revision snapshot) --------
|
|
432
470
|
|
|
433
471
|
export interface RenderedBlock {
|
|
@@ -1020,6 +1058,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1020
1058
|
const ct = ctRows[0];
|
|
1021
1059
|
if (!ct) throw new BadRequest("unknown content type");
|
|
1022
1060
|
validateFields(ct.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, "page.fields", { requireRequired: false });
|
|
1061
|
+
const cleanPageFields = await sanitizeFields(ct.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {});
|
|
1023
1062
|
const locale = input.locale ?? defaultLocale;
|
|
1024
1063
|
await assertSlugFree(db, input.slug, locale);
|
|
1025
1064
|
|
|
@@ -1028,7 +1067,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1028
1067
|
title: input.title,
|
|
1029
1068
|
slug: input.slug,
|
|
1030
1069
|
locale,
|
|
1031
|
-
fields:
|
|
1070
|
+
fields: cleanPageFields,
|
|
1032
1071
|
status: "draft",
|
|
1033
1072
|
});
|
|
1034
1073
|
|
|
@@ -1043,7 +1082,8 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1043
1082
|
if (!bts[0]) throw new BadRequest(`unknown block type '${d.blockTypeSlug}'`);
|
|
1044
1083
|
await assertRegionAllows(db, page, d.region, d.blockTypeSlug);
|
|
1045
1084
|
validateFields(bts[0].fieldsSchema as FieldDefinition[] | undefined, d.fields ?? {}, "", { requireRequired: false });
|
|
1046
|
-
const
|
|
1085
|
+
const cleanDefault = await sanitizeFields(bts[0].fieldsSchema as FieldDefinition[] | undefined, d.fields ?? {});
|
|
1086
|
+
const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: cleanDefault });
|
|
1047
1087
|
const position = await nextPosition(db, String(page.id), d.region);
|
|
1048
1088
|
await db.insert("cms_page_blocks", { pageId: page.id, blockId: block.id, region: d.region, position });
|
|
1049
1089
|
} catch (e) {
|
|
@@ -1140,11 +1180,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1140
1180
|
const bt = await loadBlockTypeBySlug(db, input.blockTypeSlug);
|
|
1141
1181
|
await assertRegionAllows(db, page, input.region, input.blockTypeSlug);
|
|
1142
1182
|
validateFields(bt.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, "", { requireRequired: false });
|
|
1183
|
+
const cleanFields = await sanitizeFields(bt.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {});
|
|
1143
1184
|
|
|
1144
1185
|
const block = await db.insert("cms_blocks", {
|
|
1145
1186
|
typeId: bt.id,
|
|
1146
1187
|
title: input.title ?? null,
|
|
1147
|
-
fields:
|
|
1188
|
+
fields: cleanFields,
|
|
1148
1189
|
isReusable: input.isReusable ?? false,
|
|
1149
1190
|
});
|
|
1150
1191
|
const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
|
|
@@ -1184,8 +1225,10 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1184
1225
|
const bts = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
|
|
1185
1226
|
const slug = String(bts[0]?.slug ?? "");
|
|
1186
1227
|
await assertRegionAllows(db, page, input.region, slug);
|
|
1228
|
+
let cleanOverrides: Record<string, unknown> | null = input.overrides ?? null;
|
|
1187
1229
|
if (input.overrides !== undefined) {
|
|
1188
1230
|
validateFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false });
|
|
1231
|
+
cleanOverrides = await sanitizeFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, input.overrides);
|
|
1189
1232
|
}
|
|
1190
1233
|
const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
|
|
1191
1234
|
return db.insert("cms_page_blocks", {
|
|
@@ -1194,7 +1237,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1194
1237
|
region: input.region,
|
|
1195
1238
|
position,
|
|
1196
1239
|
isShared: true,
|
|
1197
|
-
overrides:
|
|
1240
|
+
overrides: cleanOverrides,
|
|
1198
1241
|
});
|
|
1199
1242
|
}, {
|
|
1200
1243
|
...editor,
|
|
@@ -1226,12 +1269,14 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
|
|
|
1226
1269
|
const rows = await db.find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
|
|
1227
1270
|
const block = rows[0];
|
|
1228
1271
|
if (!block) throw notFound("block");
|
|
1272
|
+
let cleanFields = input.fields;
|
|
1229
1273
|
if (input.fields !== undefined) {
|
|
1230
1274
|
const bt = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
|
|
1231
1275
|
validateFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields, "", { requireRequired: false });
|
|
1276
|
+
cleanFields = await sanitizeFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields);
|
|
1232
1277
|
}
|
|
1233
1278
|
const patch: Record<string, unknown> = { updatedAt: nowStamp() };
|
|
1234
|
-
if (
|
|
1279
|
+
if (cleanFields !== undefined) patch.fields = cleanFields;
|
|
1235
1280
|
if (input.title !== undefined) patch.title = input.title;
|
|
1236
1281
|
return db.update("cms_blocks", input.blockId, patch);
|
|
1237
1282
|
}, {
|