boxwood 1.0.0 → 1.1.1

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 +323 -32
  3. package/package.json +4 -4
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
 
@@ -329,10 +493,62 @@ function css(inputs) {
329
493
  }
330
494
  }
331
495
 
332
- css.load = function () {
333
- const path = join(...arguments)
496
+ function occurences(input, string) {
497
+ if (string.length <= 0) {
498
+ return input.length + 1
499
+ }
500
+
501
+ let count = 0
502
+ let position = 0
503
+ const step = string.length
504
+ while (true) {
505
+ position = input.indexOf(string, position)
506
+ if (position >= 0) {
507
+ count += 1
508
+ position += step
509
+ } else {
510
+ break
511
+ }
512
+ }
513
+ return count
514
+ }
515
+
516
+ const validateCSS = (content, character1, character2) => {
517
+ const count1 = occurences(content, character1)
518
+ const count2 = occurences(content, character2)
519
+ if (count1 !== count2) {
520
+ return {
521
+ valid: false,
522
+ message: `Mismatched count of ${character1} and ${character2}`,
523
+ }
524
+ }
525
+ return { valid: true }
526
+ }
527
+
528
+ const CSS_PAIRS = [
529
+ ["{", "}"],
530
+ ["(", ")"],
531
+ ["[", "]"],
532
+ ]
533
+
534
+ function isCSSValid(content) {
535
+ for (const [left, right] of CSS_PAIRS) {
536
+ const { valid, message } = validateCSS(content, left, right)
537
+ if (!valid) {
538
+ return { valid, message: message }
539
+ }
540
+ }
541
+
542
+ return { valid: true }
543
+ }
544
+
545
+ css.load = function (path) {
334
546
  const file = path.endsWith(".css") ? path : join(path, "index.css")
335
- const content = readFileSync(file, "utf8")
547
+ const content = readFile(file, "utf8")
548
+ const { valid, message } = isCSSValid(content)
549
+ if (!valid) {
550
+ throw new Error(`CSSError: invalid CSS for path "${file}": ${message}`)
551
+ }
336
552
  return css`
337
553
  ${content}
338
554
  `
@@ -362,18 +578,23 @@ function js(inputs) {
362
578
  }
363
579
  }
364
580
 
365
- js.load = function () {
366
- const parts = []
367
- for (const param of arguments) {
368
- if (typeof param === "string") {
369
- parts.push(param)
370
- }
371
- }
372
- const path = join(...parts)
581
+ /*
582
+ * Load a JavaScript file and return a script tag.
583
+ *
584
+ * Please note that this should be used with caution,
585
+ * as it can lead to XSS vulnerabilities if the content
586
+ * is not properly sanitized.
587
+ *
588
+ * It should only be used for trusted content
589
+ * or in controlled environments.
590
+ *
591
+ * Should not be used for user-generated content.
592
+ */
593
+
594
+ js.load = function (path, options = {}) {
373
595
  const file = path.endsWith(".js") ? path : join(path, "index.js")
374
- const content = readFileSync(file, "utf8")
596
+ const content = readFile(file, "utf8")
375
597
 
376
- const options = arguments[arguments.length - 1]
377
598
  const attributes = options.target ? { target: options.target } : {}
378
599
  if (options && options.transform) {
379
600
  return {
@@ -519,17 +740,73 @@ function base64({ content, path }) {
519
740
  return `data:${media(path)};base64,${content}`
520
741
  }
521
742
 
522
- nodes.img.load = function () {
523
- const path = join(...arguments)
524
- const content = readFileSync(path, "base64")
743
+ nodes.img.load = function (path) {
744
+ const type = extension(path)
745
+ if (!ALLOWED_IMAGE_EXTENSIONS.includes(type)) {
746
+ throw new Error(
747
+ `ImageError: unsupported image type "${type}" for path "${path}"`
748
+ )
749
+ }
750
+ const content = readFile(path, "base64")
525
751
  return (options) => {
526
752
  return nodes.img({ src: base64({ content, path }), ...options })
527
753
  }
528
754
  }
529
755
 
530
- nodes.svg.load = function () {
531
- const path = join(...arguments)
532
- const content = readFileSync(path, "utf8")
756
+ /*
757
+ Never trust SVG files from untrusted sources.
758
+ This function is a basic sanitization of SVG content in case
759
+ you've accidentally included a "trusted", but malicious SVG file that was downloaded
760
+ from the internet or other untrusted sources.
761
+
762
+ This function removes script and style tags, inline event handlers,
763
+ and any href attributes that use JavaScript. It does not
764
+ guarantee complete security, but it helps to mitigate some common
765
+ XSS attacks that can be embedded in SVG files.
766
+
767
+ It is recommended to check all SVG files before using them
768
+ in your application.
769
+ */
770
+ const sanitizeSVG = (content) => {
771
+ return content
772
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
773
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
774
+ .replace(/\son\w+="[^"]*"/gi, "")
775
+ .replace(/\son\w+='[^']*'/gi, "")
776
+ .replace(/(href|xlink:href)\s*=\s*(['"])javascript:[^'"]*\2/gi, "")
777
+ }
778
+
779
+ /*
780
+ * SVG files are a special case where we want to allow
781
+ * unescaped SVG content to be rendered directly.
782
+ * This is useful for cases where we want to
783
+ * include SVG fragments or templates that are
784
+ * not meant to be escaped, like large blocks of SVG,
785
+ * or when integrating with third-party libraries
786
+ * that require raw SVG.
787
+ *
788
+ * Please note that this should be used with caution,
789
+ * as it can lead to XSS vulnerabilities if the content
790
+ * is not properly sanitized.
791
+ *
792
+ * It should only be used for trusted content
793
+ * or in controlled environments.
794
+ *
795
+ * Should not be used for user-generated content.
796
+ */
797
+
798
+ nodes.svg.load = function (path, options = {}) {
799
+ const type = extension(path)
800
+ if (type !== "svg") {
801
+ throw new Error(
802
+ `SVGError: unsupported SVG type "${type}" for path "${path}"`
803
+ )
804
+ }
805
+ let content = readFile(path, "utf8")
806
+ if (options.sanitize !== false) {
807
+ content = sanitizeSVG(content)
808
+ }
809
+
533
810
  return raw(content)
534
811
  }
535
812
 
@@ -557,11 +834,16 @@ function classes() {
557
834
  }
558
835
 
559
836
  const json = {
560
- load() {
561
- const path = join(...arguments)
837
+ load(path) {
562
838
  const file = path.endsWith(".json") ? path : join(path, "index.json")
563
- const content = readFileSync(file, "utf8")
564
- return JSON.parse(content)
839
+ const content = readFile(file, "utf8")
840
+ try {
841
+ return JSON.parse(content)
842
+ } catch (exception) {
843
+ throw new Error(
844
+ `JSONError: cannot parse file "${file}": ${exception.message}`
845
+ )
846
+ }
565
847
  },
566
848
  }
567
849
 
@@ -571,9 +853,18 @@ function i18n(translations) {
571
853
  }
572
854
  }
573
855
 
574
- i18n.load = function () {
575
- const path = join(...arguments)
856
+ i18n.load = function (path, options = {}) {
576
857
  const data = json.load(path)
858
+ if (options.sanitize !== false) {
859
+ for (const key in data) {
860
+ for (const lang in data[key]) {
861
+ if (typeof data[key][lang] === "string") {
862
+ data[key][lang] = sanitizeHTML(data[key][lang])
863
+ }
864
+ }
865
+ }
866
+ }
867
+
577
868
  return function translate(language, key) {
578
869
  if (!language) {
579
870
  throw new Error(`TranslationError: language is undefined`)
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "boxwood",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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
14
  "engines": {
15
- "node": ">= 20.11.1"
15
+ "node": ">= 24.1.0"
16
16
  },
17
17
  "repository": {
18
18
  "type": "git",