inertjs-vector 1.0.0-beta.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 +12 -0
- package/src/escaper.js +211 -0
- package/src/index.js +102 -0
- package/src/resolve.js +48 -0
- package/src/stream.js +142 -0
- package/test/stress.test.js +62 -0
- package/test/xss.test.js +68 -0
package/package.json
ADDED
package/src/escaper.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export const CONTEXT = {
|
|
2
|
+
TEXT: 0,
|
|
3
|
+
TAG_NAME: 1,
|
|
4
|
+
ATTR_NAME: 2,
|
|
5
|
+
ATTR_VALUE_UNQUOTED: 3,
|
|
6
|
+
ATTR_VALUE_SINGLE: 4,
|
|
7
|
+
ATTR_VALUE_DOUBLE: 5,
|
|
8
|
+
SCRIPT: 6,
|
|
9
|
+
STYLE: 7,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const URL_ATTRIBUTES = new Set(['href', 'src', 'action', 'formaction', 'data', 'manifest', 'poster']);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Escapes a string for insertion into a specific HTML context.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} value The value to escape.
|
|
18
|
+
* @param {number} context The CONTEXT enum value.
|
|
19
|
+
* @param {string} attrName The name of the attribute (if in an attribute context).
|
|
20
|
+
* @returns {string} The escaped string.
|
|
21
|
+
* @throws {Error} If interpolation is hard-refused in this context.
|
|
22
|
+
*/
|
|
23
|
+
export function escape(value, context, attrName = '') {
|
|
24
|
+
if (value == null) return '';
|
|
25
|
+
const str = String(value);
|
|
26
|
+
|
|
27
|
+
switch (context) {
|
|
28
|
+
case CONTEXT.SCRIPT:
|
|
29
|
+
case CONTEXT.STYLE:
|
|
30
|
+
throw new Error(`E_INERT_VECTOR_UNSAFE: Interpolation inside <script> or <style> is hard-refused. Use raw() if absolutely necessary, but be aware of XSS.`);
|
|
31
|
+
|
|
32
|
+
case CONTEXT.TEXT:
|
|
33
|
+
return str
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>');
|
|
37
|
+
|
|
38
|
+
case CONTEXT.ATTR_VALUE_DOUBLE:
|
|
39
|
+
if (URL_ATTRIBUTES.has(attrName.toLowerCase())) {
|
|
40
|
+
if (/^\s*javascript:/i.test(str)) {
|
|
41
|
+
throw new Error(`E_INERT_VECTOR_UNSAFE: javascript: URLs are hard-refused in URL attributes.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return str
|
|
45
|
+
.replace(/&/g, '&')
|
|
46
|
+
.replace(/"/g, '"')
|
|
47
|
+
.replace(/</g, '<')
|
|
48
|
+
.replace(/>/g, '>');
|
|
49
|
+
|
|
50
|
+
case CONTEXT.ATTR_VALUE_SINGLE:
|
|
51
|
+
if (URL_ATTRIBUTES.has(attrName.toLowerCase())) {
|
|
52
|
+
if (/^\s*javascript:/i.test(str)) {
|
|
53
|
+
throw new Error(`E_INERT_VECTOR_UNSAFE: javascript: URLs are hard-refused in URL attributes.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return str
|
|
57
|
+
.replace(/&/g, '&')
|
|
58
|
+
.replace(/'/g, ''')
|
|
59
|
+
.replace(/</g, '<')
|
|
60
|
+
.replace(/>/g, '>');
|
|
61
|
+
|
|
62
|
+
case CONTEXT.ATTR_VALUE_UNQUOTED:
|
|
63
|
+
if (URL_ATTRIBUTES.has(attrName.toLowerCase())) {
|
|
64
|
+
if (/^\s*javascript:/i.test(str)) {
|
|
65
|
+
throw new Error(`E_INERT_VECTOR_UNSAFE: javascript: URLs are hard-refused in URL attributes.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return str
|
|
69
|
+
.replace(/&/g, '&')
|
|
70
|
+
.replace(/"/g, '"')
|
|
71
|
+
.replace(/'/g, ''')
|
|
72
|
+
.replace(/</g, '<')
|
|
73
|
+
.replace(/>/g, '>')
|
|
74
|
+
.replace(/`/g, '`')
|
|
75
|
+
.replace(/\s/g, ' '); // Spaces break unquoted attributes
|
|
76
|
+
|
|
77
|
+
case CONTEXT.TAG_NAME:
|
|
78
|
+
case CONTEXT.ATTR_NAME:
|
|
79
|
+
// Strictly alphanumeric/dashes for tag/attr names to prevent breaking out
|
|
80
|
+
if (!/^[a-z0-9-]+$/i.test(str)) {
|
|
81
|
+
throw new Error(`E_INERT_VECTOR_UNSAFE: Invalid characters in tag or attribute name interpolation.`);
|
|
82
|
+
}
|
|
83
|
+
return str;
|
|
84
|
+
|
|
85
|
+
default:
|
|
86
|
+
throw new Error(`E_INERT_VECTOR_UNSAFE: Unknown context ${context}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A basic state machine to determine the HTML context at the end of a string chunk.
|
|
92
|
+
*/
|
|
93
|
+
export function analyzeTemplateStrings(strings) {
|
|
94
|
+
const statics = [...strings];
|
|
95
|
+
const contexts = [];
|
|
96
|
+
const attrNames = [];
|
|
97
|
+
|
|
98
|
+
let state = CONTEXT.TEXT;
|
|
99
|
+
let currentAttrName = '';
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < strings.length - 1; i++) {
|
|
102
|
+
const chunk = strings[i];
|
|
103
|
+
|
|
104
|
+
// We process the chunk character by character to track the state
|
|
105
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
106
|
+
const char = chunk[j];
|
|
107
|
+
|
|
108
|
+
switch (state) {
|
|
109
|
+
case CONTEXT.TEXT:
|
|
110
|
+
if (char === '<') {
|
|
111
|
+
// Check for </script> or </style>
|
|
112
|
+
if (chunk.startsWith('</script>', j) || chunk.startsWith('</SCRIPT>', j)) {
|
|
113
|
+
j += 8;
|
|
114
|
+
} else if (chunk.startsWith('</style>', j) || chunk.startsWith('</STYLE>', j)) {
|
|
115
|
+
j += 7;
|
|
116
|
+
} else if (chunk.startsWith('!--', j + 1)) {
|
|
117
|
+
// Comment - simplified, just let it be TEXT for now as it's safe to escape
|
|
118
|
+
} else if (chunk.startsWith('script', j + 1) || chunk.startsWith('SCRIPT', j + 1)) {
|
|
119
|
+
state = CONTEXT.TAG_NAME; // Going into script tag definition
|
|
120
|
+
} else if (chunk.startsWith('style', j + 1) || chunk.startsWith('STYLE', j + 1)) {
|
|
121
|
+
state = CONTEXT.TAG_NAME; // Going into style tag definition
|
|
122
|
+
} else {
|
|
123
|
+
state = CONTEXT.TAG_NAME;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
|
|
128
|
+
case CONTEXT.TAG_NAME:
|
|
129
|
+
if (/\s/.test(char)) {
|
|
130
|
+
state = CONTEXT.ATTR_NAME;
|
|
131
|
+
currentAttrName = '';
|
|
132
|
+
} else if (char === '>') {
|
|
133
|
+
// Determine if we just opened a script or style
|
|
134
|
+
// We need to look back to see the tag name
|
|
135
|
+
const tagMatch = chunk.substring(0, j).match(/<\s*([a-z0-9-]+)[^>]*$/i);
|
|
136
|
+
const tag = tagMatch ? tagMatch[1].toLowerCase() : '';
|
|
137
|
+
if (tag === 'script') state = CONTEXT.SCRIPT;
|
|
138
|
+
else if (tag === 'style') state = CONTEXT.STYLE;
|
|
139
|
+
else state = CONTEXT.TEXT;
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
|
|
143
|
+
case CONTEXT.ATTR_NAME:
|
|
144
|
+
if (char === '=') {
|
|
145
|
+
state = CONTEXT.ATTR_VALUE_UNQUOTED;
|
|
146
|
+
} else if (char === '>') {
|
|
147
|
+
const tagMatch = chunk.substring(0, j).match(/<\s*([a-z0-9-]+)[^>]*$/i);
|
|
148
|
+
const tag = tagMatch ? tagMatch[1].toLowerCase() : '';
|
|
149
|
+
if (tag === 'script') state = CONTEXT.SCRIPT;
|
|
150
|
+
else if (tag === 'style') state = CONTEXT.STYLE;
|
|
151
|
+
else state = CONTEXT.TEXT;
|
|
152
|
+
} else if (/\s/.test(char)) {
|
|
153
|
+
// Keep in ATTR_NAME
|
|
154
|
+
} else {
|
|
155
|
+
currentAttrName += char;
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
|
|
159
|
+
case CONTEXT.ATTR_VALUE_UNQUOTED:
|
|
160
|
+
if (char === '"') {
|
|
161
|
+
state = CONTEXT.ATTR_VALUE_DOUBLE;
|
|
162
|
+
} else if (char === "'") {
|
|
163
|
+
state = CONTEXT.ATTR_VALUE_SINGLE;
|
|
164
|
+
} else if (/\s/.test(char)) {
|
|
165
|
+
state = CONTEXT.ATTR_NAME;
|
|
166
|
+
currentAttrName = '';
|
|
167
|
+
} else if (char === '>') {
|
|
168
|
+
const tagMatch = chunk.substring(0, j).match(/<\s*([a-z0-9-]+)[^>]*$/i);
|
|
169
|
+
const tag = tagMatch ? tagMatch[1].toLowerCase() : '';
|
|
170
|
+
if (tag === 'script') state = CONTEXT.SCRIPT;
|
|
171
|
+
else if (tag === 'style') state = CONTEXT.STYLE;
|
|
172
|
+
else state = CONTEXT.TEXT;
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
case CONTEXT.ATTR_VALUE_DOUBLE:
|
|
177
|
+
if (char === '"') {
|
|
178
|
+
state = CONTEXT.ATTR_NAME;
|
|
179
|
+
currentAttrName = '';
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
|
|
183
|
+
case CONTEXT.ATTR_VALUE_SINGLE:
|
|
184
|
+
if (char === "'") {
|
|
185
|
+
state = CONTEXT.ATTR_NAME;
|
|
186
|
+
currentAttrName = '';
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
|
|
190
|
+
case CONTEXT.SCRIPT:
|
|
191
|
+
if (char === '<' && (chunk.startsWith('</script>', j) || chunk.startsWith('</SCRIPT>', j))) {
|
|
192
|
+
state = CONTEXT.TAG_NAME; // Closing tag
|
|
193
|
+
j += 8;
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
|
|
197
|
+
case CONTEXT.STYLE:
|
|
198
|
+
if (char === '<' && (chunk.startsWith('</style>', j) || chunk.startsWith('</STYLE>', j))) {
|
|
199
|
+
state = CONTEXT.TAG_NAME; // Closing tag
|
|
200
|
+
j += 7;
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
contexts.push(state);
|
|
207
|
+
attrNames.push(currentAttrName.trim());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { statics, contexts, attrNames };
|
|
211
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { analyzeTemplateStrings, escape, CONTEXT } from './escaper.js';
|
|
2
|
+
import { minifyHTML } from 'inertjs-optimizer';
|
|
3
|
+
export { resolveToString } from './resolve.js';
|
|
4
|
+
|
|
5
|
+
const planCache = new WeakMap();
|
|
6
|
+
|
|
7
|
+
export class RawString {
|
|
8
|
+
constructor(str) {
|
|
9
|
+
this.value = str;
|
|
10
|
+
}
|
|
11
|
+
toString() {
|
|
12
|
+
return this.value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Opt-out of HTML escaping.
|
|
18
|
+
* Use with extreme caution.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} str Unescaped HTML string
|
|
21
|
+
* @returns {RawString}
|
|
22
|
+
*/
|
|
23
|
+
export function raw(str, suppressWarning = false) {
|
|
24
|
+
if (!suppressWarning && process.env.NODE_ENV !== 'production') {
|
|
25
|
+
const stack = new Error().stack;
|
|
26
|
+
// index 1 is raw(), index 2 is the caller
|
|
27
|
+
const callSite = stack && stack.split('\n')[2] ? stack.split('\n')[2].trim() : 'unknown location';
|
|
28
|
+
console.warn(`[Lens] Warning: raw() escape hatch used at ${callSite}. Ensure this string is safe.`);
|
|
29
|
+
}
|
|
30
|
+
return new RawString(str);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Tagged template literal for Vector template engine.
|
|
35
|
+
* Safely escapes all interpolations based on their HTML context.
|
|
36
|
+
*
|
|
37
|
+
* @param {TemplateStringsArray} strings
|
|
38
|
+
* @param {...any} values
|
|
39
|
+
*/
|
|
40
|
+
export function vec(strings, ...values) {
|
|
41
|
+
let plan = planCache.get(strings);
|
|
42
|
+
if (!plan) {
|
|
43
|
+
plan = analyzeTemplateStrings(strings);
|
|
44
|
+
// Minify statics on the fly during template parse
|
|
45
|
+
plan.statics = plan.statics.map(staticStr => minifyHTML(staticStr));
|
|
46
|
+
planCache.set(strings, plan);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Check if we need to stream (any value is a Promise or AsyncIterable)
|
|
50
|
+
const isAsync = values.some(v => v instanceof Promise || (v && typeof v[Symbol.asyncIterator] === 'function') || (v && v.type === 'VecStream'));
|
|
51
|
+
|
|
52
|
+
if (isAsync) {
|
|
53
|
+
return {
|
|
54
|
+
type: 'VecStream',
|
|
55
|
+
plan,
|
|
56
|
+
values
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let result = plan.statics[0];
|
|
61
|
+
for (let i = 0; i < values.length; i++) {
|
|
62
|
+
const val = values[i];
|
|
63
|
+
const ctx = plan.contexts[i];
|
|
64
|
+
const attrName = plan.attrNames[i];
|
|
65
|
+
|
|
66
|
+
if (val instanceof RawString) {
|
|
67
|
+
result += val.value;
|
|
68
|
+
} else if (val && val.type === 'VecStream') {
|
|
69
|
+
throw new Error('E_INERT_VECTOR_NESTED_ASYNC: Async nested vec`` found in synchronous render context.');
|
|
70
|
+
} else if (Array.isArray(val)) {
|
|
71
|
+
result += val.map(v => v instanceof RawString ? v.value : escape(v, ctx, attrName)).join('');
|
|
72
|
+
} else {
|
|
73
|
+
result += escape(val, ctx, attrName);
|
|
74
|
+
}
|
|
75
|
+
result += plan.statics[i + 1];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Return RawString so nested `vec` calls aren't double-escaped
|
|
79
|
+
return new RawString(result);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generates an optimized image tag that hooks into InertJS's on-the-fly optimizer.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} props Image properties: src, width, height, quality, class, alt
|
|
86
|
+
* @returns {RawString}
|
|
87
|
+
*/
|
|
88
|
+
export function img({ src, width, height, quality, ...rest }) {
|
|
89
|
+
const query = new URLSearchParams();
|
|
90
|
+
query.set('src', src);
|
|
91
|
+
if (width) query.set('w', width);
|
|
92
|
+
if (height) query.set('h', height);
|
|
93
|
+
if (quality) query.set('q', quality);
|
|
94
|
+
|
|
95
|
+
const url = `/_inert/image?${query.toString()}`;
|
|
96
|
+
|
|
97
|
+
const attrs = Object.entries(rest)
|
|
98
|
+
.map(([key, val]) => `${key}="${escape(val, CONTEXT.ATTR_VALUE_DOUBLE)}"`)
|
|
99
|
+
.join(' ');
|
|
100
|
+
|
|
101
|
+
return raw(`<img src="${url}" ${attrs} />`, true);
|
|
102
|
+
}
|
package/src/resolve.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { escape } from './escaper.js';
|
|
2
|
+
import { RawString } from './index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fully resolves a VecStream and all its asynchronous holes into a single string.
|
|
6
|
+
*/
|
|
7
|
+
export async function resolveToString(val, ctx = 'text', attrName = '') {
|
|
8
|
+
if (val == null || val === false) return '';
|
|
9
|
+
if (typeof val === 'string' || typeof val === 'number') return escape(String(val), ctx, attrName);
|
|
10
|
+
if (val instanceof RawString) return val.value;
|
|
11
|
+
|
|
12
|
+
if (val instanceof Promise) {
|
|
13
|
+
return resolveToString(await val, ctx, attrName);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (Array.isArray(val)) {
|
|
17
|
+
let res = '';
|
|
18
|
+
for (const item of val) {
|
|
19
|
+
res += await resolveToString(item, ctx, attrName);
|
|
20
|
+
}
|
|
21
|
+
return res;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (val && val.type === 'VecStream') {
|
|
25
|
+
let result = '';
|
|
26
|
+
for (let i = 0; i < val.values.length; i++) {
|
|
27
|
+
result += val.plan.statics[i];
|
|
28
|
+
|
|
29
|
+
const part = val.values[i];
|
|
30
|
+
const nextCtx = val.plan.contexts[i];
|
|
31
|
+
const nextAttrName = val.plan.attrNames[i];
|
|
32
|
+
|
|
33
|
+
result += await resolveToString(part, nextCtx, nextAttrName);
|
|
34
|
+
}
|
|
35
|
+
result += val.plan.statics[val.plan.statics.length - 1];
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (val && typeof val[Symbol.asyncIterator] === 'function') {
|
|
40
|
+
let res = '';
|
|
41
|
+
for await (const chunk of val) {
|
|
42
|
+
res += await resolveToString(chunk, ctx, attrName);
|
|
43
|
+
}
|
|
44
|
+
return res;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return escape(String(val), ctx, attrName);
|
|
48
|
+
}
|
package/src/stream.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { escape } from './escaper.js';
|
|
2
|
+
import { RawString } from './index.js';
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
|
|
6
|
+
const PATCHER_SCRIPT = `<script nonce="[INERT_NONCE]">
|
|
7
|
+
(function(){
|
|
8
|
+
const ds=document.querySelectorAll('template[data-i-fill]');
|
|
9
|
+
for(let d of ds){
|
|
10
|
+
const id=d.getAttribute('data-i-fill');
|
|
11
|
+
const target=document.getElementById('i-slot-'+id);
|
|
12
|
+
if(target){
|
|
13
|
+
target.replaceWith(d.content);
|
|
14
|
+
}
|
|
15
|
+
d.remove();
|
|
16
|
+
}
|
|
17
|
+
})();
|
|
18
|
+
</script>`;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Renders a VecStream (or RawString) to a WHATWG ReadableStream.
|
|
22
|
+
* Handles out-of-order streaming for Promises and AsyncIterables.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} vecResult The result from calling vec\`\`
|
|
25
|
+
* @param {string} nonce The CSP nonce for inline scripts
|
|
26
|
+
* @returns {ReadableStream}
|
|
27
|
+
*/
|
|
28
|
+
export function renderToStream(vecResult, nonce = '') {
|
|
29
|
+
if (!(vecResult && vecResult.type === 'VecStream')) {
|
|
30
|
+
const value = vecResult instanceof RawString ? vecResult.value : String(vecResult);
|
|
31
|
+
return new ReadableStream({
|
|
32
|
+
start(controller) {
|
|
33
|
+
controller.enqueue(encoder.encode(value));
|
|
34
|
+
controller.close();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { plan, values } = vecResult;
|
|
40
|
+
let slotCounter = 0;
|
|
41
|
+
const pendingTasks = new Set();
|
|
42
|
+
|
|
43
|
+
return new ReadableStream({
|
|
44
|
+
async start(controller) {
|
|
45
|
+
try {
|
|
46
|
+
let initialHtml = plan.statics[0];
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < values.length; i++) {
|
|
49
|
+
const val = values[i];
|
|
50
|
+
const ctx = plan.contexts[i];
|
|
51
|
+
const attrName = plan.attrNames[i];
|
|
52
|
+
|
|
53
|
+
const isPromise = val instanceof Promise;
|
|
54
|
+
const isAsyncIter = val && typeof val[Symbol.asyncIterator] === 'function';
|
|
55
|
+
const isNestedStream = val && val.type === 'VecStream';
|
|
56
|
+
|
|
57
|
+
if (isPromise || isAsyncIter || isNestedStream) {
|
|
58
|
+
const slotId = ++slotCounter;
|
|
59
|
+
initialHtml += `<i-slot id="i-slot-${slotId}"></i-slot>`;
|
|
60
|
+
|
|
61
|
+
const task = (async () => {
|
|
62
|
+
try {
|
|
63
|
+
if (isPromise) {
|
|
64
|
+
const resolved = await val;
|
|
65
|
+
let str = '';
|
|
66
|
+
if (resolved && resolved.type === 'VecStream') {
|
|
67
|
+
// Resolve nested stream by reading from it
|
|
68
|
+
const nestedReadable = renderToStream(resolved, nonce);
|
|
69
|
+
const reader = nestedReadable.getReader();
|
|
70
|
+
let nestedHtml = '';
|
|
71
|
+
while (true) {
|
|
72
|
+
const { done, value } = await reader.read();
|
|
73
|
+
if (done) break;
|
|
74
|
+
// value is Uint8Array
|
|
75
|
+
nestedHtml += new TextDecoder().decode(value);
|
|
76
|
+
}
|
|
77
|
+
str = nestedHtml;
|
|
78
|
+
} else if (resolved instanceof RawString) {
|
|
79
|
+
str = resolved.value;
|
|
80
|
+
} else {
|
|
81
|
+
str = escape(resolved, ctx, attrName);
|
|
82
|
+
}
|
|
83
|
+
const patch = `\n<template data-i-fill="${slotId}">${str}</template>` +
|
|
84
|
+
PATCHER_SCRIPT.replace('[INERT_NONCE]', nonce);
|
|
85
|
+
controller.enqueue(encoder.encode(patch));
|
|
86
|
+
} else if (isAsyncIter) {
|
|
87
|
+
let accumulated = '';
|
|
88
|
+
for await (const chunk of val) {
|
|
89
|
+
accumulated += (chunk instanceof RawString ? chunk.value : escape(chunk, ctx, attrName));
|
|
90
|
+
}
|
|
91
|
+
const patch = `\n<template data-i-fill="${slotId}">${accumulated}</template>` +
|
|
92
|
+
PATCHER_SCRIPT.replace('[INERT_NONCE]', nonce);
|
|
93
|
+
controller.enqueue(encoder.encode(patch));
|
|
94
|
+
} else if (isNestedStream) {
|
|
95
|
+
const nestedReadable = renderToStream(val, nonce);
|
|
96
|
+
const reader = nestedReadable.getReader();
|
|
97
|
+
let nestedHtml = '';
|
|
98
|
+
while (true) {
|
|
99
|
+
const { done, value } = await reader.read();
|
|
100
|
+
if (done) break;
|
|
101
|
+
nestedHtml += new TextDecoder().decode(value);
|
|
102
|
+
}
|
|
103
|
+
const patch = `\n<template data-i-fill="${slotId}">${nestedHtml}</template>` +
|
|
104
|
+
PATCHER_SCRIPT.replace('[INERT_NONCE]', nonce);
|
|
105
|
+
controller.enqueue(encoder.encode(patch));
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error(`[InertJS] Error streaming slot ${slotId}:`, err);
|
|
109
|
+
} finally {
|
|
110
|
+
pendingTasks.delete(task);
|
|
111
|
+
if (pendingTasks.size === 0) {
|
|
112
|
+
controller.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
})();
|
|
116
|
+
|
|
117
|
+
pendingTasks.add(task);
|
|
118
|
+
} else {
|
|
119
|
+
// Synchronous value
|
|
120
|
+
if (val instanceof RawString) {
|
|
121
|
+
initialHtml += val.value;
|
|
122
|
+
} else if (Array.isArray(val)) {
|
|
123
|
+
initialHtml += val.map(v => v instanceof RawString ? v.value : escape(v, ctx, attrName)).join('');
|
|
124
|
+
} else {
|
|
125
|
+
initialHtml += escape(val, ctx, attrName);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
initialHtml += plan.statics[i + 1];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Flush the synchronous initial shell
|
|
132
|
+
controller.enqueue(encoder.encode(initialHtml));
|
|
133
|
+
|
|
134
|
+
if (pendingTasks.size === 0) {
|
|
135
|
+
controller.close();
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
controller.error(err);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { vec } from '../src/index.js';
|
|
4
|
+
import { renderToStream } from '../src/stream.js';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
|
|
7
|
+
test('Vector Stress Test (100,000 DOM nodes)', async (t) => {
|
|
8
|
+
// Generate 10,000 items, each item will have 10 nodes, totaling 100,000 nodes.
|
|
9
|
+
const data = Array.from({ length: 10000 }).map((_, i) => ({
|
|
10
|
+
id: i,
|
|
11
|
+
name: `Item ${i}`,
|
|
12
|
+
desc: `Description for ${i}`
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
function itemTemplate(item) {
|
|
16
|
+
return vec`
|
|
17
|
+
<div id="item-${item.id}" class="item-container">
|
|
18
|
+
<h2>${item.name}</h2>
|
|
19
|
+
<p>${item.desc}</p>
|
|
20
|
+
<span>Extra node 1</span>
|
|
21
|
+
<span>Extra node 2</span>
|
|
22
|
+
<ul>
|
|
23
|
+
<li>Child 1</li>
|
|
24
|
+
<li>Child 2</li>
|
|
25
|
+
<li>Child 3</li>
|
|
26
|
+
</ul>
|
|
27
|
+
</div>
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rootTemplate(items) {
|
|
32
|
+
return vec`
|
|
33
|
+
<main>
|
|
34
|
+
<h1>100k Node Stress Test</h1>
|
|
35
|
+
<div class="list">
|
|
36
|
+
${items.map(itemTemplate)}
|
|
37
|
+
</div>
|
|
38
|
+
</main>
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const start = performance.now();
|
|
43
|
+
|
|
44
|
+
const webStream = renderToStream(rootTemplate(data));
|
|
45
|
+
const nodeStream = Readable.fromWeb(webStream);
|
|
46
|
+
|
|
47
|
+
let chunkCount = 0;
|
|
48
|
+
let totalBytes = 0;
|
|
49
|
+
|
|
50
|
+
for await (const chunk of nodeStream) {
|
|
51
|
+
chunkCount++;
|
|
52
|
+
totalBytes += chunk.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const end = performance.now();
|
|
56
|
+
const timeMs = end - start;
|
|
57
|
+
|
|
58
|
+
console.log(`Rendered ${totalBytes} bytes in ${chunkCount} chunks. Time: ${timeMs.toFixed(2)}ms`);
|
|
59
|
+
|
|
60
|
+
assert.ok(totalBytes > 1000000, `Expected > 1MB of HTML, got ${totalBytes} bytes`);
|
|
61
|
+
assert.ok(timeMs < 2000, `Expected render under 2s, took ${timeMs}ms`);
|
|
62
|
+
});
|
package/test/xss.test.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { vec, raw } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
test('Vector Template Engine XSS Corpus', async (t) => {
|
|
6
|
+
await t.test('escapes basic text interpolation', () => {
|
|
7
|
+
const malicious = '<script>alert(1)</script>';
|
|
8
|
+
const result = vec`<div>${malicious}</div>`;
|
|
9
|
+
assert.strictEqual(result.toString(), '<div><script>alert(1)</script></div>');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
await t.test('escapes double-quoted attributes', () => {
|
|
13
|
+
const malicious = '"> <script>alert(1)</script>';
|
|
14
|
+
const result = vec`<div class="${malicious}"></div>`;
|
|
15
|
+
assert.strictEqual(result.toString(), '<div class=""> <script>alert(1)</script>"></div>');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await t.test('escapes single-quoted attributes', () => {
|
|
19
|
+
const malicious = "'> <script>alert(1)</script>";
|
|
20
|
+
const result = vec`<div class='${malicious}'></div>`;
|
|
21
|
+
assert.strictEqual(result.toString(), "<div class=''> <script>alert(1)</script>'></div>");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await t.test('escapes unquoted attributes', () => {
|
|
25
|
+
const malicious = 'onclick=alert(1)';
|
|
26
|
+
const result = vec`<div class=${malicious}></div>`;
|
|
27
|
+
assert.strictEqual(result.toString(), '<div class=onclick=alert(1)></div>');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
await t.test('hard-refuses javascript: in URL attributes', () => {
|
|
31
|
+
const malicious = 'javascript:alert(1)';
|
|
32
|
+
assert.throws(
|
|
33
|
+
() => vec`<a href="${malicious}">link</a>`,
|
|
34
|
+
/E_INERT_VECTOR_UNSAFE/
|
|
35
|
+
);
|
|
36
|
+
assert.throws(
|
|
37
|
+
() => vec`<a href=' ${malicious} '>link</a>`,
|
|
38
|
+
/E_INERT_VECTOR_UNSAFE/
|
|
39
|
+
);
|
|
40
|
+
assert.throws(
|
|
41
|
+
() => vec`<iframe src=${malicious}></iframe>`,
|
|
42
|
+
/E_INERT_VECTOR_UNSAFE/
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await t.test('hard-refuses interpolation in <script> and <style>', () => {
|
|
47
|
+
const data = 'alert(1)';
|
|
48
|
+
assert.throws(
|
|
49
|
+
() => vec`<script>${data}</script>`,
|
|
50
|
+
/E_INERT_VECTOR_UNSAFE/
|
|
51
|
+
);
|
|
52
|
+
assert.throws(
|
|
53
|
+
() => vec`<style>${data}</style>`,
|
|
54
|
+
/E_INERT_VECTOR_UNSAFE/
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await t.test('raw() escape hatch bypasses escaping', () => {
|
|
59
|
+
const result = vec`<div>${raw('<b>bold</b>')}</div>`;
|
|
60
|
+
assert.strictEqual(result.toString(), '<div><b>bold</b></div>');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await t.test('nested vec calls do not double-escape', () => {
|
|
64
|
+
const child = vec`<span>${'<escaped>'}</span>`;
|
|
65
|
+
const parent = vec`<div>${child}</div>`;
|
|
66
|
+
assert.strictEqual(parent.toString(), '<div><span><escaped></span></div>');
|
|
67
|
+
});
|
|
68
|
+
});
|