boxwood 1.0.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.
- package/README.md +11 -1
- package/index.js +270 -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,9 @@ function css(inputs) {
|
|
|
329
493
|
}
|
|
330
494
|
}
|
|
331
495
|
|
|
332
|
-
css.load = function () {
|
|
333
|
-
const path = join(...arguments)
|
|
496
|
+
css.load = function (path) {
|
|
334
497
|
const file = path.endsWith(".css") ? path : join(path, "index.css")
|
|
335
|
-
const content =
|
|
498
|
+
const content = readFile(file, "utf8")
|
|
336
499
|
return css`
|
|
337
500
|
${content}
|
|
338
501
|
`
|
|
@@ -362,18 +525,23 @@ function js(inputs) {
|
|
|
362
525
|
}
|
|
363
526
|
}
|
|
364
527
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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 = {}) {
|
|
373
542
|
const file = path.endsWith(".js") ? path : join(path, "index.js")
|
|
374
|
-
const content =
|
|
543
|
+
const content = readFile(file, "utf8")
|
|
375
544
|
|
|
376
|
-
const options = arguments[arguments.length - 1]
|
|
377
545
|
const attributes = options.target ? { target: options.target } : {}
|
|
378
546
|
if (options && options.transform) {
|
|
379
547
|
return {
|
|
@@ -519,17 +687,73 @@ function base64({ content, path }) {
|
|
|
519
687
|
return `data:${media(path)};base64,${content}`
|
|
520
688
|
}
|
|
521
689
|
|
|
522
|
-
nodes.img.load = function () {
|
|
523
|
-
const
|
|
524
|
-
|
|
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")
|
|
525
698
|
return (options) => {
|
|
526
699
|
return nodes.img({ src: base64({ content, path }), ...options })
|
|
527
700
|
}
|
|
528
701
|
}
|
|
529
702
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
+
|
|
533
757
|
return raw(content)
|
|
534
758
|
}
|
|
535
759
|
|
|
@@ -557,11 +781,16 @@ function classes() {
|
|
|
557
781
|
}
|
|
558
782
|
|
|
559
783
|
const json = {
|
|
560
|
-
load() {
|
|
561
|
-
const path = join(...arguments)
|
|
784
|
+
load(path) {
|
|
562
785
|
const file = path.endsWith(".json") ? path : join(path, "index.json")
|
|
563
|
-
const content =
|
|
564
|
-
|
|
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
|
+
}
|
|
565
794
|
},
|
|
566
795
|
}
|
|
567
796
|
|
|
@@ -571,9 +800,18 @@ function i18n(translations) {
|
|
|
571
800
|
}
|
|
572
801
|
}
|
|
573
802
|
|
|
574
|
-
i18n.load = function () {
|
|
575
|
-
const path = join(...arguments)
|
|
803
|
+
i18n.load = function (path, options = {}) {
|
|
576
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
|
+
|
|
577
815
|
return function translate(language, key) {
|
|
578
816
|
if (!language) {
|
|
579
817
|
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.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
14
|
"engines": {
|
|
15
|
-
"node": ">=
|
|
15
|
+
"node": ">= 24.1.0"
|
|
16
16
|
},
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|