dalila 1.10.2 → 1.10.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.
@@ -473,11 +473,27 @@ export function clearPersisted(name, storage = safeDefaultStorage() ?? {}) {
473
473
  }
474
474
  }
475
475
  function escapeInlineScriptContent(script) {
476
- return script
477
- .replace(/</g, '\\x3C')
478
- .replace(/-->/g, '--\\x3E')
479
- .replace(/\u2028/g, '\\u2028')
480
- .replace(/\u2029/g, '\\u2029');
476
+ return script.replace(/--!>|-->|[<>\u2028\u2029]/g, (match) => {
477
+ switch (match) {
478
+ case '--!>':
479
+ return '--!\\u003E';
480
+ case '-->':
481
+ return '--\\u003E';
482
+ case '<':
483
+ return '\\u003C';
484
+ case '>':
485
+ return '\\u003E';
486
+ case '\u2028':
487
+ return '\\u2028';
488
+ case '\u2029':
489
+ return '\\u2029';
490
+ default:
491
+ return match;
492
+ }
493
+ });
494
+ }
495
+ function stringifyInlineScriptLiteral(value) {
496
+ return escapeInlineScriptContent(JSON.stringify(value));
481
497
  }
482
498
  /**
483
499
  * Generate a minimal inline script to prevent FOUC.
@@ -486,13 +502,14 @@ function escapeInlineScriptContent(script) {
486
502
  */
487
503
  export function createPreloadScript(options) {
488
504
  const { storageKey, defaultValue, target = 'documentElement', attribute = 'data-theme', storageType = 'localStorage', } = options;
489
- // Use JSON.stringify to safely embed strings (avoid breaking quotes / injection)
490
- const k = JSON.stringify(storageKey);
491
- const d = JSON.stringify(defaultValue);
492
- const a = JSON.stringify(attribute);
505
+ const safeTarget = target === 'body' ? 'body' : 'documentElement';
506
+ const safeStorageType = storageType === 'sessionStorage' ? 'sessionStorage' : 'localStorage';
507
+ const k = stringifyInlineScriptLiteral(storageKey);
508
+ const d = stringifyInlineScriptLiteral(defaultValue);
509
+ const a = stringifyInlineScriptLiteral(attribute);
493
510
  // Still minified
494
- const script = `(function(){try{var s=${storageType}.getItem(${k});var v=s==null?${d}:JSON.parse(s);document.${target}.setAttribute(${a},v)}catch(e){document.${target}.setAttribute(${a},${d})}})();`;
495
- return escapeInlineScriptContent(script);
511
+ const script = `(function(){try{var s=${safeStorageType}.getItem(${k});var v=s==null?${d}:JSON.parse(s);document.${safeTarget}.setAttribute(${a},v)}catch(e){document.${safeTarget}.setAttribute(${a},${d})}})();`;
512
+ return script;
496
513
  }
497
514
  export function createThemeScript(storageKey, defaultTheme = 'light') {
498
515
  return createPreloadScript({
@@ -1,9 +1,13 @@
1
1
  const TRUSTED_POLICY_CACHE_KEY = Symbol.for('dalila.runtime.trustedTypesPolicies');
2
2
  const TRUSTED_POLICY_PARSE_SUFFIX = '--dalila-parse';
3
- const EXECUTABLE_HTML_SCRIPT_PATTERN = /<script[\s/>]/i;
4
- const EXECUTABLE_HTML_EVENT_ATTR_PATTERN = /<[^>]+\son[a-z0-9:_-]+\s*=/i;
5
- const EXECUTABLE_HTML_URL_ATTR_PATTERN = /<[^>]+\s(?:href|src|xlink:href|formaction|action|poster)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
6
- const EXECUTABLE_DATA_URL_PATTERN = /^data:(?:text\/html|application\/xhtml\+xml|image\/svg\+xml)\b/i;
3
+ const EXECUTABLE_HTML_URL_ATTR_NAMES = new Set([
4
+ 'href',
5
+ 'src',
6
+ 'xlink:href',
7
+ 'formaction',
8
+ 'action',
9
+ 'poster',
10
+ ]);
7
11
  function getTrustedPolicyCache() {
8
12
  const host = globalThis;
9
13
  if (host[TRUSTED_POLICY_CACHE_KEY] instanceof Map) {
@@ -17,26 +21,254 @@ const trustedPolicyCache = getTrustedPolicyCache();
17
21
  function normalizeHtmlUrlAttrValue(value) {
18
22
  return value.replace(/[\u0000-\u0020\u007f]+/g, '').toLowerCase();
19
23
  }
24
+ function isHtmlWhitespaceCode(code) {
25
+ return code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d;
26
+ }
27
+ function isHtmlAttributeNameChar(code) {
28
+ return !Number.isNaN(code)
29
+ && code !== 0x20
30
+ && code !== 0x09
31
+ && code !== 0x0a
32
+ && code !== 0x0c
33
+ && code !== 0x0d
34
+ && code !== 0x22
35
+ && code !== 0x27
36
+ && code !== 0x2f
37
+ && code !== 0x3c
38
+ && code !== 0x3d
39
+ && code !== 0x3e
40
+ && code !== 0x60;
41
+ }
42
+ function isTagBoundaryChar(char) {
43
+ return !char || char === '/' || char === '>' || isHtmlWhitespaceCode(char.charCodeAt(0));
44
+ }
45
+ function getPreviousNonWhitespaceChar(value, end, start = 0) {
46
+ for (let index = end - 1; index >= start; index -= 1) {
47
+ if (!isHtmlWhitespaceCode(value.charCodeAt(index))) {
48
+ return value[index];
49
+ }
50
+ }
51
+ return undefined;
52
+ }
53
+ function isHtmlTagStartChar(char) {
54
+ return !!char && /[A-Za-z/!?]/.test(char);
55
+ }
56
+ function findTagLikeStart(value, start, end = value.length) {
57
+ let index = value.indexOf('<', start);
58
+ while (index !== -1 && index < end) {
59
+ if (isHtmlTagStartChar(value[index + 1])) {
60
+ return index;
61
+ }
62
+ index = value.indexOf('<', index + 1);
63
+ }
64
+ return -1;
65
+ }
66
+ function hasExecutableHtmlScriptTag(value) {
67
+ const lower = value.toLowerCase();
68
+ let searchIndex = 0;
69
+ while (searchIndex < lower.length) {
70
+ const index = lower.indexOf('<script', searchIndex);
71
+ if (index === -1)
72
+ return false;
73
+ if (isTagBoundaryChar(lower[index + 7])) {
74
+ return true;
75
+ }
76
+ searchIndex = index + 7;
77
+ }
78
+ return false;
79
+ }
80
+ function hasExecutableProtocol(value) {
81
+ return value.startsWith('javascript:')
82
+ || value.startsWith('vbscript:')
83
+ || value.startsWith('data:');
84
+ }
85
+ function hasExecutableHtmlEventAttribute(value) {
86
+ let index = 0;
87
+ while (index < value.length) {
88
+ const tagStart = value.indexOf('<', index);
89
+ if (tagStart === -1)
90
+ return false;
91
+ let cursor = tagStart + 1;
92
+ const firstCode = value.charCodeAt(cursor);
93
+ if (Number.isNaN(firstCode)
94
+ || value[cursor] === '/'
95
+ || value[cursor] === '!'
96
+ || value[cursor] === '?') {
97
+ index = cursor;
98
+ continue;
99
+ }
100
+ while (cursor < value.length
101
+ && !isHtmlWhitespaceCode(value.charCodeAt(cursor))
102
+ && value[cursor] !== '>') {
103
+ cursor += 1;
104
+ }
105
+ while (cursor < value.length && value[cursor] !== '>') {
106
+ while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
107
+ cursor += 1;
108
+ }
109
+ if (cursor >= value.length || value[cursor] === '>') {
110
+ break;
111
+ }
112
+ if (value[cursor] === '/') {
113
+ cursor += 1;
114
+ continue;
115
+ }
116
+ const nameStart = cursor;
117
+ while (cursor < value.length && isHtmlAttributeNameChar(value.charCodeAt(cursor))) {
118
+ cursor += 1;
119
+ }
120
+ if (cursor === nameStart) {
121
+ cursor += 1;
122
+ continue;
123
+ }
124
+ const attrName = value.slice(nameStart, cursor).toLowerCase();
125
+ while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
126
+ cursor += 1;
127
+ }
128
+ if (value[cursor] !== '=') {
129
+ continue;
130
+ }
131
+ if (attrName.startsWith('on')) {
132
+ return true;
133
+ }
134
+ cursor += 1;
135
+ while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
136
+ cursor += 1;
137
+ }
138
+ if (cursor >= value.length) {
139
+ break;
140
+ }
141
+ const quote = value[cursor];
142
+ if (quote === '"' || quote === '\'') {
143
+ cursor += 1;
144
+ const closingQuoteIndex = value.indexOf(quote, cursor);
145
+ if (closingQuoteIndex === -1) {
146
+ break;
147
+ }
148
+ cursor = closingQuoteIndex + 1;
149
+ continue;
150
+ }
151
+ while (cursor < value.length
152
+ && !isHtmlWhitespaceCode(value.charCodeAt(cursor))
153
+ && value[cursor] !== '>') {
154
+ cursor += 1;
155
+ }
156
+ }
157
+ index = cursor + 1;
158
+ }
159
+ return false;
160
+ }
20
161
  function hasExecutableHtmlUrlAttribute(value) {
21
- EXECUTABLE_HTML_URL_ATTR_PATTERN.lastIndex = 0;
22
- let match = null;
23
- while ((match = EXECUTABLE_HTML_URL_ATTR_PATTERN.exec(value)) !== null) {
24
- const attrValue = match[1] ?? match[2] ?? match[3] ?? '';
25
- const normalized = normalizeHtmlUrlAttrValue(attrValue);
26
- if (!normalized)
162
+ let index = 0;
163
+ while (index < value.length) {
164
+ const tagStart = value.indexOf('<', index);
165
+ if (tagStart === -1)
166
+ return false;
167
+ let cursor = tagStart + 1;
168
+ const firstCode = value.charCodeAt(cursor);
169
+ if (Number.isNaN(firstCode)
170
+ || value[cursor] === '/'
171
+ || value[cursor] === '!'
172
+ || value[cursor] === '?') {
173
+ index = cursor;
27
174
  continue;
28
- if (normalized.startsWith('javascript:'))
29
- return true;
30
- if (EXECUTABLE_DATA_URL_PATTERN.test(normalized))
31
- return true;
175
+ }
176
+ while (cursor < value.length && !isHtmlWhitespaceCode(value.charCodeAt(cursor)) && value[cursor] !== '>') {
177
+ cursor += 1;
178
+ }
179
+ while (cursor < value.length && value[cursor] !== '>') {
180
+ while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
181
+ cursor += 1;
182
+ }
183
+ if (cursor >= value.length || value[cursor] === '>') {
184
+ break;
185
+ }
186
+ if (value[cursor] === '/') {
187
+ cursor += 1;
188
+ continue;
189
+ }
190
+ const nameStart = cursor;
191
+ while (cursor < value.length && isHtmlAttributeNameChar(value.charCodeAt(cursor))) {
192
+ cursor += 1;
193
+ }
194
+ if (cursor === nameStart) {
195
+ cursor += 1;
196
+ continue;
197
+ }
198
+ const attrName = value.slice(nameStart, cursor).toLowerCase();
199
+ while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
200
+ cursor += 1;
201
+ }
202
+ if (value[cursor] !== '=') {
203
+ continue;
204
+ }
205
+ cursor += 1;
206
+ while (cursor < value.length && isHtmlWhitespaceCode(value.charCodeAt(cursor))) {
207
+ cursor += 1;
208
+ }
209
+ if (cursor >= value.length) {
210
+ break;
211
+ }
212
+ let attrValue = '';
213
+ let recoveryTagIndex = -1;
214
+ let unterminatedQuotedValue = false;
215
+ const quote = value[cursor];
216
+ if (quote === '"' || quote === '\'') {
217
+ cursor += 1;
218
+ const valueStart = cursor;
219
+ const closingQuoteIndex = value.indexOf(quote, valueStart);
220
+ const quotedValueEnd = closingQuoteIndex === -1 ? value.length : closingQuoteIndex;
221
+ recoveryTagIndex = findTagLikeStart(value, valueStart, quotedValueEnd);
222
+ if (closingQuoteIndex === -1) {
223
+ unterminatedQuotedValue = true;
224
+ const valueEnd = recoveryTagIndex === -1 ? value.length : recoveryTagIndex;
225
+ attrValue = value.slice(valueStart, valueEnd);
226
+ }
227
+ else {
228
+ const hasSuspiciousQuotedRestart = recoveryTagIndex !== -1
229
+ && getPreviousNonWhitespaceChar(value, closingQuoteIndex, valueStart) === '=';
230
+ if (hasSuspiciousQuotedRestart) {
231
+ unterminatedQuotedValue = true;
232
+ attrValue = value.slice(valueStart, recoveryTagIndex);
233
+ }
234
+ else {
235
+ cursor = closingQuoteIndex;
236
+ attrValue = value.slice(valueStart, cursor);
237
+ cursor += 1;
238
+ }
239
+ }
240
+ }
241
+ else {
242
+ const valueStart = cursor;
243
+ while (cursor < value.length
244
+ && !isHtmlWhitespaceCode(value.charCodeAt(cursor))
245
+ && value[cursor] !== '>') {
246
+ cursor += 1;
247
+ }
248
+ attrValue = value.slice(valueStart, cursor);
249
+ }
250
+ if (EXECUTABLE_HTML_URL_ATTR_NAMES.has(attrName)) {
251
+ const normalized = normalizeHtmlUrlAttrValue(attrValue);
252
+ if (normalized && hasExecutableProtocol(normalized)) {
253
+ return true;
254
+ }
255
+ }
256
+ if (unterminatedQuotedValue && recoveryTagIndex !== -1) {
257
+ return hasExecutableHtmlUrlAttribute(value.slice(recoveryTagIndex));
258
+ }
259
+ if (unterminatedQuotedValue) {
260
+ return false;
261
+ }
262
+ }
263
+ index = cursor + 1;
32
264
  }
33
265
  return false;
34
266
  }
35
267
  export function hasExecutableHtmlSinkPattern(value) {
36
268
  if (!value)
37
269
  return false;
38
- return EXECUTABLE_HTML_SCRIPT_PATTERN.test(value)
39
- || EXECUTABLE_HTML_EVENT_ATTR_PATTERN.test(value)
270
+ return hasExecutableHtmlScriptTag(value)
271
+ || hasExecutableHtmlEventAttribute(value)
40
272
  || hasExecutableHtmlUrlAttribute(value);
41
273
  }
42
274
  function getTrustedTypesApi() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dalila",
3
- "version": "1.10.2",
3
+ "version": "1.10.4",
4
4
  "description": "DOM-first reactive framework based on signals",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -27,6 +27,7 @@ function resolveServerConfig(argv = process.argv.slice(2), cwd = process.cwd())
27
27
  const serverConfig = resolveServerConfig();
28
28
  const projectDir = serverConfig.projectDir;
29
29
  const rootDir = serverConfig.rootDir;
30
+ const rootDirAbs = fs.existsSync(rootDir) ? fs.realpathSync(rootDir) : path.resolve(rootDir);
30
31
  const distMode = serverConfig.distMode;
31
32
  const isDalilaRepo = serverConfig.isDalilaRepo;
32
33
  const defaultEntry = serverConfig.defaultEntry;
@@ -107,6 +108,325 @@ function send(res, status, body, headers = {}) {
107
108
  res.end(body);
108
109
  }
109
110
 
111
+ const FORBIDDEN_PATH_ERROR_CODE = 'DALILA_FORBIDDEN_PATH';
112
+
113
+ function createForbiddenPathError() {
114
+ const error = new Error('Forbidden');
115
+ error.code = FORBIDDEN_PATH_ERROR_CODE;
116
+ return error;
117
+ }
118
+
119
+ function toServedRelativePath(candidatePath) {
120
+ if (typeof candidatePath !== 'string' || candidatePath.length === 0) {
121
+ return null;
122
+ }
123
+
124
+ const normalizedPath = path.resolve(candidatePath);
125
+ const relativePath = path.relative(rootDirAbs, normalizedPath);
126
+ if (relativePath === '') {
127
+ return '';
128
+ }
129
+
130
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
131
+ return null;
132
+ }
133
+
134
+ return relativePath;
135
+ }
136
+
137
+ function normalizeServedPath(candidatePath) {
138
+ const relativePath = toServedRelativePath(candidatePath);
139
+ return relativePath == null ? null : path.join(rootDirAbs, relativePath);
140
+ }
141
+
142
+ function statServedPath(targetPath) {
143
+ const safePath = normalizeServedPath(targetPath);
144
+ if (!safePath) {
145
+ throw createForbiddenPathError();
146
+ }
147
+
148
+ return {
149
+ safePath,
150
+ stat: fs.statSync(safePath),
151
+ };
152
+ }
153
+
154
+ function existsServedPath(targetPath) {
155
+ const safePath = normalizeServedPath(targetPath);
156
+ return safePath ? fs.existsSync(safePath) : false;
157
+ }
158
+
159
+ function readServedFile(targetPath, encoding, callback) {
160
+ const safePath = normalizeServedPath(targetPath);
161
+ if (!safePath) {
162
+ queueMicrotask(() => callback(createForbiddenPathError()));
163
+ return;
164
+ }
165
+
166
+ fs.readFile(safePath, encoding, callback);
167
+ }
168
+
169
+ function replaceServedPathExtension(targetPath, fromExtension, toExtension) {
170
+ const safePath = normalizeServedPath(targetPath);
171
+ if (!safePath || !safePath.endsWith(fromExtension)) {
172
+ return null;
173
+ }
174
+
175
+ return normalizeServedPath(`${safePath.slice(0, -fromExtension.length)}${toExtension}`);
176
+ }
177
+
178
+ function appendServedPathExtension(targetPath, extension) {
179
+ const safePath = normalizeServedPath(targetPath);
180
+ if (!safePath) {
181
+ return null;
182
+ }
183
+
184
+ return normalizeServedPath(`${safePath}${extension}`);
185
+ }
186
+
187
+ function joinServedPath(targetPath, childPath) {
188
+ const safePath = normalizeServedPath(targetPath);
189
+ if (!safePath) {
190
+ return null;
191
+ }
192
+
193
+ return normalizeServedPath(path.join(safePath, childPath));
194
+ }
195
+
196
+ function escapeInlineScriptContent(script) {
197
+ return script.replace(/--!>|-->|[<>\u2028\u2029]/g, (match) => {
198
+ switch (match) {
199
+ case '--!>':
200
+ return '--!\\u003E';
201
+ case '-->':
202
+ return '--\\u003E';
203
+ case '<':
204
+ return '\\u003C';
205
+ case '>':
206
+ return '\\u003E';
207
+ case '\u2028':
208
+ return '\\u2028';
209
+ case '\u2029':
210
+ return '\\u2029';
211
+ default:
212
+ return match;
213
+ }
214
+ });
215
+ }
216
+
217
+ function stringifyInlineScriptPayload(value, indent = 0) {
218
+ const json = escapeInlineScriptContent(JSON.stringify(value, null, 2));
219
+ if (indent <= 0) {
220
+ return json;
221
+ }
222
+
223
+ const padding = ' '.repeat(indent);
224
+ return json
225
+ .split('\n')
226
+ .map((line) => `${padding}${line}`)
227
+ .join('\n');
228
+ }
229
+
230
+ function stringifyInlineScriptLiteral(value) {
231
+ return escapeInlineScriptContent(JSON.stringify(value));
232
+ }
233
+
234
+ function normalizePreloadStorageType(storageType) {
235
+ return storageType === 'sessionStorage' ? 'sessionStorage' : 'localStorage';
236
+ }
237
+
238
+ function isHtmlWhitespaceChar(char) {
239
+ return char === ' ' || char === '\n' || char === '\r' || char === '\t' || char === '\f';
240
+ }
241
+
242
+ function isHtmlTagBoundary(char) {
243
+ return !char || isHtmlWhitespaceChar(char) || char === '>' || char === '/';
244
+ }
245
+
246
+ function findHtmlTagEnd(html, startIndex) {
247
+ let quote = null;
248
+
249
+ for (let index = startIndex; index < html.length; index += 1) {
250
+ const char = html[index];
251
+ if (quote) {
252
+ if (char === quote) {
253
+ quote = null;
254
+ }
255
+ continue;
256
+ }
257
+
258
+ if (char === '"' || char === '\'') {
259
+ quote = char;
260
+ continue;
261
+ }
262
+
263
+ if (char === '>') {
264
+ return index;
265
+ }
266
+ }
267
+
268
+ return -1;
269
+ }
270
+
271
+ function findScriptCloseTagStart(lower, searchIndex) {
272
+ let index = lower.indexOf('</script', searchIndex);
273
+
274
+ while (index !== -1) {
275
+ if (isHtmlTagBoundary(lower[index + 8])) {
276
+ return index;
277
+ }
278
+ index = lower.indexOf('</script', index + 8);
279
+ }
280
+
281
+ return -1;
282
+ }
283
+
284
+ function getHtmlAttributeValue(attributesSource, attributeName) {
285
+ const name = attributeName.toLowerCase();
286
+ let index = 0;
287
+
288
+ while (index < attributesSource.length) {
289
+ while (index < attributesSource.length && isHtmlWhitespaceChar(attributesSource[index])) {
290
+ index += 1;
291
+ }
292
+
293
+ if (index >= attributesSource.length) {
294
+ return null;
295
+ }
296
+
297
+ if (attributesSource[index] === '/') {
298
+ index += 1;
299
+ continue;
300
+ }
301
+
302
+ const nameStart = index;
303
+ while (
304
+ index < attributesSource.length
305
+ && !isHtmlWhitespaceChar(attributesSource[index])
306
+ && !['=', '>', '"', '\'', '`'].includes(attributesSource[index])
307
+ ) {
308
+ index += 1;
309
+ }
310
+ if (index === nameStart) {
311
+ index += 1;
312
+ continue;
313
+ }
314
+
315
+ const currentName = attributesSource.slice(nameStart, index).toLowerCase();
316
+
317
+ while (index < attributesSource.length && isHtmlWhitespaceChar(attributesSource[index])) {
318
+ index += 1;
319
+ }
320
+
321
+ if (attributesSource[index] !== '=') {
322
+ continue;
323
+ }
324
+ index += 1;
325
+
326
+ while (index < attributesSource.length && isHtmlWhitespaceChar(attributesSource[index])) {
327
+ index += 1;
328
+ }
329
+
330
+ if (index >= attributesSource.length) {
331
+ return currentName === name ? '' : null;
332
+ }
333
+
334
+ let value = '';
335
+ const quote = attributesSource[index];
336
+ if (quote === '"' || quote === '\'') {
337
+ index += 1;
338
+ const valueStart = index;
339
+ while (index < attributesSource.length && attributesSource[index] !== quote) {
340
+ index += 1;
341
+ }
342
+ value = attributesSource.slice(valueStart, index);
343
+ if (index < attributesSource.length) {
344
+ index += 1;
345
+ }
346
+ } else {
347
+ const valueStart = index;
348
+ while (
349
+ index < attributesSource.length
350
+ && !isHtmlWhitespaceChar(attributesSource[index])
351
+ && attributesSource[index] !== '>'
352
+ ) {
353
+ index += 1;
354
+ }
355
+ value = attributesSource.slice(valueStart, index);
356
+ }
357
+
358
+ if (currentName === name) {
359
+ return value;
360
+ }
361
+ }
362
+
363
+ return null;
364
+ }
365
+
366
+ function forEachHtmlScriptElement(html, visitor) {
367
+ const lower = html.toLowerCase();
368
+ let searchIndex = 0;
369
+
370
+ while (searchIndex < html.length) {
371
+ const openStart = lower.indexOf('<script', searchIndex);
372
+ if (openStart === -1) {
373
+ return;
374
+ }
375
+ if (!isHtmlTagBoundary(lower[openStart + 7])) {
376
+ searchIndex = openStart + 7;
377
+ continue;
378
+ }
379
+
380
+ const openEnd = findHtmlTagEnd(html, openStart);
381
+ if (openEnd === -1) {
382
+ searchIndex = openStart + 7;
383
+ continue;
384
+ }
385
+
386
+ const closeStart = findScriptCloseTagStart(lower, openEnd + 1);
387
+ if (closeStart === -1) {
388
+ searchIndex = openStart + 7;
389
+ continue;
390
+ }
391
+
392
+ const closeEnd = findHtmlTagEnd(html, closeStart);
393
+ if (closeEnd === -1) {
394
+ searchIndex = closeStart + 8;
395
+ continue;
396
+ }
397
+
398
+ const element = {
399
+ attributesSource: html.slice(openStart + 7, openEnd),
400
+ content: html.slice(openEnd + 1, closeStart),
401
+ fullMatch: html.slice(openStart, closeEnd + 1),
402
+ start: openStart,
403
+ end: closeEnd + 1,
404
+ };
405
+
406
+ if (visitor(element) === false) {
407
+ return;
408
+ }
409
+
410
+ searchIndex = closeEnd + 1;
411
+ }
412
+ }
413
+
414
+ function findFirstHtmlScriptElementByType(html, type) {
415
+ let found = null;
416
+
417
+ forEachHtmlScriptElement(html, (element) => {
418
+ const scriptType = getHtmlAttributeValue(element.attributesSource, 'type');
419
+ if ((scriptType ?? '').toLowerCase() !== type) {
420
+ return true;
421
+ }
422
+
423
+ found = element;
424
+ return false;
425
+ });
426
+
427
+ return found;
428
+ }
429
+
110
430
  /**
111
431
  * Secure path resolution:
112
432
  * - Strip leading slashes to treat URL as relative
@@ -126,18 +446,8 @@ function resolvePath(urlPath) {
126
446
  const relativePath = decoded.replace(/^\/+/, '').replace(/\\/g, '/');
127
447
 
128
448
  // Resolve to absolute path
129
- const fsPath = path.resolve(rootDir, relativePath);
130
-
131
- // Normalize for comparison (handles .., ., etc.)
132
- const normalizedPath = path.normalize(fsPath);
133
-
134
- // Security check: must be within rootDir
135
- // Use startsWith with path.sep to prevent /root matching /rootkit
136
- if (!normalizedPath.startsWith(rootDir + path.sep) && normalizedPath !== rootDir) {
137
- return null;
138
- }
139
-
140
- return normalizedPath;
449
+ const fsPath = path.resolve(rootDirAbs, relativePath);
450
+ return normalizeServedPath(fsPath);
141
451
  }
142
452
 
143
453
  function safeDecodeUrlPath(url) {
@@ -327,18 +637,17 @@ function createImportMapEntries(dalilaPath, sourceDirPath = '/src/') {
327
637
  }
328
638
 
329
639
  function createImportMapScript(dalilaPath, sourceDirPath = '/src/') {
330
- const payload = JSON.stringify({ imports: createImportMapEntries(dalilaPath, sourceDirPath) }, null, 2)
331
- .split('\n')
332
- .map(line => ` ${line}`)
333
- .join('\n');
640
+ const payload = stringifyInlineScriptPayload(
641
+ { imports: createImportMapEntries(dalilaPath, sourceDirPath) },
642
+ 4
643
+ );
334
644
 
335
645
  return ` <script type="importmap">\n${payload}\n </script>`;
336
646
  }
337
647
 
338
648
  function mergeImportMapIntoHtml(html, dalilaPath, sourceDirPath = '/src/') {
339
- const importMapPattern = /<script\b[^>]*type=["']importmap["'][^>]*>([\s\S]*?)<\/script>/i;
340
- const match = html.match(importMapPattern);
341
- if (!match) {
649
+ const importMapElement = findFirstHtmlScriptElementByType(html, 'importmap');
650
+ if (!importMapElement) {
342
651
  return {
343
652
  html,
344
653
  merged: false,
@@ -346,7 +655,7 @@ function mergeImportMapIntoHtml(html, dalilaPath, sourceDirPath = '/src/') {
346
655
  };
347
656
  }
348
657
 
349
- const existingPayload = match[1]?.trim() || '{}';
658
+ const existingPayload = importMapElement.content.trim() || '{}';
350
659
  let importMap;
351
660
  try {
352
661
  importMap = JSON.parse(existingPayload);
@@ -368,14 +677,11 @@ function mergeImportMapIntoHtml(html, dalilaPath, sourceDirPath = '/src/') {
368
677
  ...existingImports,
369
678
  },
370
679
  };
371
- const payload = JSON.stringify(mergedImportMap, null, 2)
372
- .split('\n')
373
- .map(line => ` ${line}`)
374
- .join('\n');
680
+ const payload = stringifyInlineScriptPayload(mergedImportMap, 4);
375
681
  const script = ` <script type="importmap">\n${payload}\n </script>`;
376
682
 
377
683
  return {
378
- html: html.replace(importMapPattern, ''),
684
+ html: `${html.slice(0, importMapElement.start)}${html.slice(importMapElement.end)}`,
379
685
  merged: true,
380
686
  script,
381
687
  };
@@ -468,14 +774,15 @@ function findTypeScriptFiles(dir, files = []) {
468
774
  * Generate inline preload script
469
775
  */
470
776
  function generatePreloadScript(name, defaultValue, storageType = 'localStorage') {
471
- const k = JSON.stringify(name);
472
- const d = JSON.stringify(defaultValue);
473
- const script = `(function(){try{var v=${storageType}.getItem(${k});document.documentElement.setAttribute('data-theme',v?JSON.parse(v):${d})}catch(e){document.documentElement.setAttribute('data-theme',${d})}})();`;
474
- return script
475
- .replace(/</g, '\\x3C')
476
- .replace(/-->/g, '--\\x3E')
477
- .replace(/\u2028/g, '\\u2028')
478
- .replace(/\u2029/g, '\\u2029');
777
+ const safeStorageType = normalizePreloadStorageType(storageType);
778
+ const payload = stringifyInlineScriptLiteral({
779
+ key: name,
780
+ defaultValue,
781
+ storageType: safeStorageType,
782
+ });
783
+ const fallbackValue = stringifyInlineScriptLiteral(defaultValue);
784
+ const script = `(function(){try{var p=${payload};var s=window[p.storageType];var v=s.getItem(p.key);document.documentElement.setAttribute('data-theme',v==null?p.defaultValue:JSON.parse(v))}catch(e){document.documentElement.setAttribute('data-theme',${fallbackValue})}})();`;
785
+ return script;
479
786
  }
480
787
 
481
788
  function renderPreloadScriptTags(baseDir) {
@@ -578,8 +885,8 @@ function addLoadingAttributes(html) {
578
885
  // ============================================================================
579
886
  // Binding Injection (for HTML files that need runtime bindings)
580
887
  // ============================================================================
581
- function injectBindings(html, requestPath) {
582
- const normalizedPath = normalizeHtmlRequestPath(requestPath);
888
+ function injectBindings(html, options = {}) {
889
+ const isPlaygroundPage = options.isPlaygroundPage === true;
583
890
  // Different paths for dalila repo vs user projects
584
891
  const dalilaPath = isDalilaRepo ? '/dist' : '/node_modules/dalila/dist';
585
892
  const sourceDirPath = buildProjectSourceDirPath(projectDir);
@@ -760,7 +1067,7 @@ function injectBindings(html, requestPath) {
760
1067
  }
761
1068
 
762
1069
  // Dalila repo: only inject import map for non-playground pages
763
- if (normalizedPath !== '/examples/playground/index.html') {
1070
+ if (!isPlaygroundPage) {
764
1071
  return injectHeadFragments(html, [importMap], {
765
1072
  beforeModule: true,
766
1073
  beforeStyles: true,
@@ -1004,7 +1311,9 @@ const server = http.createServer((req, res) => {
1004
1311
 
1005
1312
  let targetPath = fsPath;
1006
1313
  try {
1007
- const stat = fs.statSync(targetPath);
1314
+ const target = statServedPath(targetPath);
1315
+ targetPath = target.safePath;
1316
+ const stat = target.stat;
1008
1317
  if (stat.isDirectory()) {
1009
1318
  // Redirect directory URLs without trailing slash to include it
1010
1319
  if (!requestPath.endsWith('/')) {
@@ -1012,13 +1321,23 @@ const server = http.createServer((req, res) => {
1012
1321
  res.end();
1013
1322
  return;
1014
1323
  }
1015
- targetPath = path.join(targetPath, 'index.html');
1324
+ const indexPath = joinServedPath(targetPath, 'index.html');
1325
+ if (!indexPath) {
1326
+ send(res, 403, 'Forbidden');
1327
+ return;
1328
+ }
1329
+ targetPath = indexPath;
1016
1330
  }
1017
- } catch {
1331
+ } catch (err) {
1332
+ if (err && err.code === FORBIDDEN_PATH_ERROR_CODE) {
1333
+ send(res, 403, 'Forbidden');
1334
+ return;
1335
+ }
1336
+
1018
1337
  // If .js file not found, try .ts alternative
1019
1338
  if (targetPath.endsWith('.js')) {
1020
- const tsPath = targetPath.replace(/\.js$/, '.ts');
1021
- if (fs.existsSync(tsPath)) {
1339
+ const tsPath = replaceServedPathExtension(targetPath, '.js', '.ts');
1340
+ if (tsPath && existsServedPath(tsPath)) {
1022
1341
  targetPath = tsPath;
1023
1342
  } else {
1024
1343
  send(res, 404, 'Not Found');
@@ -1026,11 +1345,11 @@ const server = http.createServer((req, res) => {
1026
1345
  }
1027
1346
  } else {
1028
1347
  // Extensionless import — try .ts, then .js
1029
- const tsPath = targetPath + '.ts';
1030
- const jsPath = targetPath + '.js';
1031
- if (!path.extname(targetPath) && isScriptRequest && fs.existsSync(tsPath)) {
1348
+ const tsPath = appendServedPathExtension(targetPath, '.ts');
1349
+ const jsPath = appendServedPathExtension(targetPath, '.js');
1350
+ if (!path.extname(targetPath) && isScriptRequest && tsPath && existsServedPath(tsPath)) {
1032
1351
  targetPath = tsPath;
1033
- } else if (!path.extname(targetPath) && isScriptRequest && fs.existsSync(jsPath)) {
1352
+ } else if (!path.extname(targetPath) && isScriptRequest && jsPath && existsServedPath(jsPath)) {
1034
1353
  targetPath = jsPath;
1035
1354
  } else {
1036
1355
  const spaFallback = resolveSpaFallbackPath(requestPath);
@@ -1052,8 +1371,12 @@ const server = http.createServer((req, res) => {
1052
1371
  && isScriptRequest
1053
1372
  && !isNavigationRequest;
1054
1373
  if (isRawQuery || isHtmlModuleImport) {
1055
- fs.readFile(targetPath, 'utf8', (err, source) => {
1374
+ readServedFile(targetPath, 'utf8', (err, source) => {
1056
1375
  if (err) {
1376
+ if (err.code === FORBIDDEN_PATH_ERROR_CODE) {
1377
+ send(res, 403, 'Forbidden');
1378
+ return;
1379
+ }
1057
1380
  send(res, err.code === 'ENOENT' ? 404 : 500, err.code === 'ENOENT' ? 'Not Found' : 'Error');
1058
1381
  return;
1059
1382
  }
@@ -1069,8 +1392,12 @@ const server = http.createServer((req, res) => {
1069
1392
 
1070
1393
  // TypeScript transpilation (only if ts available)
1071
1394
  if (targetPath.endsWith('.ts') && ts) {
1072
- fs.readFile(targetPath, 'utf8', (err, source) => {
1395
+ readServedFile(targetPath, 'utf8', (err, source) => {
1073
1396
  if (err) {
1397
+ if (err.code === FORBIDDEN_PATH_ERROR_CODE) {
1398
+ send(res, 403, 'Forbidden');
1399
+ return;
1400
+ }
1074
1401
  if (err.code === 'ENOENT' || err.code === 'EISDIR') {
1075
1402
  send(res, 404, 'Not Found');
1076
1403
  return;
@@ -1104,15 +1431,23 @@ const server = http.createServer((req, res) => {
1104
1431
  }
1105
1432
 
1106
1433
  // Static file serving
1107
- fs.readFile(targetPath, (err, data) => {
1434
+ readServedFile(targetPath, null, (err, data) => {
1108
1435
  if (err) {
1436
+ if (err.code === FORBIDDEN_PATH_ERROR_CODE) {
1437
+ send(res, 403, 'Forbidden');
1438
+ return;
1439
+ }
1109
1440
  if (err.code === 'ENOENT' || err.code === 'EISDIR') {
1110
1441
  const spaFallback = resolveSpaFallbackPath(requestPath);
1111
1442
  if (spaFallback) {
1112
1443
  targetPath = spaFallback.fsPath;
1113
1444
  resolvedRequestPath = spaFallback.requestPath;
1114
- fs.readFile(targetPath, (fallbackErr, fallbackData) => {
1445
+ readServedFile(targetPath, null, (fallbackErr, fallbackData) => {
1115
1446
  if (fallbackErr) {
1447
+ if (fallbackErr.code === FORBIDDEN_PATH_ERROR_CODE) {
1448
+ send(res, 403, 'Forbidden');
1449
+ return;
1450
+ }
1116
1451
  send(res, 404, 'Not Found');
1117
1452
  return;
1118
1453
  }
@@ -1125,7 +1460,9 @@ const server = http.createServer((req, res) => {
1125
1460
  };
1126
1461
 
1127
1462
  if (shouldInjectBindings(resolvedRequestPath, htmlSource)) {
1128
- const html = injectBindings(htmlSource, resolvedRequestPath);
1463
+ const html = injectBindings(htmlSource, {
1464
+ isPlaygroundPage: normalizeHtmlRequestPath(resolvedRequestPath) === '/examples/playground/index.html',
1465
+ });
1129
1466
  writeResponseHead(res, 200, headers);
1130
1467
  res.end(html);
1131
1468
  return;
@@ -1157,7 +1494,9 @@ const server = http.createServer((req, res) => {
1157
1494
  if (ext === '.html') {
1158
1495
  const htmlSource = data.toString('utf8');
1159
1496
  if (shouldInjectBindings(resolvedRequestPath, htmlSource)) {
1160
- const html = injectBindings(htmlSource, resolvedRequestPath);
1497
+ const html = injectBindings(htmlSource, {
1498
+ isPlaygroundPage: normalizeHtmlRequestPath(resolvedRequestPath) === '/examples/playground/index.html',
1499
+ });
1161
1500
  writeResponseHead(res, 200, headers);
1162
1501
  res.end(html);
1163
1502
  return;