@u-krupaveho-kraba/form-schemas 0.4.0 → 0.5.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/htmlWhiteList.js +137 -0
- package/index.js +21 -2
- package/package.json +4 -1
- package/sanitizeHtml.js +57 -0
- package/schema/campaignItem.schema.json +2058 -147
- package/schemas/campaign.js +67 -14
- package/schemas/element.js +40 -0
- package/schemas/elementProperties.js +34 -10
- package/schemas/formDisplay.js +65 -6
- package/schemas/helpers.js +8 -0
- package/types/htmlWhiteList.d.ts +1 -0
- package/types/index.d.ts +1 -0
- package/types/sanitizeHtml.d.ts +1 -0
- package/types/schemas/campaign.d.ts +2885 -445
- package/types/schemas/element.d.ts +26 -6
- package/types/schemas/elementProperties.d.ts +5 -3
- package/types/schemas/form.d.ts +560 -24
- package/types/schemas/formDisplay.d.ts +426 -5
- package/types/schemas/helpers.d.ts +7 -0
- package/types/schemas/step.d.ts +4 -3
package/htmlWhiteList.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import xss from "xss";
|
|
2
|
+
// xss's CJS build attaches `whiteList` (and escapeAttrValue/safeAttrValue,
|
|
3
|
+
// see sanitizeHtml.ts) as extra properties on the same object it
|
|
4
|
+
// default-exports, via a runtime loop rather than a literal `exports.x =`
|
|
5
|
+
// statement -- Node's native ESM interop (cjs-module-lexer) only detects
|
|
6
|
+
// the latter statically, so `import { whiteList } from "xss"` type-checks
|
|
7
|
+
// (xss's own hand-written .d.ts declares it) but resolves to `undefined` at
|
|
8
|
+
// runtime under a plain Node ESM loader. Surfaced by testing/form-screenshot's
|
|
9
|
+
// Playwright suite, which runs test files directly under Node -- every
|
|
10
|
+
// Vite-bundled app sidesteps this via esbuild's more permissive interop.
|
|
11
|
+
// The default import is the one thing every runtime agrees on (it IS the
|
|
12
|
+
// whole module.exports object), so read the rest off of it instead.
|
|
13
|
+
const xssDefaultWhiteList = xss.whiteList;
|
|
14
|
+
// Extends xss's own default whitelist with extra per-tag attributes a
|
|
15
|
+
// hand-authored HTML snippet plausibly needs (links, images, tables, form
|
|
16
|
+
// controls) -- ported from landing-page-editor's getXssWhiteList.ts, which
|
|
17
|
+
// turned out to have nothing landing-page-specific in it. Deliberately
|
|
18
|
+
// drops the email/MSO-specific handling landing-page-editor and
|
|
19
|
+
// email-editor layer on top of this in their own editor components (Outlook
|
|
20
|
+
// conditional comments, unused/unclosed-tag repair) -- not applicable to a
|
|
21
|
+
// form, which never renders in an email client.
|
|
22
|
+
export function getHtmlWhiteList() {
|
|
23
|
+
const whiteList = JSON.parse(JSON.stringify(xssDefaultWhiteList));
|
|
24
|
+
whiteList["a"] = [
|
|
25
|
+
"download",
|
|
26
|
+
"href",
|
|
27
|
+
"hreflang",
|
|
28
|
+
"ping",
|
|
29
|
+
"referrerpolicy",
|
|
30
|
+
"rel",
|
|
31
|
+
"target",
|
|
32
|
+
"type",
|
|
33
|
+
"title",
|
|
34
|
+
"charset",
|
|
35
|
+
"coords",
|
|
36
|
+
"name",
|
|
37
|
+
"rev",
|
|
38
|
+
"shape",
|
|
39
|
+
];
|
|
40
|
+
whiteList["area"] = [
|
|
41
|
+
"alt",
|
|
42
|
+
"coords",
|
|
43
|
+
"download",
|
|
44
|
+
"href",
|
|
45
|
+
"ping",
|
|
46
|
+
"referrerpolicy",
|
|
47
|
+
"rel",
|
|
48
|
+
"shape",
|
|
49
|
+
"target",
|
|
50
|
+
];
|
|
51
|
+
// `formaction`/`formmethod`/`formtarget` are omitted deliberately: author
|
|
52
|
+
// HTML renders inside the widget's real <form>, so a pasted button
|
|
53
|
+
// carrying them re-points native submission at an arbitrary host. The
|
|
54
|
+
// widget's own submit button is type="button", which would make a pasted
|
|
55
|
+
// one the only native submit in the form. Nothing was lost by dropping
|
|
56
|
+
// them -- this list came from landing-page-editor, which never renders
|
|
57
|
+
// author HTML inside a form at all.
|
|
58
|
+
whiteList["button"] = [
|
|
59
|
+
"autofocus",
|
|
60
|
+
"disabled",
|
|
61
|
+
"form",
|
|
62
|
+
"formenctype",
|
|
63
|
+
"formnovalidate",
|
|
64
|
+
"name",
|
|
65
|
+
"type",
|
|
66
|
+
"value",
|
|
67
|
+
];
|
|
68
|
+
whiteList["img"] = [
|
|
69
|
+
"alt",
|
|
70
|
+
"crossorigin",
|
|
71
|
+
"decoding",
|
|
72
|
+
"height",
|
|
73
|
+
"loading",
|
|
74
|
+
"referrerpolicy",
|
|
75
|
+
"sizes",
|
|
76
|
+
"src",
|
|
77
|
+
"srcset",
|
|
78
|
+
"usemap",
|
|
79
|
+
"width",
|
|
80
|
+
"align",
|
|
81
|
+
"border",
|
|
82
|
+
"hspace",
|
|
83
|
+
"vspace",
|
|
84
|
+
];
|
|
85
|
+
whiteList["li"] = ["value"];
|
|
86
|
+
whiteList["ol"] = ["reversed", "start", "type"];
|
|
87
|
+
whiteList["style"] = [];
|
|
88
|
+
whiteList["table"] = [
|
|
89
|
+
"align",
|
|
90
|
+
"bgcolor",
|
|
91
|
+
"border",
|
|
92
|
+
"cellpadding",
|
|
93
|
+
"cellspacing",
|
|
94
|
+
"frame",
|
|
95
|
+
"rules",
|
|
96
|
+
"summary",
|
|
97
|
+
"width",
|
|
98
|
+
];
|
|
99
|
+
whiteList["tbody"] =
|
|
100
|
+
whiteList["tfoot"] =
|
|
101
|
+
whiteList["thead"] =
|
|
102
|
+
["align", "bgcolor", "char", "charoff", "valign"];
|
|
103
|
+
whiteList["td"] = whiteList["th"] = [
|
|
104
|
+
"abbr",
|
|
105
|
+
"align",
|
|
106
|
+
"bgcolor",
|
|
107
|
+
"char",
|
|
108
|
+
"charoff",
|
|
109
|
+
"colspan",
|
|
110
|
+
"headers",
|
|
111
|
+
"height",
|
|
112
|
+
"rowspan",
|
|
113
|
+
"scope",
|
|
114
|
+
"valign",
|
|
115
|
+
"width",
|
|
116
|
+
];
|
|
117
|
+
whiteList["tr"] = ["align", "bgcolor", "char", "charoff", "valign"];
|
|
118
|
+
// Attributes every tag gets, regardless of what's listed above -- without
|
|
119
|
+
// this, `style`/`class`/`id` are simply absent from xss's own default
|
|
120
|
+
// whitelist (e.g. `div: []` allows no attributes at all), so any inline
|
|
121
|
+
// styling or CSS hook on a hand-typed snippet would silently vanish.
|
|
122
|
+
const globalAttributes = [
|
|
123
|
+
"class",
|
|
124
|
+
"id",
|
|
125
|
+
"style",
|
|
126
|
+
"title",
|
|
127
|
+
"dir",
|
|
128
|
+
"lang",
|
|
129
|
+
"role",
|
|
130
|
+
"tabindex",
|
|
131
|
+
"hidden",
|
|
132
|
+
];
|
|
133
|
+
for (const tag in whiteList) {
|
|
134
|
+
whiteList[tag] = whiteList[tag].concat(globalAttributes);
|
|
135
|
+
}
|
|
136
|
+
return whiteList;
|
|
137
|
+
}
|
package/index.js
CHANGED
|
@@ -8,12 +8,31 @@ export * from "./schemas/element.js";
|
|
|
8
8
|
export * from "./schemas/step.js";
|
|
9
9
|
export * from "./schemas/form.js";
|
|
10
10
|
export * from "./schemas/campaign.js";
|
|
11
|
+
export { sanitizeHtml } from "./sanitizeHtml.js";
|
|
12
|
+
const JSON_TYPE_BY_ZOD_TYPE = {
|
|
13
|
+
string: "string",
|
|
14
|
+
number: "number",
|
|
15
|
+
boolean: "boolean",
|
|
16
|
+
};
|
|
17
|
+
const jsonSchemaParams = {
|
|
18
|
+
unrepresentable: "any",
|
|
19
|
+
override: ({ zodSchema, jsonSchema }) => {
|
|
20
|
+
const def = zodSchema._zod.def;
|
|
21
|
+
if (def.type !== "pipe")
|
|
22
|
+
return;
|
|
23
|
+
const zodType = def.in?._zod?.def?.type;
|
|
24
|
+
if (zodType && zodType in JSON_TYPE_BY_ZOD_TYPE) {
|
|
25
|
+
jsonSchema.type =
|
|
26
|
+
JSON_TYPE_BY_ZOD_TYPE[zodType];
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
};
|
|
11
30
|
/**
|
|
12
31
|
* Serializes the full form type tree as standard JSON Schema —
|
|
13
32
|
* used to tell an LLM the exact shape of valid form JSON (issue 024).
|
|
14
33
|
*/
|
|
15
34
|
export function getFormJsonSchema() {
|
|
16
|
-
return z.toJSONSchema(FormSchema);
|
|
35
|
+
return z.toJSONSchema(FormSchema, jsonSchemaParams);
|
|
17
36
|
}
|
|
18
37
|
/**
|
|
19
38
|
* Serializes the CampaignItem contract as standard JSON Schema — the
|
|
@@ -24,5 +43,5 @@ export function getFormJsonSchema() {
|
|
|
24
43
|
* now the atomic authored/published unit.
|
|
25
44
|
*/
|
|
26
45
|
export function getCampaignItemJsonSchema() {
|
|
27
|
-
return z.toJSONSchema(CampaignItemSchema);
|
|
46
|
+
return z.toJSONSchema(CampaignItemSchema, jsonSchemaParams);
|
|
28
47
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@u-krupaveho-kraba/form-schemas",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Zod schemas for Topol form JSON (validation gate + AI generation)",
|
|
5
5
|
"author": "Topol.io",
|
|
6
6
|
"license": "ISC",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"publishConfig": {
|
|
24
24
|
"access": "public"
|
|
25
25
|
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"xss": "^1.0.15"
|
|
28
|
+
},
|
|
26
29
|
"peerDependencies": {
|
|
27
30
|
"zod": "^4.1.11"
|
|
28
31
|
}
|
package/sanitizeHtml.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import xss from "xss";
|
|
2
|
+
import { getHtmlWhiteList } from "./htmlWhiteList.js";
|
|
3
|
+
// See htmlWhiteList.ts's comment: escapeAttrValue and getDefaultCSSWhiteList
|
|
4
|
+
// are runtime-loop-assigned CJS properties, not statically-detectable named
|
|
5
|
+
// exports -- read them off the default import (the same object, guaranteed)
|
|
6
|
+
// rather than `import { escapeAttrValue } from "xss"` directly.
|
|
7
|
+
const { escapeAttrValue, getDefaultCSSWhiteList } = xss;
|
|
8
|
+
// Every CSS property xss already knows about, all allowed. Sourced from xss
|
|
9
|
+
// rather than importing `cssfilter` directly: xss owns that dependency, and
|
|
10
|
+
// reaching past it into an untyped transitive package is what forced an
|
|
11
|
+
// ambient `cssfilter.d.ts` shim into six separate form packages and apps,
|
|
12
|
+
// most of which never referenced cssfilter at all.
|
|
13
|
+
// Built from the keys rather than spread: xss types the returned whitelist as
|
|
14
|
+
// carrying a `process` member, which it does not at runtime (verified -- every
|
|
15
|
+
// entry is a CSS property name with a boolean value).
|
|
16
|
+
const cssWhiteList = Object.fromEntries(Object.keys(getDefaultCSSWhiteList()).map((property) => [property, true]));
|
|
17
|
+
function allowDataAttributes(_tag, name, value) {
|
|
18
|
+
if (name.startsWith("data-")) {
|
|
19
|
+
return `${name}="${escapeAttrValue(value)}"`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const whiteList = getHtmlWhiteList();
|
|
23
|
+
// The security control for every stored html/text property (ticket 169):
|
|
24
|
+
// applied as a schema .transform() in elementProperties.ts, so it runs on
|
|
25
|
+
// the READ path (migrateForm/migrateCampaignItem, both apps) rather than
|
|
26
|
+
// only when content is authored. Lives here, not in the editor, because
|
|
27
|
+
// the transform needs it at schema-definition time -- form-schemas has no
|
|
28
|
+
// dependency on form-shared (it's the other way around), so the sanitizer
|
|
29
|
+
// can't live in form-shared without a circular package dependency. It's
|
|
30
|
+
// re-exported from @topol/form-shared's public API so both apps can still
|
|
31
|
+
// reach it from the package the ticket named.
|
|
32
|
+
export function sanitizeHtml(html) {
|
|
33
|
+
return xss(html, {
|
|
34
|
+
allowCommentTag: true,
|
|
35
|
+
whiteList,
|
|
36
|
+
// NOT `stripIgnoreTag: true`. That removed disallowed tags outright, and
|
|
37
|
+
// since xss treats any `<`...`>` run as a tag it also deleted ordinary
|
|
38
|
+
// copy: "5 < 3 & 4 > 2" became "5 2", and "Hi <FirstName>" became "Hi ".
|
|
39
|
+
// Author text is destroyed silently, on load, for every form carrying a
|
|
40
|
+
// comparison or a placeholder.
|
|
41
|
+
//
|
|
42
|
+
// Escaping instead keeps that text (it becomes `<`/`>`), and the
|
|
43
|
+
// reason the option was set does not apply: it was chosen so a stray
|
|
44
|
+
// <script> would not survive as garbled on-page text, but <script> is
|
|
45
|
+
// removed entirely either way by stripIgnoreTagBody below -- tag and body
|
|
46
|
+
// both. What changes is that a disallowed *real* tag (<iframe>, <object>)
|
|
47
|
+
// now renders as visible escaped text rather than vanishing, which is the
|
|
48
|
+
// better failure: the author can see that something was rejected.
|
|
49
|
+
stripIgnoreTagBody: ["script"],
|
|
50
|
+
onIgnoreTagAttr: allowDataAttributes,
|
|
51
|
+
// xss applies this to `style` attributes through its own CSS filter, which
|
|
52
|
+
// is what the hand-written safeAttrValue used to do by calling cssfilter
|
|
53
|
+
// directly -- and that call needed a `@ts-expect-error`, because xss's own
|
|
54
|
+
// signature wants a cssFilter instance it was never given.
|
|
55
|
+
css: { whiteList: cssWhiteList },
|
|
56
|
+
});
|
|
57
|
+
}
|