@burger-editor/frozen-patty 4.0.0-alpha.3 → 4.0.0-alpha.4

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/set-value.js CHANGED
@@ -1,5 +1,5 @@
1
- import { fieldNameParser } from './field-name-parser.js';
2
- import { kebabCase } from './utils.js';
1
+ import { parseFields } from './parse-fields.js';
2
+ import { kebabCase, propInElement, replaceNode, sanitizeAttributeValue, sanitizeHtml, } from './utils.js';
3
3
  /**
4
4
  * Set value to an element
5
5
  *
@@ -11,58 +11,67 @@ import { kebabCase } from './utils.js';
11
11
  * @param datum A datum of value
12
12
  * @param attr Data attribute name for specifying the node that FrozenPatty treats as a field
13
13
  * @param filter
14
+ * @param xssSanitize Enable XSS protection
14
15
  */
15
- export function setValue(el, name, datum, attr = 'field', filter) {
16
+ export function setValue(el, name, datum, attr = 'field', filter, xssSanitize = true) {
16
17
  const rawValue = el.getAttribute(`data-${attr}`);
17
- const fieldList = rawValue?.split(/\s*,\s*/) ?? [];
18
- for (const field of fieldList) {
19
- const { fieldName, propName } = fieldNameParser(field);
20
- if (name !== fieldName) {
21
- continue;
22
- }
23
- if (filter) {
24
- datum = filter(datum);
25
- }
26
- // console.log({ name, field, fieldName, propName, datum, el: el.innerHTML });
27
- if (propName) {
28
- if (/^style\([a-z-]+\)$/i.test(propName)) {
29
- const cssPropertyName = propName.replace(/^style\(([a-z-]+)\)$/i, '$1');
30
- let cssValue;
31
- switch (cssPropertyName) {
32
- case 'background-image': {
33
- //
34
- // NGパターン
35
- // $changeDom.css(cssPropertyName, 'url("' + value + '")');
36
- //
37
- // cssメソッドを経由すると styleAPIを使用するので URLがホストを含めた絶対パスになる
38
- // デモサーバーから本番サーバーへの移行ができなくなってしまうので避ける
39
- // 単純な文字列を流し込む(setAttributeを利用)
40
- // urlはマルチバイト文字や空白記号を含まないはずであるがエスケープする
41
- const url = encodeURI(`${datum}`);
42
- cssValue = `url(${url})`;
43
- break;
44
- }
45
- //
46
- // TODO: 他にもvalueに単位が必要なケースなどに対応したい
47
- //
48
- default: {
49
- cssValue = `${datum}`;
50
- }
51
- }
52
- el.setAttribute('style', `${cssPropertyName}: ${cssValue}`);
53
- }
54
- else if (el instanceof HTMLElement) {
55
- // HTMLElement
56
- set(el, attr, propName, datum);
18
+ const fields = parseFields(rawValue ?? '');
19
+ const field = fields.find((field) => field.fieldName === name);
20
+ if (!field) {
21
+ return;
22
+ }
23
+ const { propName } = field;
24
+ if (filter) {
25
+ datum = filter(datum);
26
+ }
27
+ // console.log({ name, field, fieldName, propName, datum, el: el.innerHTML });
28
+ if (!propName) {
29
+ setContent(el, datum, undefined, xssSanitize);
30
+ return;
31
+ }
32
+ if (propName === 'style' && el instanceof HTMLElement) {
33
+ el.style.cssText = `${datum}`;
34
+ return;
35
+ }
36
+ if (/^style\([a-z-]+\)$/i.test(propName)) {
37
+ const cssPropertyName = propName.replace(/^style\(([a-z-]+)\)$/i, '$1');
38
+ let cssValue;
39
+ switch (cssPropertyName) {
40
+ case 'background-image': {
41
+ // Using CSS method would create absolute URLs with host
42
+ // This would prevent migration from demo to production server
43
+ // Using simple string insertion (setAttribute) instead
44
+ // URL may not contain multibyte or whitespace characters, but escape anyway
45
+ const url = encodeURI(`${datum}`);
46
+ cssValue = `url(${url})`;
47
+ break;
57
48
  }
58
- else {
59
- // SVGElement or more
60
- el.setAttribute(propName, `${datum}`);
49
+ //
50
+ // TODO: Handle other cases where values need units
51
+ //
52
+ default: {
53
+ cssValue = `${datum}`;
61
54
  }
62
- return;
63
55
  }
64
- setContent(el, datum);
56
+ el.setAttribute('style', `${cssPropertyName}: ${cssValue}`);
57
+ return;
58
+ }
59
+ if (el instanceof HTMLElement) {
60
+ // HTMLElement
61
+ set(el, attr, propName, datum, xssSanitize);
62
+ return;
63
+ }
64
+ // SVGElement or more
65
+ // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
66
+ // Check attribute value if XSS protection is enabled
67
+ if (xssSanitize) {
68
+ const safeValue = sanitizeAttributeValue(propName, `${datum}`);
69
+ if (safeValue !== null) {
70
+ el.setAttribute(propName, safeValue);
71
+ }
72
+ return;
65
73
  }
74
+ el.setAttribute(propName, `${datum}`);
66
75
  }
67
76
  /**
68
77
  *
@@ -70,41 +79,54 @@ export function setValue(el, name, datum, attr = 'field', filter) {
70
79
  * @param prefix
71
80
  * @param name
72
81
  * @param datum
82
+ * @param xssSanitize
73
83
  */
74
- function set(el, prefix, name, datum) {
84
+ function set(el, prefix, name, datum, xssSanitize = true) {
75
85
  if (datum == null) {
76
86
  el.removeAttribute(name);
77
87
  return;
78
88
  }
79
89
  switch (name) {
80
90
  case 'text': {
81
- setContent(el, datum, false);
91
+ setContent(el, datum, false, xssSanitize);
82
92
  return;
83
93
  }
84
94
  case 'html': {
85
- setContent(el, datum, true);
95
+ // Sanitize HTML if XSS protection is enabled
96
+ if (xssSanitize && typeof datum === 'string') {
97
+ setContent(el, sanitizeHtml(datum), true, xssSanitize);
98
+ }
99
+ else {
100
+ setContent(el, datum, true, xssSanitize);
101
+ }
86
102
  return;
87
103
  }
88
104
  case 'node': {
89
105
  if (typeof datum !== 'string') {
90
106
  return;
91
107
  }
92
- const nodeName = el.localName ?? el.nodeName.toLowerCase();
93
- if (nodeName === datum.toLowerCase()) {
94
- return;
95
- }
96
- const node = (el.ownerDocument ?? document).createElement(`${datum}`);
97
- for (const child of el.childNodes) {
98
- node.append(child.cloneNode(true));
99
- }
100
- for (const { name, value } of el.attributes) {
101
- node.setAttribute(name, value);
102
- }
103
- el.replaceWith(node);
108
+ replaceNode(el, datum, prefix, xssSanitize);
109
+ return;
110
+ }
111
+ }
112
+ // If XSS protection is enabled
113
+ if (xssSanitize) {
114
+ // Don't set event handler attributes (starting with 'on')
115
+ if (name.toLowerCase().startsWith('on')) {
104
116
  return;
105
117
  }
118
+ const beforeSanitize = datum;
119
+ datum = sanitizeAttributeValue(name, datum);
120
+ // If sanitization results in null value (except when original value was null), don't change the value
121
+ if (datum === null && beforeSanitize !== null) {
122
+ return;
123
+ }
124
+ }
125
+ if (datum == null) {
126
+ el.removeAttribute(name);
127
+ return;
106
128
  }
107
- if (!name.startsWith('data-') && !(name in el)) {
129
+ if (!name.startsWith('data-') && !propInElement(el, name)) {
108
130
  const dataAttr = `data-${prefix}-${kebabCase(name)}`;
109
131
  if (el.hasAttribute(dataAttr)) {
110
132
  el.setAttribute(dataAttr, `${datum}`);
@@ -676,8 +698,9 @@ function set(el, prefix, name, datum) {
676
698
  * @param el
677
699
  * @param datum
678
700
  * @param asHtml
701
+ * @param xssSanitize
679
702
  */
680
- export function setContent(el, datum, asHtml = true) {
703
+ export function setContent(el, datum, asHtml = true, xssSanitize = true) {
681
704
  if (el instanceof HTMLInputElement ||
682
705
  el instanceof HTMLSelectElement ||
683
706
  el instanceof HTMLTextAreaElement ||
@@ -695,9 +718,18 @@ export function setContent(el, datum, asHtml = true) {
695
718
  return;
696
719
  }
697
720
  if (asHtml) {
698
- el.innerHTML = datum == null ? '' : `${datum}`;
721
+ // Sanitize HTML if XSS protection is enabled
722
+ const htmlStr = datum == null ? '' : `${datum}`;
723
+ if (xssSanitize && typeof htmlStr === 'string') {
724
+ // sanitizeHtml will now replace dangerous elements instead of removing them
725
+ el.innerHTML = sanitizeHtml(htmlStr);
726
+ }
727
+ else {
728
+ el.innerHTML = htmlStr;
729
+ }
699
730
  return;
700
731
  }
732
+ // No need to sanitize textContent (automatically escaped)
701
733
  el.textContent = datum == null ? '' : `${datum}`;
702
734
  }
703
735
  /**
package/dist/set.d.ts CHANGED
@@ -5,5 +5,6 @@ import type { Filter, FrozenPattyData } from './types.js';
5
5
  * @param data
6
6
  * @param attr
7
7
  * @param filter
8
+ * @param xssSanitize Enable XSS protection
8
9
  */
9
- export default function (el: Element, data: FrozenPattyData, attr: string, filter?: Filter): Element;
10
+ export default function (el: Element, data: FrozenPattyData, attr: string, filter?: Filter, xssSanitize?: boolean): Element;
package/dist/set.js CHANGED
@@ -6,8 +6,9 @@ import { kebabCase } from './utils.js';
6
6
  * @param data
7
7
  * @param attr
8
8
  * @param filter
9
+ * @param xssSanitize Enable XSS protection
9
10
  */
10
- export default function (el, data, attr, filter) {
11
+ export default function (el, data, attr, filter, xssSanitize = true) {
11
12
  el = el.cloneNode(true);
12
13
  for (const dataKeyName in data) {
13
14
  if (dataKeyName in data) {
@@ -35,14 +36,14 @@ export default function (el, data, attr, filter) {
35
36
  let deleteNodeList = [];
36
37
  for (const [i, child] of [...newChildren].entries()) {
37
38
  if (datum[i] == null) {
38
- setValue(child, dataKeyName, '', attr, filter);
39
+ setValue(child, dataKeyName, '', attr, filter, xssSanitize);
39
40
  const oldChild = oldChildList[i];
40
41
  if (oldChild) {
41
42
  deleteNodeList.push(oldChild);
42
43
  }
43
44
  }
44
45
  else {
45
- setValue(child, dataKeyName, datum[i], attr, filter);
46
+ setValue(child, dataKeyName, datum[i], attr, filter, xssSanitize);
46
47
  deleteNodeList = [];
47
48
  }
48
49
  }
@@ -51,7 +52,7 @@ export default function (el, data, attr, filter) {
51
52
  }
52
53
  else {
53
54
  for (const [, targetEl] of [...targetList].entries()) {
54
- setValue(targetEl, dataKeyName, datum, attr, filter);
55
+ setValue(targetEl, dataKeyName, datum, attr, filter, xssSanitize);
55
56
  }
56
57
  }
57
58
  }
@@ -0,0 +1,14 @@
1
+ import type { FieldDefinition } from './types.js';
2
+ /**
3
+ * Combines multiple field definitions with comma separation
4
+ * @param fields Array of field definitions
5
+ * @returns Comma-separated field definition string
6
+ */
7
+ export declare function stringifyFields(fields: readonly FieldDefinition[]): string;
8
+ /**
9
+ * Generates a field definition string from field name and attribute name
10
+ * @param fieldName Field name
11
+ * @param propName Attribute name (optional)
12
+ * @returns Field definition string
13
+ */
14
+ export declare function stringifyField(fieldName: string, propName?: string): string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Combines multiple field definitions with comma separation
3
+ * @param fields Array of field definitions
4
+ * @returns Comma-separated field definition string
5
+ */
6
+ export function stringifyFields(fields) {
7
+ return fields
8
+ .map((field) => stringifyField(field.fieldName, field.propName))
9
+ .join(', ');
10
+ }
11
+ /**
12
+ * Generates a field definition string from field name and attribute name
13
+ * @param fieldName Field name
14
+ * @param propName Attribute name (optional)
15
+ * @returns Field definition string
16
+ */
17
+ export function stringifyField(fieldName, propName) {
18
+ if (!propName) {
19
+ return fieldName;
20
+ }
21
+ // Use shorthand notation when fieldName and propName are the same
22
+ if (fieldName === propName) {
23
+ return `:${propName}`;
24
+ }
25
+ return `${fieldName}:${propName}`;
26
+ }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  export interface FrozenPattyData {
2
2
  [filed: string]: PrimitiveData;
3
3
  }
4
+ export interface FrozenPattyFlattenData {
5
+ [field: string]: PrimitiveDatum;
6
+ }
7
+ export interface FieldDefinition {
8
+ fieldName: string;
9
+ propName?: string;
10
+ }
4
11
  export type PrimitiveDatum = string | number | boolean | null | undefined;
5
12
  export type PrimitiveData = PrimitiveDatum | PrimitiveDatum[];
6
13
  export type Filter = <T>(value: T) => T;
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { FrozenPattyData, PrimitiveDatum } from './types.js';
1
+ import type { FieldDefinition, FrozenPattyData, FrozenPattyFlattenData, PrimitiveDatum } from './types.js';
2
+ /**
3
+ * List of dangerous HTML elements to block for XSS protection
4
+ */
5
+ export declare const DANGEROUS_ELEMENTS: string[];
2
6
  /**
3
7
  *
4
8
  * @param kvs
@@ -16,3 +20,76 @@ export declare function kebabCase(str: string): string;
16
20
  * @param str
17
21
  */
18
22
  export declare function camelCase(str: string): string;
23
+ /**
24
+ *
25
+ * @param el
26
+ * @param nodeName
27
+ * @param attr
28
+ * @param xssSanitize
29
+ */
30
+ export declare function replaceNode(el: Element, nodeName: string, attr: string, xssSanitize?: boolean): Element;
31
+ /**
32
+ * Removes dangerous script elements from HTML string
33
+ * @param html HTML string
34
+ * @returns Sanitized HTML
35
+ */
36
+ export declare function sanitizeHtml(html: string): string;
37
+ /**
38
+ * Sanitizes attribute value
39
+ * @param name Attribute name
40
+ * @param value Attribute value
41
+ * @returns Safe attribute value, or null if dangerous
42
+ */
43
+ export declare function sanitizeAttributeValue<T extends PrimitiveDatum>(name: string, value: T): T | null;
44
+ /**
45
+ *
46
+ * @param data
47
+ * @param definedFields
48
+ */
49
+ export declare function maxLengthOf(data: FrozenPattyData, definedFields: string[]): number;
50
+ /**
51
+ *
52
+ * @param el
53
+ * @param attr
54
+ */
55
+ export declare function definedFields(el: Element, attr: string): string[];
56
+ /**
57
+ *
58
+ * @param el
59
+ * @param attr
60
+ */
61
+ export declare function getFields(el: Element, attr: string): readonly FieldDefinition[];
62
+ /**
63
+ *
64
+ * @param el
65
+ * @param attr
66
+ * @param dataKeyName
67
+ */
68
+ export declare function hasField(el: Element, attr: string, dataKeyName: string): boolean;
69
+ /**
70
+ *
71
+ * @param fields
72
+ * @param oldPropName
73
+ * @param newPropName
74
+ */
75
+ export declare function replaceProp(fields: readonly FieldDefinition[], oldPropName: string, newPropName: string): FieldDefinition[];
76
+ /**
77
+ *
78
+ * @param fields
79
+ * @param propName
80
+ */
81
+ export declare function removeProp(fields: readonly FieldDefinition[], propName: string): FieldDefinition[];
82
+ /**
83
+ * - 値が配列の場合は、index に対応する値を返す。
84
+ * - 値が配列でない場合は、そのまま返す。
85
+ * - ⚠️ 値が配列で、index に対応する値が undefined の場合は、配列の最初の値を返す。
86
+ * @param data
87
+ * @param index
88
+ */
89
+ export declare function flattenData(data: FrozenPattyData, index: number): FrozenPattyFlattenData;
90
+ /**
91
+ *
92
+ * @param el
93
+ * @param name
94
+ */
95
+ export declare function propInElement(el: Element, name: string): boolean;