boxwood 0.80.0 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +11 -1
  2. package/index.js +306 -32
  3. package/package.json +8 -14
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  7. i18n support
17
17
  8. server side
18
18
  9. good for seo
19
- 10. small (1 file, 700 LOC~)
19
+ 10. small (1 file, 890 LOC~)
20
20
  11. easy to start, familiar syntax
21
21
  12. easy to test
22
22
 
@@ -136,6 +136,16 @@ test("banner renders an optional description", async () => {
136
136
 
137
137
  You can check the `test` dir for more examples.
138
138
 
139
+ ## Security
140
+
141
+ By default, boxwood sanitizes all HTML, SVG and i18n content loaded via its API to protect against basic XSS attacks.
142
+
143
+ Disabling sanitization ({ sanitize: false }) is only safe for trusted, developer-controlled files. Never use it with user-generated or untrusted content.
144
+
145
+ All file access is restricted to the project directory and symlinks are not allowed by default to prevent path traversal attacks.
146
+
147
+ That said, the library is pretty small so please review it and suggest improvements if you have any.
148
+
139
149
  ## Maintainers
140
150
 
141
151
  [@emilos](https://github.com/emilos)
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
- const { join } = require("path")
2
- const { readFileSync } = require("fs")
1
+ const { join, resolve, sep: separator } = require("path")
2
+ const { readFileSync, realpathSync, lstatSync } = require("fs")
3
3
  const csstree = require("css-tree")
4
4
 
5
5
  function compile(path) {
@@ -106,6 +106,86 @@ const escapeHTML = (string) => {
106
106
  })
107
107
  }
108
108
 
109
+ const normalizePath = (path) => path.replace(/\\/g, "/").replace(/\/+$/, "")
110
+
111
+ const ALLOWED_RAW_EXTENSIONS = ["html", "txt"]
112
+ const ALLOWED_CODE_EXTENSIONS = ["js", "css", "json"]
113
+ const ALLOWED_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg"]
114
+ const ALLOWED_READ_EXTENSIONS = [
115
+ ...ALLOWED_RAW_EXTENSIONS,
116
+ ...ALLOWED_IMAGE_EXTENSIONS,
117
+ ...ALLOWED_CODE_EXTENSIONS,
118
+ ]
119
+
120
+ function validateSymlinks(path, base) {
121
+ let relative = path.slice(base.length + 1).split(separator)
122
+ let current = base
123
+ for (const part of relative) {
124
+ if (!part) continue
125
+ current = resolve(current, part)
126
+ if (lstatSync(current).isSymbolicLink()) {
127
+ throw new Error(`FileError: symlinks are not allowed ("${current}")`)
128
+ }
129
+ }
130
+ }
131
+
132
+ function validateFile(path, base) {
133
+ const normalizedPath = normalizePath(path)
134
+ const normalizedBase = normalizePath(base)
135
+
136
+ const type = extension(normalizedPath)
137
+
138
+ if (!type) {
139
+ throw new Error(`FileError: path "${path}" has no extension`)
140
+ }
141
+
142
+ if (!ALLOWED_READ_EXTENSIONS.includes(type)) {
143
+ throw new Error(
144
+ `FileError: unsupported file type "${type}" for path "${path}"`
145
+ )
146
+ }
147
+
148
+ const stats = lstatSync(normalizedPath)
149
+ if (!stats.isFile()) {
150
+ throw new Error(`FileError: path "${path}" is not a file`)
151
+ }
152
+
153
+ if (stats.isSymbolicLink()) {
154
+ throw new Error(`FileError: path "${path}" is a symbolic link`)
155
+ }
156
+
157
+ if (normalizedPath === normalizedBase) {
158
+ throw new Error(
159
+ `FileError: path "${path}" is the same as the current working directory "${base}"`
160
+ )
161
+ }
162
+
163
+ if (!normalizedPath.startsWith(normalizedBase + "/")) {
164
+ throw new Error(
165
+ `FileError: real path "${realPath}" is not within the current working directory "${realBase}"`
166
+ )
167
+ }
168
+ }
169
+
170
+ function readFile(path, encoding) {
171
+ try {
172
+ const base = process.cwd()
173
+ const absoluteBase = resolve(base)
174
+ const absolutePath = resolve(path)
175
+ const realBase = realpathSync(absoluteBase)
176
+ const realPath = realpathSync(absolutePath)
177
+
178
+ validateSymlinks(realPath, realBase)
179
+ validateFile(realPath, realBase)
180
+
181
+ return readFileSync(path, encoding)
182
+ } catch (exception) {
183
+ throw new Error(
184
+ `FileError: cannot read file "${path}": ${exception.message}`
185
+ )
186
+ }
187
+ }
188
+
109
189
  const BOOLEAN_ATTRIBUTES = [
110
190
  "async",
111
191
  "autofocus",
@@ -150,9 +230,17 @@ const ALIASES = {
150
230
  htmlFor: "for",
151
231
  }
152
232
 
233
+ const isKeyValid = (key) => /^[a-zA-Z0-9\-_]+$/.test(key)
234
+
153
235
  const attributes = (options) => {
236
+ if (!options) {
237
+ return ""
238
+ }
154
239
  const result = []
155
240
  for (const key in options) {
241
+ if (!isKeyValid(key)) {
242
+ continue
243
+ }
156
244
  const value = options[key]
157
245
  if (
158
246
  typeof value === "string" ||
@@ -166,10 +254,25 @@ const attributes = (options) => {
166
254
  const name = ALIASES[key] || key
167
255
  const value = options[key]
168
256
  const content = Array.isArray(value) ? classes(...value) : value
169
- result.push(name + "=" + '"' + content + '"')
257
+ result.push(name + "=" + '"' + escapeHTML(content) + '"')
258
+ }
259
+ } else if (key === "style" && typeof value === "object") {
260
+ const styles = []
261
+ for (const param in value) {
262
+ if (!isKeyValid(param)) {
263
+ continue
264
+ }
265
+ const result = value[param]
266
+ if (result) {
267
+ styles.push(`${decamelize(param)}:${escapeHTML(result)}`)
268
+ }
269
+ }
270
+ if (styles.length > 0) {
271
+ result.push(`style="${styles.join(";")}"`)
170
272
  }
171
273
  }
172
274
  }
275
+
173
276
  return result.join(" ")
174
277
  }
175
278
 
@@ -229,9 +332,12 @@ const render = (input, escape = true) => {
229
332
  return `<${input.name}>`
230
333
  }
231
334
 
335
+ const string = input.attributes ? attributes(input.attributes) : ""
336
+ const attrs = string ? " " + string : ""
337
+
232
338
  return (
233
339
  `<${input.name}` +
234
- (input.attributes ? " " + attributes(input.attributes) : "") +
340
+ attrs +
235
341
  ">" +
236
342
  render(input.children, isUnescapedTag(input.name)) +
237
343
  `</${input.name}>`
@@ -242,9 +348,67 @@ const raw = (children) => {
242
348
  return { name: "raw", children }
243
349
  }
244
350
 
245
- raw.load = function () {
246
- const path = join(...arguments)
247
- const content = readFileSync(path, "utf8")
351
+ /**
352
+ * Never trust HTML files from untrusted sources.
353
+ *
354
+ * This function is a basic sanitization of HTML content in case
355
+ * you've accidentally included a "trusted", but malicious HTML file that was downloaded
356
+ * from the internet or other untrusted sources.
357
+ *
358
+ * This function removes script and style tags, inline event handlers,
359
+ * and any href attributes that use JavaScript. It does not
360
+ * guarantee complete security, but it helps to mitigate some common
361
+ * XSS attacks that can be embedded in HTML files.
362
+ *
363
+ * It is recommended to check all HTML files before using them
364
+ * in your application.
365
+ *
366
+ * Never trust user-generated content.
367
+ */
368
+
369
+ const sanitizeHTML = (content) => {
370
+ return content
371
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
372
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
373
+ .replace(/\son\w+="[^"]*"/gi, "")
374
+ .replace(/\son\w+='[^']*'/gi, "")
375
+ .replace(/(href|xlink:href)\s*=\s*(['"])javascript:[^'"]*\2/gi, "")
376
+ }
377
+
378
+ /*
379
+ * Raw content is a special case where we want to allow
380
+ * unescaped HTML content to be rendered directly.
381
+ * This is useful for cases where we want to
382
+ * include HTML fragments or templates that are
383
+ * not meant to be escaped, like large blocks of HTML,
384
+ * or when integrating with third-party libraries
385
+ * that require raw HTML.
386
+ *
387
+ * Please note that this should be used with caution,
388
+ * as it can lead to XSS vulnerabilities if the content
389
+ * is not properly sanitized.
390
+ *
391
+ * It should only be used for trusted content
392
+ * or in controlled environments.
393
+ *
394
+ * Should not be used for user-generated content.
395
+ */
396
+
397
+ raw.load = function (path, options = {}) {
398
+ const type = extension(path)
399
+ if (!ALLOWED_RAW_EXTENSIONS.includes(type)) {
400
+ throw new Error(
401
+ `RawError: unsupported raw type "${type}" for path "${path}"`
402
+ )
403
+ }
404
+
405
+ let content = readFile(path, "utf8")
406
+ if (type === "html" && options.sanitize !== false) {
407
+ content = sanitizeHTML(content)
408
+ } else if (type === "txt" && options.escape !== false) {
409
+ content = escapeHTML(content)
410
+ }
411
+
248
412
  return raw(content)
249
413
  }
250
414
 
@@ -272,6 +436,34 @@ function sequence() {
272
436
  return number++
273
437
  }
274
438
 
439
+ function decamelize(string) {
440
+ return string.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()
441
+ }
442
+
443
+ function stylesheet(input) {
444
+ const object = { ...input }
445
+ return {
446
+ add(item) {
447
+ for (const key in item) {
448
+ object[key] = item[key]
449
+ }
450
+ },
451
+ set(key, value) {
452
+ object[key] = value
453
+ },
454
+ toString() {
455
+ let result = []
456
+ for (const key in object) {
457
+ const value = object[key]
458
+ if (value) {
459
+ result.push(`${decamelize(key)}:${value}`)
460
+ }
461
+ }
462
+ return result.join(";")
463
+ },
464
+ }
465
+ }
466
+
275
467
  function css(inputs) {
276
468
  let result = ""
277
469
  for (let i = 0, ilen = inputs.length; i < ilen; i += 1) {
@@ -301,15 +493,22 @@ function css(inputs) {
301
493
  }
302
494
  }
303
495
 
304
- css.load = function () {
305
- const path = join(...arguments)
496
+ css.load = function (path) {
306
497
  const file = path.endsWith(".css") ? path : join(path, "index.css")
307
- const content = readFileSync(file, "utf8")
498
+ const content = readFile(file, "utf8")
308
499
  return css`
309
500
  ${content}
310
501
  `
311
502
  }
312
503
 
504
+ css.create = function (object) {
505
+ return stylesheet(object)
506
+ }
507
+
508
+ css.inline = function (object) {
509
+ return stylesheet(object).toString()
510
+ }
511
+
313
512
  function js(inputs) {
314
513
  let result = ""
315
514
  for (let i = 0, ilen = inputs.length; i < ilen; i += 1) {
@@ -326,18 +525,23 @@ function js(inputs) {
326
525
  }
327
526
  }
328
527
 
329
- js.load = function () {
330
- const parts = []
331
- for (const param of arguments) {
332
- if (typeof param === "string") {
333
- parts.push(param)
334
- }
335
- }
336
- const path = join(...parts)
528
+ /*
529
+ * Load a JavaScript file and return a script tag.
530
+ *
531
+ * Please note that this should be used with caution,
532
+ * as it can lead to XSS vulnerabilities if the content
533
+ * is not properly sanitized.
534
+ *
535
+ * It should only be used for trusted content
536
+ * or in controlled environments.
537
+ *
538
+ * Should not be used for user-generated content.
539
+ */
540
+
541
+ js.load = function (path, options = {}) {
337
542
  const file = path.endsWith(".js") ? path : join(path, "index.js")
338
- const content = readFileSync(file, "utf8")
543
+ const content = readFile(file, "utf8")
339
544
 
340
- const options = arguments[arguments.length - 1]
341
545
  const attributes = options.target ? { target: options.target } : {}
342
546
  if (options && options.transform) {
343
547
  return {
@@ -483,17 +687,73 @@ function base64({ content, path }) {
483
687
  return `data:${media(path)};base64,${content}`
484
688
  }
485
689
 
486
- nodes.img.load = function () {
487
- const path = join(...arguments)
488
- const content = readFileSync(path, "base64")
690
+ nodes.img.load = function (path) {
691
+ const type = extension(path)
692
+ if (!ALLOWED_IMAGE_EXTENSIONS.includes(type)) {
693
+ throw new Error(
694
+ `ImageError: unsupported image type "${type}" for path "${path}"`
695
+ )
696
+ }
697
+ const content = readFile(path, "base64")
489
698
  return (options) => {
490
699
  return nodes.img({ src: base64({ content, path }), ...options })
491
700
  }
492
701
  }
493
702
 
494
- nodes.svg.load = function () {
495
- const path = join(...arguments)
496
- const content = readFileSync(path, "utf8")
703
+ /*
704
+ Never trust SVG files from untrusted sources.
705
+ This function is a basic sanitization of SVG content in case
706
+ you've accidentally included a "trusted", but malicious SVG file that was downloaded
707
+ from the internet or other untrusted sources.
708
+
709
+ This function removes script and style tags, inline event handlers,
710
+ and any href attributes that use JavaScript. It does not
711
+ guarantee complete security, but it helps to mitigate some common
712
+ XSS attacks that can be embedded in SVG files.
713
+
714
+ It is recommended to check all SVG files before using them
715
+ in your application.
716
+ */
717
+ const sanitizeSVG = (content) => {
718
+ return content
719
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
720
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
721
+ .replace(/\son\w+="[^"]*"/gi, "")
722
+ .replace(/\son\w+='[^']*'/gi, "")
723
+ .replace(/(href|xlink:href)\s*=\s*(['"])javascript:[^'"]*\2/gi, "")
724
+ }
725
+
726
+ /*
727
+ * SVG files are a special case where we want to allow
728
+ * unescaped SVG content to be rendered directly.
729
+ * This is useful for cases where we want to
730
+ * include SVG fragments or templates that are
731
+ * not meant to be escaped, like large blocks of SVG,
732
+ * or when integrating with third-party libraries
733
+ * that require raw SVG.
734
+ *
735
+ * Please note that this should be used with caution,
736
+ * as it can lead to XSS vulnerabilities if the content
737
+ * is not properly sanitized.
738
+ *
739
+ * It should only be used for trusted content
740
+ * or in controlled environments.
741
+ *
742
+ * Should not be used for user-generated content.
743
+ */
744
+
745
+ nodes.svg.load = function (path, options = {}) {
746
+ const type = extension(path)
747
+ if (type !== "svg") {
748
+ throw new Error(
749
+ `SVGError: unsupported SVG type "${type}" for path "${path}"`
750
+ )
751
+ }
752
+ let content = readFile(path, "utf8")
753
+ if (options.sanitize !== false) {
754
+ content = sanitizeSVG(content)
755
+ }
756
+
497
757
  return raw(content)
498
758
  }
499
759
 
@@ -521,11 +781,16 @@ function classes() {
521
781
  }
522
782
 
523
783
  const json = {
524
- load() {
525
- const path = join(...arguments)
784
+ load(path) {
526
785
  const file = path.endsWith(".json") ? path : join(path, "index.json")
527
- const content = readFileSync(file, "utf8")
528
- return JSON.parse(content)
786
+ const content = readFile(file, "utf8")
787
+ try {
788
+ return JSON.parse(content)
789
+ } catch (exception) {
790
+ throw new Error(
791
+ `JSONError: cannot parse file "${file}": ${exception.message}`
792
+ )
793
+ }
529
794
  },
530
795
  }
531
796
 
@@ -535,9 +800,18 @@ function i18n(translations) {
535
800
  }
536
801
  }
537
802
 
538
- i18n.load = function () {
539
- const path = join(...arguments)
803
+ i18n.load = function (path, options = {}) {
540
804
  const data = json.load(path)
805
+ if (options.sanitize !== false) {
806
+ for (const key in data) {
807
+ for (const lang in data[key]) {
808
+ if (typeof data[key][lang] === "string") {
809
+ data[key][lang] = sanitizeHTML(data[key][lang])
810
+ }
811
+ }
812
+ }
813
+ }
814
+
541
815
  return function translate(language, key) {
542
816
  if (!language) {
543
817
  throw new Error(`TranslationError: language is undefined`)
package/package.json CHANGED
@@ -1,24 +1,18 @@
1
1
  {
2
2
  "name": "boxwood",
3
- "version": "0.80.0",
3
+ "version": "1.1.0",
4
4
  "description": "Compile HTML templates into JS",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
- "test": "node --test --test-reporter=dot",
8
- "test:debug": "node --test --test-reporter=spec",
7
+ "test": "node --test --test-reporter=dot \"test/**/*.test.js\" \"test/**/*.spec.js\"",
8
+ "test:debug": "node --test --test-reporter=spec \"test/**/*.test.js\" \"test/**/*.spec.js\"",
9
9
  "coverage": "c8 npm test",
10
10
  "benchmark": "node --test benchmark/index.js",
11
11
  "watch": "npm test -- --watch",
12
12
  "prepush": "npm test"
13
13
  },
14
- "ava": {
15
- "files": [
16
- "test/spec/**/*.js",
17
- "**/*.spec.js"
18
- ]
19
- },
20
14
  "engines": {
21
- "node": ">= 20.11.1"
15
+ "node": ">= 24.1.0"
22
16
  },
23
17
  "repository": {
24
18
  "type": "git",
@@ -47,10 +41,10 @@
47
41
  "homepage": "https://github.com/buxlabs/boxwood#readme",
48
42
  "devDependencies": {
49
43
  "benchmark": "2.1.4",
50
- "c8": "^10.1.2",
51
- "express": "^4.21.0",
44
+ "c8": "^10.1.3",
45
+ "express": "^5.1.0",
52
46
  "handlebars": "^4.7.8",
53
- "jsdom": "^25.0.0",
47
+ "jsdom": "^26.1.0",
54
48
  "mustache": "^4.2.0",
55
49
  "underscore": "^1.13.7"
56
50
  },
@@ -62,7 +56,7 @@
62
56
  ]
63
57
  },
64
58
  "dependencies": {
65
- "css-tree": "^3.0.0"
59
+ "css-tree": "^3.1.0"
66
60
  },
67
61
  "prettier": {
68
62
  "semi": false