@ez1219/sanitize-url 7.1.2-rc.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/.cz-config.js +72 -0
- package/.eslintignore +2 -0
- package/.eslintrc +6 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report_form.yml +47 -0
- package/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/.github/ISSUE_TEMPLATE/feature_request_form.yml +30 -0
- package/.github/dependabot.yml +16 -0
- package/.github/pull_request_template.md +17 -0
- package/.github/workflows/ci.yml +30 -0
- package/.github/workflows/enforce-conventional-pr-title.yml +39 -0
- package/.github/workflows/publish.yml +65 -0
- package/.github/workflows/security.yml +22 -0
- package/.github/workflows/stale-cleanup.yml +22 -0
- package/.husky/commit-msg +2 -0
- package/.nvmrc +1 -0
- package/.prettierignore +3 -0
- package/CHANGELOG.md +155 -0
- package/CONTRIBUTING.md +79 -0
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/SECURITY.md +41 -0
- package/commitlint.config.js +70 -0
- package/dist/constants.d.ts +8 -0
- package/dist/constants.js +11 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +88 -0
- package/package.json +56 -0
- package/src/__tests__/index.test.ts +299 -0
- package/src/constants.ts +10 -0
- package/src/index.ts +114 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# sanitize-url
|
|
2
|
+
|
|
3
|
+
## Installation
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install -S @ez1219/sanitize-url
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
var sanitizeUrl = require("@ez1219/sanitize-url").sanitizeUrl;
|
|
13
|
+
|
|
14
|
+
sanitizeUrl("https://example.com"); // 'https://example.com'
|
|
15
|
+
sanitizeUrl("http://example.com"); // 'http://example.com'
|
|
16
|
+
sanitizeUrl("www.example.com"); // 'www.example.com'
|
|
17
|
+
sanitizeUrl("mailto:hello@example.com"); // 'mailto:hello@example.com'
|
|
18
|
+
sanitizeUrl(
|
|
19
|
+
"https://example.com",
|
|
20
|
+
); // https://example.com
|
|
21
|
+
|
|
22
|
+
sanitizeUrl("javascript:alert(document.domain)"); // 'about:blank'
|
|
23
|
+
sanitizeUrl("jAvasCrIPT:alert(document.domain)"); // 'about:blank'
|
|
24
|
+
sanitizeUrl(decodeURIComponent("JaVaScRiP%0at:alert(document.domain)")); // 'about:blank'
|
|
25
|
+
// HTML encoded javascript:alert('XSS')
|
|
26
|
+
sanitizeUrl(
|
|
27
|
+
"javascript:alert('XSS')",
|
|
28
|
+
); // 'about:blank'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Testing
|
|
32
|
+
|
|
33
|
+
This library uses [Vitest](https://vitest.dev/). All testing dependencies
|
|
34
|
+
will be installed upon `npm install` and the test suite can be executed with
|
|
35
|
+
`npm test`. Running the test suite will also run lint checks upon exiting.
|
|
36
|
+
|
|
37
|
+
npm test
|
|
38
|
+
|
|
39
|
+
To generate a coverage report, use `npm run coverage`.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
This repository adheres to the [PayPal Vulnerability Reporting Policy](https://hackerone.com/paypal).
|
|
4
|
+
|
|
5
|
+
## Reporting a Vulnerability
|
|
6
|
+
|
|
7
|
+
If you think you have found a vulnerability in this repository, please report it to us through coordinated disclosure.
|
|
8
|
+
|
|
9
|
+
**Please do not report security vulnerabilities through public issues, discussions, or pull requests.**
|
|
10
|
+
|
|
11
|
+
Instead, report it using one of the following ways:
|
|
12
|
+
|
|
13
|
+
- Email the PayPal Security Team at [security@paypal.com](mailto:security@paypal.com)
|
|
14
|
+
- Submit through the [PayPal Bug Bounty Program](https://hackerone.com/paypal) on HackerOne
|
|
15
|
+
- Report a [vulnerability](https://github.com/ez1219/sanitize-url/security/advisories/new) directly via private vulnerability reporting on GitHub
|
|
16
|
+
|
|
17
|
+
Please include the following in your report:
|
|
18
|
+
|
|
19
|
+
- The type of issue and affected version(s)
|
|
20
|
+
- Step-by-step instructions to reproduce the issue
|
|
21
|
+
- Impact of the issue and how an attacker might exploit it
|
|
22
|
+
|
|
23
|
+
## Supported Security Updates
|
|
24
|
+
|
|
25
|
+
Only the latest release receives security patches and new versions in the case of a security issue.
|
|
26
|
+
|
|
27
|
+
## Disclosure Policy
|
|
28
|
+
|
|
29
|
+
We are committed to working with security researchers in good faith. To support responsible disclosure, our team will:
|
|
30
|
+
|
|
31
|
+
- Acknowledge your report in a timely manner
|
|
32
|
+
- Keep you informed of our progress toward a fix
|
|
33
|
+
- Notify you before any public disclosure
|
|
34
|
+
|
|
35
|
+
We ask that you:
|
|
36
|
+
|
|
37
|
+
- Do not publicly disclose the issue before it has been resolved
|
|
38
|
+
- Avoid accessing, modifying, or deleting data that does not belong to you
|
|
39
|
+
- Make a good faith effort to avoid disruption to production systems
|
|
40
|
+
|
|
41
|
+
We appreciate responsible disclosure and your efforts to keep Braintree SDK users safe.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// commitlint.config.js
|
|
2
|
+
module.exports = {
|
|
3
|
+
extends: ["@commitlint/config-conventional"],
|
|
4
|
+
|
|
5
|
+
// Customize rules
|
|
6
|
+
rules: {
|
|
7
|
+
// Type must be one of the specified values
|
|
8
|
+
"type-enum": [
|
|
9
|
+
2, // Error level (0=off, 1=warn, 2=error)
|
|
10
|
+
"always",
|
|
11
|
+
[
|
|
12
|
+
"feat", // New feature
|
|
13
|
+
"fix", // Bug fix
|
|
14
|
+
"review",
|
|
15
|
+
"docs", // Documentation
|
|
16
|
+
"style", // Formatting
|
|
17
|
+
"refactor", // Code restructuring
|
|
18
|
+
"perf", // Performance
|
|
19
|
+
"test", // Tests
|
|
20
|
+
"build", // Build system
|
|
21
|
+
"dx", // Developer experience
|
|
22
|
+
"ci", // CI configuration
|
|
23
|
+
"chore", // Maintenance
|
|
24
|
+
"revert", // Revert commit
|
|
25
|
+
],
|
|
26
|
+
],
|
|
27
|
+
|
|
28
|
+
"scope-enum": [
|
|
29
|
+
2,
|
|
30
|
+
"always",
|
|
31
|
+
[
|
|
32
|
+
"american-express",
|
|
33
|
+
"apple-pay",
|
|
34
|
+
"client",
|
|
35
|
+
"data-collector",
|
|
36
|
+
"fastlane",
|
|
37
|
+
"google-payment",
|
|
38
|
+
"hosted-fields",
|
|
39
|
+
"instant-verification",
|
|
40
|
+
"local-payment",
|
|
41
|
+
"payment-ready",
|
|
42
|
+
"payment-request",
|
|
43
|
+
"paypal-checkout",
|
|
44
|
+
"paypal-checkout-v6",
|
|
45
|
+
"sepa",
|
|
46
|
+
"three-d-secure",
|
|
47
|
+
"us-bank-account",
|
|
48
|
+
"vault-manager",
|
|
49
|
+
"venmo",
|
|
50
|
+
"deps",
|
|
51
|
+
"dev-deps",
|
|
52
|
+
"other",
|
|
53
|
+
],
|
|
54
|
+
],
|
|
55
|
+
// Scope is optional but recommended
|
|
56
|
+
"scope-empty": [1, "never"],
|
|
57
|
+
|
|
58
|
+
// Subject configuration
|
|
59
|
+
"subject-empty": [2, "never"],
|
|
60
|
+
"subject-case": [0, "always", "sentence-case"],
|
|
61
|
+
"subject-full-stop": [1, "never", "."],
|
|
62
|
+
"subject-max-length": [2, "always", 85],
|
|
63
|
+
|
|
64
|
+
// Body configuration
|
|
65
|
+
"body-max-line-length": [2, "always", 100],
|
|
66
|
+
|
|
67
|
+
// Footer configuration
|
|
68
|
+
"footer-max-line-length": [2, "always", 100],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const invalidProtocolRegex: RegExp;
|
|
2
|
+
export declare const htmlEntitiesRegex: RegExp;
|
|
3
|
+
export declare const htmlCtrlEntityRegex: RegExp;
|
|
4
|
+
export declare const ctrlCharactersRegex: RegExp;
|
|
5
|
+
export declare const urlSchemeRegex: RegExp;
|
|
6
|
+
export declare const whitespaceEscapeCharsRegex: RegExp;
|
|
7
|
+
export declare const relativeFirstCharacters: string[];
|
|
8
|
+
export declare const BLANK_URL = "about:blank";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BLANK_URL = exports.relativeFirstCharacters = exports.whitespaceEscapeCharsRegex = exports.urlSchemeRegex = exports.ctrlCharactersRegex = exports.htmlCtrlEntityRegex = exports.htmlEntitiesRegex = exports.invalidProtocolRegex = void 0;
|
|
4
|
+
exports.invalidProtocolRegex = /^([^\w]*)(javascript|data|vbscript)/im;
|
|
5
|
+
exports.htmlEntitiesRegex = /&#(\w+)(^\w|;)?/g;
|
|
6
|
+
exports.htmlCtrlEntityRegex = /&(newline|tab);/gi;
|
|
7
|
+
exports.ctrlCharactersRegex = /[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim;
|
|
8
|
+
exports.urlSchemeRegex = /^.+(:|:)/gim;
|
|
9
|
+
exports.whitespaceEscapeCharsRegex = /(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g;
|
|
10
|
+
exports.relativeFirstCharacters = [".", "/"];
|
|
11
|
+
exports.BLANK_URL = "about:blank";
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function sanitizeUrl(url?: string): string;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sanitizeUrl = sanitizeUrl;
|
|
4
|
+
var constants_1 = require("./constants");
|
|
5
|
+
function isRelativeUrlWithoutProtocol(url) {
|
|
6
|
+
return constants_1.relativeFirstCharacters.indexOf(url[0]) > -1;
|
|
7
|
+
}
|
|
8
|
+
function decodeHtmlCharacters(str) {
|
|
9
|
+
var removedNullByte = str.replace(constants_1.ctrlCharactersRegex, "");
|
|
10
|
+
return removedNullByte.replace(constants_1.htmlEntitiesRegex, function (match, dec) {
|
|
11
|
+
return String.fromCharCode(dec);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function isValidUrl(url) {
|
|
15
|
+
if (typeof URL !== "undefined" && typeof URL.canParse === "function") {
|
|
16
|
+
return URL.canParse(url);
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return Boolean(new URL(url));
|
|
20
|
+
}
|
|
21
|
+
catch (_a) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function decodeURI(uri) {
|
|
26
|
+
try {
|
|
27
|
+
return decodeURIComponent(uri);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
// Ignoring error
|
|
31
|
+
// It is possible that the URI contains a `%` not associated
|
|
32
|
+
// with URI/URL-encoding.
|
|
33
|
+
return uri;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function sanitizeUrl(url) {
|
|
37
|
+
if (!url) {
|
|
38
|
+
return constants_1.BLANK_URL;
|
|
39
|
+
}
|
|
40
|
+
var charsToDecode;
|
|
41
|
+
var decodedUrl = decodeURI(url.trim());
|
|
42
|
+
do {
|
|
43
|
+
decodedUrl = decodeHtmlCharacters(decodedUrl)
|
|
44
|
+
.replace(constants_1.htmlCtrlEntityRegex, "")
|
|
45
|
+
.replace(constants_1.ctrlCharactersRegex, "")
|
|
46
|
+
.replace(constants_1.whitespaceEscapeCharsRegex, "")
|
|
47
|
+
.trim();
|
|
48
|
+
decodedUrl = decodeURI(decodedUrl);
|
|
49
|
+
charsToDecode =
|
|
50
|
+
decodedUrl.match(constants_1.ctrlCharactersRegex) ||
|
|
51
|
+
decodedUrl.match(constants_1.htmlEntitiesRegex) ||
|
|
52
|
+
decodedUrl.match(constants_1.htmlCtrlEntityRegex) ||
|
|
53
|
+
decodedUrl.match(constants_1.whitespaceEscapeCharsRegex);
|
|
54
|
+
} while (charsToDecode && charsToDecode.length > 0);
|
|
55
|
+
var sanitizedUrl = decodedUrl;
|
|
56
|
+
if (!sanitizedUrl) {
|
|
57
|
+
return constants_1.BLANK_URL;
|
|
58
|
+
}
|
|
59
|
+
if (isRelativeUrlWithoutProtocol(sanitizedUrl)) {
|
|
60
|
+
return sanitizedUrl;
|
|
61
|
+
}
|
|
62
|
+
// Remove any leading whitespace before checking the URL scheme
|
|
63
|
+
var trimmedUrl = sanitizedUrl.trimStart();
|
|
64
|
+
var urlSchemeParseResults = trimmedUrl.match(constants_1.urlSchemeRegex);
|
|
65
|
+
if (!urlSchemeParseResults) {
|
|
66
|
+
return sanitizedUrl;
|
|
67
|
+
}
|
|
68
|
+
var urlScheme = urlSchemeParseResults[0].toLowerCase().trim();
|
|
69
|
+
if (constants_1.invalidProtocolRegex.test(urlScheme)) {
|
|
70
|
+
return constants_1.BLANK_URL;
|
|
71
|
+
}
|
|
72
|
+
var backSanitized = trimmedUrl.replace(/\\/g, "/");
|
|
73
|
+
// Handle special cases for mailto: and custom deep-link protocols
|
|
74
|
+
if (urlScheme === "mailto:" || urlScheme.includes("://")) {
|
|
75
|
+
return backSanitized;
|
|
76
|
+
}
|
|
77
|
+
// For http and https URLs, perform additional validation
|
|
78
|
+
if (urlScheme === "http:" || urlScheme === "https:") {
|
|
79
|
+
if (!isValidUrl(backSanitized)) {
|
|
80
|
+
return constants_1.BLANK_URL;
|
|
81
|
+
}
|
|
82
|
+
var url_1 = new URL(backSanitized);
|
|
83
|
+
url_1.protocol = url_1.protocol.toLowerCase();
|
|
84
|
+
url_1.hostname = url_1.hostname.toLowerCase();
|
|
85
|
+
return url_1.toString();
|
|
86
|
+
}
|
|
87
|
+
return backSanitized;
|
|
88
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ez1219/sanitize-url",
|
|
3
|
+
"version": "7.1.2-rc.0",
|
|
4
|
+
"description": "A url sanitizer",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"author": "",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"prepublishOnly": "npm run build",
|
|
10
|
+
"prebuild": "prettier --write .",
|
|
11
|
+
"build": "tsc --declaration",
|
|
12
|
+
"lint": "eslint --ext js,ts .",
|
|
13
|
+
"posttest": "npm run lint",
|
|
14
|
+
"test": "vitest",
|
|
15
|
+
"coverage": "vitest run --coverage",
|
|
16
|
+
"prepare": "husky",
|
|
17
|
+
"commit": "cz"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/ez1219/sanitize-url.git"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/ez1219/sanitize-url/issues"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/ez1219/sanitize-url#readme",
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@commitlint/cli": "^20.5.0",
|
|
34
|
+
"@commitlint/config-conventional": "^20.5.0",
|
|
35
|
+
"@types/jest": "^30.0.0",
|
|
36
|
+
"@types/node": "^24.0.0",
|
|
37
|
+
"@typescript-eslint/eslint-plugin": "^5.54.1",
|
|
38
|
+
"@vitest/coverage-v8": "^4.0.16",
|
|
39
|
+
"chai": "^6.2.2",
|
|
40
|
+
"commitizen": "^4.3.1",
|
|
41
|
+
"cz-customizable": "^7.5.4",
|
|
42
|
+
"eslint": "^8.36.0",
|
|
43
|
+
"eslint-config-braintree": "^6.0.0-typescript-prep-rc.2",
|
|
44
|
+
"eslint-plugin-prettier": "^5.5.4",
|
|
45
|
+
"happy-dom": "^20.0.11",
|
|
46
|
+
"husky": "^9.1.7",
|
|
47
|
+
"prettier": "^3.7.4",
|
|
48
|
+
"typescript": "^5.9.3",
|
|
49
|
+
"vitest": "^4.0.16"
|
|
50
|
+
},
|
|
51
|
+
"config": {
|
|
52
|
+
"commitizen": {
|
|
53
|
+
"path": "./node_modules/cz-customizable"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/* eslint-disable no-script-url */
|
|
2
|
+
import { sanitizeUrl } from "..";
|
|
3
|
+
import { BLANK_URL } from "../constants";
|
|
4
|
+
|
|
5
|
+
describe("sanitizeUrl", () => {
|
|
6
|
+
it("does not alter http URLs with alphanumeric characters", () => {
|
|
7
|
+
expect(sanitizeUrl("http://example.com/path/to:something")).toBe(
|
|
8
|
+
"http://example.com/path/to:something",
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("does not alter http URLs with ports with alphanumeric characters", () => {
|
|
13
|
+
expect(sanitizeUrl("http://example.com:4567/path/to:something")).toBe(
|
|
14
|
+
"http://example.com:4567/path/to:something",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("does not alter https URLs with alphanumeric characters", () => {
|
|
19
|
+
expect(sanitizeUrl("https://example.com")).toBe("https://example.com/");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("does not alter https URLs with ports with alphanumeric characters", () => {
|
|
23
|
+
expect(sanitizeUrl("https://example.com:4567/path/to:something")).toBe(
|
|
24
|
+
"https://example.com:4567/path/to:something",
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("does not alter relative-path reference URLs with alphanumeric characters", () => {
|
|
29
|
+
expect(sanitizeUrl("./path/to/my.json")).toBe("./path/to/my.json");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("does not alter absolute-path reference URLs with alphanumeric characters", () => {
|
|
33
|
+
expect(sanitizeUrl("/path/to/my.json")).toBe("/path/to/my.json");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not alter protocol-less network-path URLs with alphanumeric characters", () => {
|
|
37
|
+
expect(sanitizeUrl("//google.com/robots.txt")).toBe(
|
|
38
|
+
"//google.com/robots.txt",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("does not alter protocol-less URLs with alphanumeric characters", () => {
|
|
43
|
+
expect(sanitizeUrl("www.example.com")).toBe("www.example.com");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not alter deep-link urls with alphanumeric characters", () => {
|
|
47
|
+
expect(sanitizeUrl("com.braintreepayments.demo://example")).toBe(
|
|
48
|
+
"com.braintreepayments.demo://example",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not alter mailto urls with alphanumeric characters", () => {
|
|
53
|
+
expect(sanitizeUrl("mailto:test@example.com?subject=hello+world")).toBe(
|
|
54
|
+
"mailto:test@example.com?subject=hello+world",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not alter urls with accented characters", () => {
|
|
59
|
+
expect(sanitizeUrl("www.example.com/with-áccêntš")).toBe(
|
|
60
|
+
"www.example.com/with-áccêntš",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("does not strip harmless unicode characters", () => {
|
|
65
|
+
expect(sanitizeUrl("www.example.com/лот.рфшишкиü–")).toBe(
|
|
66
|
+
"www.example.com/лот.рфшишкиü–",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("strips out ctrl chars", () => {
|
|
71
|
+
expect(
|
|
72
|
+
sanitizeUrl("www.example.com/\u200D\u0000\u001F\x00\x1F\uFEFFfoo"),
|
|
73
|
+
).toBe("www.example.com/foo");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it(`replaces blank urls with ${BLANK_URL}`, () => {
|
|
77
|
+
expect(sanitizeUrl("")).toBe(BLANK_URL);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it(`replaces null values with ${BLANK_URL}`, () => {
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
82
|
+
// @ts-ignore
|
|
83
|
+
expect(sanitizeUrl(null)).toBe(BLANK_URL);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it(`replaces undefined values with ${BLANK_URL}`, () => {
|
|
87
|
+
expect(sanitizeUrl()).toBe(BLANK_URL);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("removes whitespace from urls", () => {
|
|
91
|
+
expect(sanitizeUrl(" http://example.com/path/to:something ")).toBe(
|
|
92
|
+
"http://example.com/path/to:something",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("removes newline entities from urls", () => {
|
|
97
|
+
expect(sanitizeUrl("https://example.com

/something")).toBe(
|
|
98
|
+
"https://example.com/something",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("decodes html entities", () => {
|
|
103
|
+
// all these decode to javascript:alert('xss');
|
|
104
|
+
const attackVectors = [
|
|
105
|
+
"javascript:alert('XSS')",
|
|
106
|
+
"javascript:alert('XSS')",
|
|
107
|
+
"javascript:alert('XSS')",
|
|
108
|
+
"jav	ascript:alert('XSS');",
|
|
109
|
+
"  javascript:alert('XSS');",
|
|
110
|
+
"javasc	ript: alert('XSS');",
|
|
111
|
+
"javasc&#\u0000x09;ript:alert(1)",
|
|
112
|
+
"java&NewLine&newline;;script:alert('XSS')",
|
|
113
|
+
"java&NewLine&newline;;script:alert('XSS')",
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
attackVectors.forEach((vector) => {
|
|
117
|
+
expect(sanitizeUrl(vector)).toBe(BLANK_URL);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// https://example.com/javascript:alert('XSS')
|
|
121
|
+
// since the javascript is the url path, and not the protocol,
|
|
122
|
+
// this url is technically sanitized
|
|
123
|
+
expect(
|
|
124
|
+
sanitizeUrl(
|
|
125
|
+
"https://example.com/javascript:alert('XSS')",
|
|
126
|
+
),
|
|
127
|
+
).toBe("https://example.com/javascript:alert('XSS')");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("removes whitespace escape sequences", () => {
|
|
131
|
+
const attackVectors = [
|
|
132
|
+
"javascri\npt:alert('xss')",
|
|
133
|
+
"javascri\rpt:alert('xss')",
|
|
134
|
+
"javascri\tpt:alert('xss')",
|
|
135
|
+
"javascrip\\%74t:alert('XSS')",
|
|
136
|
+
"javascrip%5c%72t:alert()",
|
|
137
|
+
"javascrip%5Ctt:alert()",
|
|
138
|
+
"javascrip%255Ctt:alert()",
|
|
139
|
+
"javascrip%25%35Ctt:alert()",
|
|
140
|
+
"javascrip%25%35%43tt:alert()",
|
|
141
|
+
"javascrip%25%32%35%25%33%35%25%34%33rt:alert()",
|
|
142
|
+
"javascrip%255Crt:alert('%25xss')",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
attackVectors.forEach((vector) => {
|
|
146
|
+
expect(sanitizeUrl(vector)).toBe(BLANK_URL);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("backslash prefixed attack vectors", () => {
|
|
151
|
+
const attackVectors = [
|
|
152
|
+
"\fjavascript:alert()",
|
|
153
|
+
"\vjavascript:alert()",
|
|
154
|
+
"\tjavascript:alert()",
|
|
155
|
+
"\njavascript:alert()",
|
|
156
|
+
"\rjavascript:alert()",
|
|
157
|
+
"\u0000javascript:alert()",
|
|
158
|
+
"\u0001javascript:alert()",
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
attackVectors.forEach((vector) => {
|
|
162
|
+
expect(sanitizeUrl(vector)).toBe(BLANK_URL);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("reverses backslashes", () => {
|
|
167
|
+
const attack = "\\j\\av\\a\\s\\cript:alert()";
|
|
168
|
+
|
|
169
|
+
expect(sanitizeUrl(attack)).toBe("/j/av/a/s/cript:alert()");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("invalid protocols", () => {
|
|
173
|
+
describe.each(["javascript", "data", "vbscript"])("%s", (protocol) => {
|
|
174
|
+
it(`replaces ${protocol} urls with ${BLANK_URL}`, () => {
|
|
175
|
+
expect(sanitizeUrl(`${protocol}:alert(document.domain)`)).toBe(
|
|
176
|
+
BLANK_URL,
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it(`allows ${protocol} urls that start with a letter prefix`, () => {
|
|
181
|
+
expect(sanitizeUrl(`not_${protocol}:alert(document.domain)`)).toBe(
|
|
182
|
+
`not_${protocol}:alert(document.domain)`,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it(`disallows ${protocol} urls that start with non-\w characters as a suffix for the protocol`, () => {
|
|
187
|
+
expect(sanitizeUrl(`&!*${protocol}:alert(document.domain)`)).toBe(
|
|
188
|
+
BLANK_URL,
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it(`disallows ${protocol} urls that use : for the colon portion of the url`, () => {
|
|
193
|
+
expect(sanitizeUrl(`${protocol}:alert(document.domain)`)).toBe(
|
|
194
|
+
BLANK_URL,
|
|
195
|
+
);
|
|
196
|
+
expect(sanitizeUrl(`${protocol}:alert(document.domain)`)).toBe(
|
|
197
|
+
BLANK_URL,
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it(`disregards capitalization for ${protocol} urls`, () => {
|
|
202
|
+
// upper case every other letter in protocol name
|
|
203
|
+
const mixedCapitalizationProtocol = protocol
|
|
204
|
+
.split("")
|
|
205
|
+
.map((character, index) => {
|
|
206
|
+
if (index % 2 === 0) {
|
|
207
|
+
return character.toUpperCase();
|
|
208
|
+
}
|
|
209
|
+
return character;
|
|
210
|
+
})
|
|
211
|
+
.join("");
|
|
212
|
+
|
|
213
|
+
expect(
|
|
214
|
+
sanitizeUrl(`${mixedCapitalizationProtocol}:alert(document.domain)`),
|
|
215
|
+
).toBe(BLANK_URL);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it(`ignores invisible ctrl characters in ${protocol} urls`, () => {
|
|
219
|
+
const protocolWithControlCharacters = protocol
|
|
220
|
+
.split("")
|
|
221
|
+
.map((character, index) => {
|
|
222
|
+
if (index === 1) {
|
|
223
|
+
return character + "%EF%BB%BF%EF%BB%BF";
|
|
224
|
+
} else if (index === 2) {
|
|
225
|
+
return character + "%e2%80%8b";
|
|
226
|
+
}
|
|
227
|
+
return character;
|
|
228
|
+
})
|
|
229
|
+
.join("");
|
|
230
|
+
|
|
231
|
+
expect(
|
|
232
|
+
sanitizeUrl(
|
|
233
|
+
decodeURIComponent(
|
|
234
|
+
`${protocolWithControlCharacters}:alert(document.domain)`,
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
).toBe(BLANK_URL);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it(`replaces ${protocol} urls with ${BLANK_URL} when url begins with %20`, () => {
|
|
241
|
+
expect(
|
|
242
|
+
sanitizeUrl(
|
|
243
|
+
decodeURIComponent(
|
|
244
|
+
`%20%20%20%20${protocol}:alert(document.domain)`,
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
).toBe(BLANK_URL);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it(`replaces ${protocol} urls with ${BLANK_URL} when ${protocol} url begins with spaces`, () => {
|
|
251
|
+
expect(sanitizeUrl(` ${protocol}:alert(document.domain)`)).toBe(
|
|
252
|
+
BLANK_URL,
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it(`does not replace ${protocol}: if it is not in the scheme of the URL`, () => {
|
|
257
|
+
expect(sanitizeUrl(`http://example.com#${protocol}:foo`)).toBe(
|
|
258
|
+
`http://example.com#${protocol}:foo`,
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("replaces invalid http/https URLs with BLANK_URL", () => {
|
|
265
|
+
expect(sanitizeUrl("http://[invalid]")).toBe(BLANK_URL);
|
|
266
|
+
expect(sanitizeUrl("https://[invalid]")).toBe(BLANK_URL);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("when URL.canParse is undefined", () => {
|
|
270
|
+
let originalCanParse: typeof URL.canParse;
|
|
271
|
+
|
|
272
|
+
beforeEach(() => {
|
|
273
|
+
originalCanParse = URL.canParse;
|
|
274
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
275
|
+
// @ts-ignore
|
|
276
|
+
URL.canParse = null;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
afterEach(() => {
|
|
280
|
+
URL.canParse = originalCanParse;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("sanitizes valid http/https URLs properly using fallback", () => {
|
|
284
|
+
expect(sanitizeUrl("http://example.com/path")).toBe(
|
|
285
|
+
"http://example.com/path",
|
|
286
|
+
);
|
|
287
|
+
expect(sanitizeUrl("https://example.com")).toBe("https://example.com/");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("replaces invalid http/https URLs with BLANK_URL using fallback", () => {
|
|
291
|
+
expect(sanitizeUrl("http://[invalid]")).toBe(BLANK_URL);
|
|
292
|
+
expect(sanitizeUrl("https://[invalid]")).toBe(BLANK_URL);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("replaces javascript URLs with BLANK_URL", () => {
|
|
296
|
+
expect(sanitizeUrl("javascript:alert(1)")).toBe(BLANK_URL);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const invalidProtocolRegex = /^([^\w]*)(javascript|data|vbscript)/im;
|
|
2
|
+
export const htmlEntitiesRegex = /&#(\w+)(^\w|;)?/g;
|
|
3
|
+
export const htmlCtrlEntityRegex = /&(newline|tab);/gi;
|
|
4
|
+
export const ctrlCharactersRegex =
|
|
5
|
+
/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim;
|
|
6
|
+
export const urlSchemeRegex = /^.+(:|:)/gim;
|
|
7
|
+
export const whitespaceEscapeCharsRegex =
|
|
8
|
+
/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g;
|
|
9
|
+
export const relativeFirstCharacters = [".", "/"];
|
|
10
|
+
export const BLANK_URL = "about:blank";
|