CVE-2021-23784: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: This affects the package tempura before 0.4.0. If the input to the esc function is of type object (i.e an array) it is returned without being escaped/sanitized, leading to a potential Cross-Site Scripting vulnerability.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2021-11-03T18:15Z
Repository: https://github.com/lukeed/tempura
Language: JavaScript
Files changed: docs/api.md, src/$index.js, src/index.d.ts, test/$index.js
Commit date: 2021-08-25 09:27:47 -0700
Commit message: break: ensure `esc` always returns a string;

- prevents a XSS attack within an array

Diff: diff --git a/docs/api.md b/docs/api.md
index f39c73b..ba4c5a7 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -4,18 +4,22 @@
 
 ## Methods
 
 
 ### tempura.esc(value)
-Returns: `string` or `typeof value`
+Returns: `string`
 
 #### value
 Type: `string` or `unknown`
 
 The value to be HTML-escaped. The following special characters are escaped: `"`, `&`, and `<`.
 
-> **Note:** Any non-`string` values are returned as is.
+> **Note:** Any non-`string` values are coerced to a strings!
+>  * `null` and `undefined` become `""`
+>  * `{ foo: 123 }` becomes `"[object Object]"`
+>  * `[1, 2, 3]` becomes `"1,2,3"`
+>  * `123` becomes `"123"`
 
 
 ### tempura.compile(input, options?)
 Returns: `Compiler`
 
diff --git a/src/$index.js b/src/$index.js
index a91d366..4062551 100644
--- a/src/$index.js
+++ b/src/$index.js
@@ -5,11 +5,11 @@ const ESCAPE = /[&"<]/g, CHARS = {
 };
 
 import { gen } from './$utils';
 
 export function esc(value) {
-	if (typeof value !== 'string') return value;
+	value = (value == null) ? '' : '' + value;
 	let last=ESCAPE.lastIndex=0, tmp=0, out='';
 	while (ESCAPE.test(value)) {
 		tmp = ESCAPE.lastIndex - 1;
 		out += value.substring(last, tmp) + CHARS[value[tmp]];
 		last = tmp + 1;
diff --git a/src/index.d.ts b/src/index.d.ts
index 320822a..43e2eea 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -10,11 +10,11 @@ export interface Options {
 	props?: string[];
 	blocks?: Blocks;
 	async?: boolean;
 }
 
-export function esc<T=unknown>(value: T): T|string;
+export function esc(value: string|unknown): string;
 export function transform(input: string, options?: Options & { format?: 'esm' | 'cjs' }): string;
 
 type CompileOptions = Options & { escape?: typeof esc };
 export function compile(input: string, options?: CompileOptions & { async: true }): <T extends Args> (data?: T, blocks?: Blocks) => Promise<string>;
 export function compile(input: string, options?: CompileOptions & { async?: false }): <T extends Args> (data?: T, blocks?: Blocks) => string;
diff --git a/test/$index.js b/test/$index.js
index 6433bc8..ca7a7db 100644
--- a/test/$index.js
+++ b/test/$index.js
@@ -261,20 +261,25 @@ const esc = suite('esc');
 
 esc('should be a function', () => {
 	assert.type(tempura.esc, 'function');
 });
 
-esc('should echo non-string inputs', () => {
-	// @ts-ignore
-	assert.is(tempura.esc(), undefined);
-	assert.is(tempura.esc(null), null);
-	assert.is(tempura.esc(false), false);
-	assert.is(tempura.esc(123), 123);
-	assert.is(tempura.esc(0), 0);
-
-	assert.equal(tempura.esc([1, 2, 3]), [1, 2, 3]);
-	assert.equal(tempura.esc({ foo: 1 }), { foo: 1 });
+esc('should convert non-string inputs to string', () => {
+	assert.is(tempura.esc(), '');
+	assert.is(tempura.esc(null), '');
+
+	assert.is(tempura.esc(false), 'false');
+	assert.is(tempura.esc(123), '123');
+	assert.is(tempura.esc(0), '0');
+
+	assert.equal(tempura.esc([1, 2, 3]), '1,2,3');
+	assert.equal(tempura.esc({ foo: 1 }), '[object Object]');
+});
+
+esc('should prevent xss scripting in array', () => {
+	let output = tempura.esc(['<img src=x onerror="alert(1)" />']);
+	assert.is(output, '&ltimg src=x onerror=&quot;alert(1)&quot; />');
 });
 
 esc('should return string from string input', () => {
 	assert.type(tempura.esc(''), 'string');
 	assert.type(tempura.esc('foobar'), 'string');

Vulnerable code: Returns: `string` or `typeof value`
> **Note:** Any non-`string` values are returned as is.
	if (typeof value !== 'string') return value;
export function esc<T=unknown>(value: T): T|string;
esc('should echo non-string inputs', () => {
	// @ts-ignore
	assert.is(tempura.esc(), undefined);
	assert.is(tempura.esc(null), null);
	assert.is(tempura.esc(false), false);
	assert.is(tempura.esc(123), 123);
	assert.is(tempura.esc(0), 0);

	assert.equal(tempura.esc([1, 2, 3]), [1, 2, 3]);
	assert.equal(tempura.esc({ foo: 1 }), { foo: 1 });
Fixed code: Returns: `string`
> **Note:** Any non-`string` values are coerced to a strings!
>  * `null` and `undefined` become `""`
>  * `{ foo: 123 }` becomes `"[object Object]"`
>  * `[1, 2, 3]` becomes `"1,2,3"`
>  * `123` becomes `"123"`
	value = (value == null) ? '' : '' + value;
export function esc(value: string|unknown): string;
esc('should convert non-string inputs to string', () => {
	assert.is(tempura.esc(), '');
	assert.is(tempura.esc(null), '');

	assert.is(tempura.esc(false), 'false');
	assert.is(tempura.esc(123), '123');
	assert.is(tempura.esc(0), '0');

	assert.equal(tempura.esc([1, 2, 3]), '1,2,3');
	assert.equal(tempura.esc({ foo: 1 }), '[object Object]');
});

esc('should prevent xss scripting in array', () => {
	let output = tempura.esc(['<img src=x onerror="alert(1)" />']);
	assert.is(output, '&ltimg src=x onerror=&quot;alert(1)&quot; />');