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.
- package/README.md +11 -1
- package/index.js +323 -32
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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 =
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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 =
|
|
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
|
|
524
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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 =
|
|
564
|
-
|
|
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.
|
|
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": ">=
|
|
15
|
+
"node": ">= 24.1.0"
|
|
16
16
|
},
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|