@pramen/cms 0.0.17 → 0.0.19

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 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: input.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 block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: d.fields ?? {} });
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: input.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: input.overrides ?? null,
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 (input.fields !== undefined)
993
- patch.fields = input.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.17",
3
+ "version": "0.0.19",
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.17"
44
+ "@pramen/server": "0.0.19",
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: input.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 block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: d.fields ?? {} });
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: input.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: input.overrides ?? null,
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 (input.fields !== undefined) patch.fields = input.fields;
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
  }, {