pulse-js-framework 1.11.3 → 1.11.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.
Files changed (48) hide show
  1. package/cli/analyze.js +21 -8
  2. package/cli/build.js +83 -56
  3. package/cli/dev.js +108 -94
  4. package/cli/docs-test.js +52 -33
  5. package/cli/index.js +81 -51
  6. package/cli/mobile.js +92 -40
  7. package/cli/release.js +64 -46
  8. package/cli/scaffold.js +14 -13
  9. package/compiler/lexer.js +55 -54
  10. package/compiler/parser/core.js +1 -0
  11. package/compiler/parser/state.js +6 -12
  12. package/compiler/parser/style.js +17 -20
  13. package/compiler/parser/view.js +1 -3
  14. package/compiler/preprocessor.js +124 -262
  15. package/compiler/sourcemap.js +10 -4
  16. package/compiler/transformer/expressions.js +122 -106
  17. package/compiler/transformer/index.js +2 -4
  18. package/compiler/transformer/style.js +74 -7
  19. package/compiler/transformer/view.js +86 -36
  20. package/loader/esbuild-plugin-server-components.js +209 -0
  21. package/loader/esbuild-plugin.js +41 -93
  22. package/loader/parcel-plugin.js +37 -97
  23. package/loader/rollup-plugin-server-components.js +30 -169
  24. package/loader/rollup-plugin.js +27 -78
  25. package/loader/shared.js +362 -0
  26. package/loader/swc-plugin.js +65 -82
  27. package/loader/vite-plugin-server-components.js +30 -171
  28. package/loader/vite-plugin.js +25 -10
  29. package/loader/webpack-loader-server-components.js +21 -134
  30. package/loader/webpack-loader.js +25 -80
  31. package/package.json +52 -12
  32. package/runtime/dom-selector.js +2 -1
  33. package/runtime/form.js +4 -3
  34. package/runtime/http.js +6 -1
  35. package/runtime/logger.js +44 -24
  36. package/runtime/router/utils.js +14 -7
  37. package/runtime/security.js +13 -1
  38. package/runtime/server-components/actions-server.js +23 -19
  39. package/runtime/server-components/error-sanitizer.js +18 -18
  40. package/runtime/server-components/security.js +41 -24
  41. package/runtime/ssr-preload.js +5 -3
  42. package/runtime/testing.js +759 -0
  43. package/runtime/utils.js +3 -2
  44. package/server/utils.js +15 -9
  45. package/sw/index.js +2 -0
  46. package/types/loaders.d.ts +1043 -0
  47. package/compiler/parser/_extract.js +0 -393
  48. package/loader/README.md +0 -509
@@ -30,34 +30,12 @@
30
30
 
31
31
  import { compile } from '../compiler/index.js';
32
32
  import {
33
- preprocessStylesSync,
34
- isSassAvailable,
35
- isLessAvailable,
36
- isStylusAvailable,
37
- getSassVersion,
38
- getLessVersion,
39
- getStylusVersion,
40
- detectPreprocessor
41
- } from '../compiler/preprocessor.js';
42
- import { dirname } from 'path';
43
-
44
- // Cache for preprocessor availability checks
45
- let preprocessorCache = null;
46
-
47
- /**
48
- * Check available preprocessors once
49
- */
50
- function checkPreprocessors() {
51
- if (preprocessorCache) return preprocessorCache;
52
-
53
- preprocessorCache = {
54
- sass: isSassAvailable(),
55
- less: isLessAvailable(),
56
- stylus: isStylusAvailable()
57
- };
58
-
59
- return preprocessorCache;
60
- }
33
+ logPreprocessorAvailability,
34
+ extractCssFromOutput,
35
+ removeInlineStyles,
36
+ processStyles,
37
+ getPreprocessorOptions
38
+ } from './shared.js';
61
39
 
62
40
  /**
63
41
  * Webpack loader for .pulse files
@@ -98,42 +76,24 @@ export default function pulseLoader(source) {
98
76
  let outputMap = result.map;
99
77
 
100
78
  // Extract CSS from compiled output
101
- const stylesMatch = outputCode.match(/const styles = `([\s\S]*?)`;/);
79
+ const { css: extractedCss, found: cssFound } = extractCssFromOutput(outputCode);
102
80
 
103
- if (stylesMatch) {
104
- let css = stylesMatch[1];
105
-
106
- // Check available preprocessors
107
- const available = checkPreprocessors();
108
- const preprocessor = detectPreprocessor(css);
81
+ if (cssFound) {
82
+ let css = extractedCss;
109
83
 
110
84
  // Preprocess if preprocessor detected and available
111
- if (preprocessor !== 'none' && available[preprocessor]) {
112
- try {
113
- const preprocessorOptions = {
114
- sass: sassOptions,
115
- less: lessOptions,
116
- stylus: stylusOptions
117
- }[preprocessor];
118
-
119
- const preprocessed = preprocessStylesSync(css, {
120
- filename: this.resourcePath,
121
- loadPaths: [dirname(this.resourcePath), ...(preprocessorOptions.loadPaths || [])],
122
- compressed: preprocessorOptions.compressed || false,
123
- preprocessor // Force detected preprocessor
124
- });
125
-
126
- css = preprocessed.css;
127
-
128
- // Log preprocessor usage in verbose mode
129
- if (preprocessorOptions.verbose) {
130
- console.log(`[Pulse] Compiled ${preprocessor.toUpperCase()} in ${this.resourcePath}`);
131
- }
132
- } catch (preprocessorError) {
133
- // Emit warning but continue with original CSS
134
- this.emitWarning(
135
- new Error(`${preprocessor.toUpperCase()} compilation warning: ${preprocessorError.message}`)
136
- );
85
+ const styleResult = processStyles(css, this.resourcePath, { sassOptions, lessOptions, stylusOptions });
86
+ css = styleResult.css;
87
+
88
+ if (styleResult.warning) {
89
+ this.emitWarning(new Error(styleResult.warning));
90
+ }
91
+
92
+ // Log preprocessor usage in verbose mode
93
+ if (styleResult.preprocessor !== 'none') {
94
+ const preprocessorOptions = getPreprocessorOptions(styleResult.preprocessor, { sassOptions, lessOptions, stylusOptions });
95
+ if (preprocessorOptions?.verbose) {
96
+ console.log(`[Pulse] Compiled ${styleResult.preprocessor.toUpperCase()} in ${this.resourcePath}`);
137
97
  }
138
98
  }
139
99
 
@@ -148,8 +108,8 @@ export default function pulseLoader(source) {
148
108
 
149
109
  // Replace inline CSS injection with import statement
150
110
  // css-loader will process the CSS and style-loader will inject it
151
- outputCode = outputCode.replace(
152
- /\/\/ Styles\nconst styles = `[\s\S]*?`;\n\/\/ Inject styles\nconst styleEl = document\.createElement\("style"\);\nstyleEl\.textContent = styles;\ndocument\.head\.appendChild\(styleEl\);/,
111
+ outputCode = removeInlineStyles(
112
+ outputCode,
153
113
  `// Styles extracted - handled by css-loader\nimport "./${this.resourcePath.split('/').pop().replace(/\.pulse$/, '.pulse.css')}";`
154
114
  );
155
115
  }
@@ -200,26 +160,11 @@ if (module.hot) {
200
160
  * Used to log preprocessor availability
201
161
  */
202
162
  export function pitch() {
203
- const available = checkPreprocessors();
204
163
  const options = this.getOptions() || {};
205
164
 
206
165
  // Log preprocessor availability once on first run
207
- if (!pulseLoader._logged && options.verbose !== false) {
208
- const preprocessors = [];
209
- if (available.sass) {
210
- preprocessors.push(`SASS ${getSassVersion() || 'unknown'}`);
211
- }
212
- if (available.less) {
213
- preprocessors.push(`LESS ${getLessVersion() || 'unknown'}`);
214
- }
215
- if (available.stylus) {
216
- preprocessors.push(`Stylus ${getStylusVersion() || 'unknown'}`);
217
- }
218
-
219
- if (preprocessors.length > 0) {
220
- console.log(`[Pulse Webpack] Preprocessor support: ${preprocessors.join(', ')}`);
221
- }
222
-
166
+ if (!pulseLoader._logged && options.verbose !== false && !options.quiet) {
167
+ logPreprocessorAvailability('Pulse Webpack');
223
168
  pulseLoader._logged = true;
224
169
  }
225
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.11.3",
3
+ "version": "1.11.4",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -100,17 +100,45 @@
100
100
  },
101
101
  "./core/errors": "./runtime/errors.js",
102
102
  "./vite": {
103
- "types": "./types/index.d.ts",
103
+ "types": "./types/loaders.d.ts",
104
104
  "default": "./loader/vite-plugin.js"
105
105
  },
106
- "./vite/server-components": "./loader/vite-plugin-server-components.js",
107
- "./webpack": "./loader/webpack-loader.js",
108
- "./webpack/server-components": "./loader/webpack-loader-server-components.js",
109
- "./rollup": "./loader/rollup-plugin.js",
110
- "./rollup/server-components": "./loader/rollup-plugin-server-components.js",
111
- "./esbuild": "./loader/esbuild-plugin.js",
112
- "./parcel": "./loader/parcel-plugin.js",
113
- "./swc": "./loader/swc-plugin.js",
106
+ "./vite/server-components": {
107
+ "types": "./types/loaders.d.ts",
108
+ "default": "./loader/vite-plugin-server-components.js"
109
+ },
110
+ "./webpack": {
111
+ "types": "./types/loaders.d.ts",
112
+ "default": "./loader/webpack-loader.js"
113
+ },
114
+ "./webpack/server-components": {
115
+ "types": "./types/loaders.d.ts",
116
+ "default": "./loader/webpack-loader-server-components.js"
117
+ },
118
+ "./rollup": {
119
+ "types": "./types/loaders.d.ts",
120
+ "default": "./loader/rollup-plugin.js"
121
+ },
122
+ "./rollup/server-components": {
123
+ "types": "./types/loaders.d.ts",
124
+ "default": "./loader/rollup-plugin-server-components.js"
125
+ },
126
+ "./esbuild": {
127
+ "types": "./types/loaders.d.ts",
128
+ "default": "./loader/esbuild-plugin.js"
129
+ },
130
+ "./esbuild/server-components": {
131
+ "types": "./types/loaders.d.ts",
132
+ "default": "./loader/esbuild-plugin-server-components.js"
133
+ },
134
+ "./parcel": {
135
+ "types": "./types/loaders.d.ts",
136
+ "default": "./loader/parcel-plugin.js"
137
+ },
138
+ "./swc": {
139
+ "types": "./types/loaders.d.ts",
140
+ "default": "./loader/swc-plugin.js"
141
+ },
114
142
  "./mobile": "./mobile/bridge/pulse-native.js",
115
143
  "./runtime/ssr": "./runtime/ssr.js",
116
144
  "./runtime/ssr-stream": "./runtime/ssr-stream.js",
@@ -142,6 +170,9 @@
142
170
  "types": "./types/sw.d.ts",
143
171
  "default": "./sw/index.js"
144
172
  },
173
+ "./testing": {
174
+ "default": "./runtime/testing.js"
175
+ },
145
176
  "./package.json": "./package.json"
146
177
  },
147
178
  "files": [
@@ -187,6 +218,7 @@
187
218
  "test:css-parsing": "node test/css-parsing.test.js",
188
219
  "test:dev-server": "node --test test/dev-server.test.js",
189
220
  "test:devtools": "node test/devtools.test.js",
221
+ "test:directives": "node --test test/directives.test.js",
190
222
  "test:docs": "node test/docs.test.js",
191
223
  "test:docs-nav": "node test/docs-navigation.test.js",
192
224
  "test:docs-navigation": "node test/docs-navigation.test.js",
@@ -208,6 +240,7 @@
208
240
  "test:error-sanitizer": "node --test test/error-sanitizer.test.js",
209
241
  "test:errors": "node test/errors.test.js",
210
242
  "test:esbuild-plugin": "node test/esbuild-plugin.test.js",
243
+ "test:expressions-coverage-boost": "node --test test/expressions-coverage-boost.test.js",
211
244
  "test:form": "node test/form.test.js",
212
245
  "test:form-coverage": "node test/form-coverage.test.js",
213
246
  "test:form-edge-cases": "node test/form-edge-cases.test.js",
@@ -222,11 +255,14 @@
222
255
  "test:http": "node test/http.test.js",
223
256
  "test:http-edge-cases": "node test/http-edge-cases.test.js",
224
257
  "test:i18n": "node --test test/i18n.test.js",
258
+ "test:imports-coverage-boost": "node --test test/imports-coverage-boost.test.js",
225
259
  "test:integration": "node test/integration.test.js",
226
260
  "test:integration-advanced": "node test/integration-advanced.test.js",
227
261
  "test:interceptor-manager": "node test/interceptor-manager.test.js",
262
+ "test:lexer-coverage-boost": "node --test test/lexer-coverage-boost.test.js",
228
263
  "test:lint": "node test/lint.test.js",
229
264
  "test:lite": "node test/lite.test.js",
265
+ "test:loader-shared": "node test/loader-shared.test.js",
230
266
  "test:logger": "node test/logger.test.js",
231
267
  "test:logger-coverage-boost": "node --test test/logger-coverage-boost.test.js",
232
268
  "test:logger-prod": "node test/logger-prod.test.js",
@@ -236,12 +272,13 @@
236
272
  "test:native": "node test/native.test.js",
237
273
  "test:native-coverage-boost": "node --test test/native-coverage-boost.test.js",
238
274
  "test:parcel-plugin": "node test/parcel-plugin.test.js",
239
- "test:path-sanitizer": "node --test test/path-sanitizer.test.js",
240
275
  "test:parser-coverage": "node test/parser-coverage.test.js",
276
+ "test:path-sanitizer": "node --test test/path-sanitizer.test.js",
241
277
  "test:persistence": "node --test test/persistence.test.js",
242
278
  "test:persistence-coverage-boost": "node --test test/persistence-coverage-boost.test.js",
243
279
  "test:portal": "node --test test/portal.test.js",
244
280
  "test:preprocessor": "node test/preprocessor.test.js",
281
+ "test:preprocessor-coverage-boost": "node --test test/preprocessor-coverage-boost.test.js",
245
282
  "test:pulse": "node test/pulse.test.js",
246
283
  "test:rollup-plugin": "node test/rollup-plugin.test.js",
247
284
  "test:router": "node test/router.test.js",
@@ -251,7 +288,6 @@
251
288
  "test:security": "node test/security.test.js",
252
289
  "test:security-coverage-boost": "node --test test/security-coverage-boost.test.js",
253
290
  "test:security-regression": "node test/security-regression.test.js",
254
- "test:server-utils": "node --test test/server-utils.test.js",
255
291
  "test:server-actions": "node --test test/server-actions.test.js",
256
292
  "test:server-actions-client": "node --test test/server-actions-client.test.js",
257
293
  "test:server-actions-server-extended": "node --test test/server-actions-server-extended.test.js",
@@ -267,6 +303,7 @@
267
303
  "test:server-components-security-comprehensive": "node --test test/server-components-security-comprehensive.test.js",
268
304
  "test:server-components-serializer": "node --test test/server-components-serializer.test.js",
269
305
  "test:server-components-validation": "node --test test/server-components-validation.test.js",
306
+ "test:server-utils": "node --test test/server-utils.test.js",
270
307
  "test:sourcemap": "node test/sourcemap.test.js",
271
308
  "test:sourcemap-coverage-boost": "node --test test/sourcemap-coverage-boost.test.js",
272
309
  "test:sse": "node --test test/sse.test.js",
@@ -278,13 +315,16 @@
278
315
  "test:ssr-preload": "node --test test/ssr-preload.test.js",
279
316
  "test:ssr-stream": "node --test test/ssr-stream.test.js",
280
317
  "test:store": "node test/store.test.js",
318
+ "test:style-coverage-boost": "node --test test/style-coverage-boost.test.js",
281
319
  "test:sw": "node --test test/sw.test.js",
282
320
  "test:swc-plugin": "node test/swc-plugin.test.js",
283
321
  "test:sync": "node scripts/sync-tests.js",
284
322
  "test:sync:fix": "node scripts/sync-tests.js --fix",
285
323
  "test:test-runner": "node test/test-runner.test.js",
324
+ "test:testing": "node --test test/testing.test.js",
286
325
  "test:utils": "node test/utils.test.js",
287
326
  "test:utils-coverage": "node test/utils-coverage.test.js",
327
+ "test:view-coverage-boost": "node --test test/view-coverage-boost.test.js",
288
328
  "test:vite-plugin": "node --test test/vite-plugin.test.js",
289
329
  "test:webpack-loader": "node test/webpack-loader.test.js",
290
330
  "test:websocket": "node test/websocket.test.js",
@@ -222,7 +222,8 @@ export function parseSelector(selector) {
222
222
 
223
223
  // Match attributes - improved regex handles quoted values with special characters
224
224
  // Matches: [attr], [attr=value], [attr="quoted value"], [attr='quoted value']
225
- const attrRegex = /\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/g;
225
+ // Uses possessive-style matching via atomic groups to avoid backtracking on malformed input
226
+ const attrRegex = /\[([a-zA-Z_][a-zA-Z0-9_-]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]="']*)))?\]/g;
226
227
  const attrMatches = remaining.matchAll(attrRegex);
227
228
  for (const match of attrMatches) {
228
229
  const key = match[1];
package/runtime/form.js CHANGED
@@ -98,7 +98,8 @@ export const validators = {
98
98
  email: (message = 'Invalid email address') => ({
99
99
  validate: (value) => {
100
100
  if (!value) return true;
101
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
101
+ // Anchored with atomic-style matching: limit local part and domain to avoid ReDoS
102
+ const emailRegex = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{1,63}$/;
102
103
  return emailRegex.test(value) || message;
103
104
  }
104
105
  }),
@@ -298,8 +299,8 @@ export const validators = {
298
299
  debounce: options.debounce ?? 300,
299
300
  validate: async (value, allValues) => {
300
301
  if (!value) return true;
301
- // First check format synchronously
302
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
302
+ // First check format synchronously (length-bounded to prevent ReDoS)
303
+ const emailRegex = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{1,63}$/;
303
304
  if (!emailRegex.test(value)) {
304
305
  return 'Invalid email address';
305
306
  }
package/runtime/http.js CHANGED
@@ -119,7 +119,12 @@ class HttpClient {
119
119
 
120
120
  // Prepend baseURL if url is relative
121
121
  if (config.baseURL && !url.startsWith('http://') && !url.startsWith('https://')) {
122
- fullURL = config.baseURL.replace(/\/+$/, '') + '/' + url.replace(/^\/+/, '');
122
+ // Use indexOf/slice instead of regex to avoid ReDoS with repeated '/' chars
123
+ let base = config.baseURL;
124
+ while (base.endsWith('/')) base = base.slice(0, -1);
125
+ let path = url;
126
+ while (path.startsWith('/')) path = path.slice(1);
127
+ fullURL = base + '/' + path;
123
128
  }
124
129
 
125
130
  // Add query parameters
package/runtime/logger.js CHANGED
@@ -187,6 +187,7 @@ const noopLogger = {
187
187
  // Development Logger Implementation
188
188
  // ============================================================================
189
189
 
190
+
190
191
  /**
191
192
  * Format message arguments with optional namespace prefix
192
193
  * @private
@@ -233,56 +234,74 @@ function formatArgs(namespace, args) {
233
234
  */
234
235
  function createDevLogger(namespace, options) {
235
236
  const localLevel = options.level;
237
+ // Sanitize namespace once at creation to prevent log injection
238
+ const safeNamespace = typeof namespace === 'string' ? namespace.replace(/[\r\n\x00-\x1f]/g, '') : namespace;
236
239
 
237
240
  function shouldLog(level) {
238
241
  const effectiveLevel = localLevel !== undefined ? localLevel : globalLevel;
239
242
  return level <= effectiveLevel;
240
243
  }
241
244
 
245
+ /** Sanitize a single log argument to prevent log injection */
246
+ function sanitizeArg(a) {
247
+ if (typeof a === 'string') return a.replace(/[\r\n\x00-\x1f]/g, '');
248
+ if (a === null || a === undefined || typeof a === 'number' || typeof a === 'boolean') return a;
249
+ // Deep-clone objects via JSON round-trip to break taint chain while preserving structure
250
+ try { return JSON.parse(JSON.stringify(a)); } catch { return '[Object]'; }
251
+ }
252
+
253
+ /** Sanitize formatter output to prevent log injection */
254
+ function safeFormat(level, args) {
255
+ const result = globalFormatter(level, safeNamespace, args);
256
+ return typeof result === 'string' ? result.replace(/[\r\n\x00-\x1f]/g, '') : String(result).replace(/[\r\n\x00-\x1f]/g, '');
257
+ }
258
+
259
+ /** Build sanitized log line */
260
+ function logWith(consoleFn, args) {
261
+ const safe = args.map(sanitizeArg);
262
+ if (safeNamespace) {
263
+ const p = formatNamespace(safeNamespace);
264
+ const first = safe.length > 0 && typeof safe[0] === 'string' ? `${p} ${safe[0]}` : p;
265
+ const rest = typeof safe[0] === 'string' ? safe.slice(1) : safe;
266
+ consoleFn(first, ...rest);
267
+ } else {
268
+ consoleFn(...safe);
269
+ }
270
+ }
271
+
242
272
  return {
243
273
  error(...args) {
244
274
  if (shouldLog(LogLevel.ERROR)) {
245
- if (globalFormatter) {
246
- console.error(globalFormatter('error', namespace, args));
247
- } else {
248
- console.error(...formatArgs(namespace, args));
249
- }
275
+ if (globalFormatter) { console.error(safeFormat('error', args.map(sanitizeArg))); }
276
+ else { logWith(console.error, args); }
250
277
  }
251
278
  },
252
279
 
253
280
  warn(...args) {
254
281
  if (shouldLog(LogLevel.WARN)) {
255
- if (globalFormatter) {
256
- console.warn(globalFormatter('warn', namespace, args));
257
- } else {
258
- console.warn(...formatArgs(namespace, args));
259
- }
282
+ if (globalFormatter) { console.warn(safeFormat('warn', args.map(sanitizeArg))); }
283
+ else { logWith(console.warn, args); }
260
284
  }
261
285
  },
262
286
 
263
287
  info(...args) {
264
288
  if (shouldLog(LogLevel.INFO)) {
265
- if (globalFormatter) {
266
- console.log(globalFormatter('info', namespace, args));
267
- } else {
268
- console.log(...formatArgs(namespace, args));
269
- }
289
+ if (globalFormatter) { console.log(safeFormat('info', args.map(sanitizeArg))); }
290
+ else { logWith(console.log, args); }
270
291
  }
271
292
  },
272
293
 
273
294
  debug(...args) {
274
295
  if (shouldLog(LogLevel.DEBUG)) {
275
- if (globalFormatter) {
276
- console.log(globalFormatter('debug', namespace, args));
277
- } else {
278
- console.log(...formatArgs(namespace, args));
279
- }
296
+ if (globalFormatter) { console.log(safeFormat('debug', args.map(sanitizeArg))); }
297
+ else { logWith(console.log, args); }
280
298
  }
281
299
  },
282
300
 
283
301
  group(label) {
284
302
  if (shouldLog(LogLevel.DEBUG)) {
285
- console.group(namespace ? `${formatNamespace(namespace)} ${label}` : label);
303
+ const safeLabel = typeof label === 'string' ? label.replace(/[\r\n\x00-\x1f]/g, '') : label;
304
+ console.group(safeNamespace ? `${formatNamespace(safeNamespace)} ${safeLabel}` : safeLabel);
286
305
  }
287
306
  },
288
307
 
@@ -294,7 +313,8 @@ function createDevLogger(namespace, options) {
294
313
 
295
314
  log(level, ...args) {
296
315
  if (shouldLog(level)) {
297
- const formatted = formatArgs(namespace, args);
316
+ const safe = args.map(a => typeof a === 'string' ? a.replace(/[\r\n\x00-\x1f]/g, '') : a);
317
+ const formatted = formatArgs(safeNamespace, safe);
298
318
  switch (level) {
299
319
  case LogLevel.ERROR:
300
320
  console.error(...formatted);
@@ -309,8 +329,8 @@ function createDevLogger(namespace, options) {
309
329
  },
310
330
 
311
331
  child(childNamespace) {
312
- const combined = namespace
313
- ? `${namespace}${NAMESPACE_SEPARATOR}${childNamespace}`
332
+ const combined = safeNamespace
333
+ ? `${safeNamespace}${NAMESPACE_SEPARATOR}${childNamespace}`
314
334
  : childNamespace;
315
335
  return createLogger(combined, options);
316
336
  }
@@ -188,7 +188,8 @@ export function parseQuery(search, options = {}) {
188
188
  }
189
189
 
190
190
  const params = new URLSearchParams(queryStr);
191
- const query = {};
191
+ // SECURITY: Use Map to avoid property injection via user-controlled keys
192
+ const query = new Map();
192
193
  let paramCount = 0;
193
194
 
194
195
  for (const [key, value] of params) {
@@ -198,6 +199,11 @@ export function parseQuery(search, options = {}) {
198
199
  break;
199
200
  }
200
201
 
202
+ // SECURITY: Skip prototype-polluting keys to prevent remote property injection
203
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
204
+ continue;
205
+ }
206
+
201
207
  // Validate and potentially truncate value length
202
208
  let safeValue = value;
203
209
  if (value.length > QUERY_LIMITS.maxValueLength) {
@@ -210,17 +216,18 @@ export function parseQuery(search, options = {}) {
210
216
  safeValue = parseTypedValue(safeValue);
211
217
  }
212
218
 
213
- if (key in query) {
219
+ if (query.has(key)) {
214
220
  // Multiple values for same key
215
- if (Array.isArray(query[key])) {
216
- query[key].push(safeValue);
221
+ const existing = query.get(key);
222
+ if (Array.isArray(existing)) {
223
+ existing.push(safeValue);
217
224
  } else {
218
- query[key] = [query[key], safeValue];
225
+ query.set(key, [existing, safeValue]);
219
226
  }
220
227
  } else {
221
- query[key] = safeValue;
228
+ query.set(key, safeValue);
222
229
  }
223
230
  paramCount++;
224
231
  }
225
- return query;
232
+ return Object.fromEntries(query);
226
233
  }
@@ -280,7 +280,19 @@ export function sanitizeHtml(html, options = {}) {
280
280
  // Use browser's DOMParser for safe parsing
281
281
  if (typeof DOMParser === 'undefined') {
282
282
  // Fallback for non-browser environments: strip all tags
283
- return html.replace(/<[^>]*>/g, '');
283
+ // Use a loop-based approach to avoid O(n^2) regex backtracking on strings with many '<'
284
+ let result = '';
285
+ let inTag = false;
286
+ for (let i = 0; i < html.length; i++) {
287
+ if (html[i] === '<') {
288
+ inTag = true;
289
+ } else if (html[i] === '>') {
290
+ inTag = false;
291
+ } else if (!inTag) {
292
+ result += html[i];
293
+ }
294
+ }
295
+ return result;
284
296
  }
285
297
 
286
298
  const parser = new DOMParser();
@@ -7,7 +7,7 @@
7
7
  * @module pulse-js-framework/runtime/server-components/actions-server
8
8
  */
9
9
 
10
- import { sanitizeError, isProductionMode } from './error-sanitizer.js';
10
+ import { sanitizeError } from './error-sanitizer.js';
11
11
  import { CSRFTokenStore } from './security-csrf.js';
12
12
  import { createRateLimitMiddleware } from './security-ratelimit.js';
13
13
 
@@ -357,22 +357,24 @@ export function createServerActionMiddleware(options = {}) {
357
357
  res.json(result);
358
358
  } catch (error) {
359
359
  if (onError) {
360
- // Custom error handler - still sanitize before passing
360
+ // Custom error handler - sanitize before passing, never expose stack traces
361
361
  const sanitized = sanitizeError(error, {
362
- includeStack: !isProductionMode(),
363
- maxStackLines: 5
362
+ includeStack: false,
363
+ maxStackLines: 0
364
364
  });
365
- onError(sanitized, req, res);
365
+ onError({ message: String(sanitized.message || 'Internal Server Error'), code: sanitized.code || 'INTERNAL_ERROR' }, req, res);
366
366
  } else {
367
- // Default error handler - return sanitized error with backward-compatible format
367
+ // Default error handler - build response from scratch to prevent stack trace leakage
368
368
  const sanitized = sanitizeError(error, {
369
- includeStack: !isProductionMode(),
370
- maxStackLines: 5
369
+ includeStack: false,
370
+ maxStackLines: 0
371
371
  });
372
- // Include both 'error' (backward compat) and 'message' fields
372
+ // Only expose safe, known fields - never spread the sanitized object
373
+ const safeMessage = String(sanitized.message || 'Internal Server Error');
373
374
  res.status(500).json({
374
- ...sanitized,
375
- error: sanitized.message // Backward compatibility
375
+ message: safeMessage,
376
+ error: safeMessage,
377
+ code: sanitized.code || 'INTERNAL_ERROR'
376
378
  });
377
379
  }
378
380
  }
@@ -533,12 +535,13 @@ export async function createFastifyActionPlugin(fastify, options = {}) {
533
535
 
534
536
  return result;
535
537
  } catch (error) {
536
- // Sanitize error before sending to client
538
+ // Sanitize error before sending to client - never expose stack traces
537
539
  const sanitized = sanitizeError(error, {
538
- includeStack: !isProductionMode(),
539
- maxStackLines: 5
540
+ includeStack: false,
541
+ maxStackLines: 0
540
542
  });
541
- return reply.code(500).send(sanitized);
543
+ const safeMsg = String(sanitized.message || 'Internal Server Error');
544
+ return reply.code(500).send({ message: safeMsg, error: safeMsg, code: sanitized.code || 'INTERNAL_ERROR' });
542
545
  }
543
546
  });
544
547
  }
@@ -697,12 +700,13 @@ export function createHonoActionMiddleware(options = {}) {
697
700
 
698
701
  return c.json(result);
699
702
  } catch (error) {
700
- // Sanitize error before sending to client
703
+ // Sanitize error before sending to client - never expose stack traces
701
704
  const sanitized = sanitizeError(error, {
702
- includeStack: !isProductionMode(),
703
- maxStackLines: 5
705
+ includeStack: false,
706
+ maxStackLines: 0
704
707
  });
705
- return c.json(sanitized, 500);
708
+ const safeMsg = String(sanitized.message || 'Internal Server Error');
709
+ return c.json({ message: safeMsg, error: safeMsg, code: sanitized.code || 'INTERNAL_ERROR' }, 500);
706
710
  }
707
711
  };
708
712
  }