First commit.

Signed-off-by: Chen Xiao <abigwc@gmail.com>
This commit is contained in:
Chen Xiao
2026-05-08 14:43:16 +08:00
commit 0b64e2de94
10989 changed files with 2253791 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
dist
coverage
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "braintree/client",
"rules": {
"no-control-regex": 0
}
}
+1
View File
@@ -0,0 +1 @@
* @braintree/team-sdk-js
@@ -0,0 +1,47 @@
name: Bug Report
description: File a bug report.
title: "[Bug]: "
labels: ["bug", "triage"]
projects: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Detail the bad behavior and the steps required to duplicate it
placeholder: Tell us what you see!
value: "A bug happened!"
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: What did you expect to happen?
description: What did you expect to happen when performing the action?
placeholder: Expected behavior
value: "Expected behavior"
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: What version of this library are you running?
placeholder: "v0.0.0"
value:
validations:
required: true
- type: textarea
id: browsers
attributes:
label: What browsers are you seeing the problem on?
description: |
List browsers where you see the problem reported. For example:
* Mac OS 15.3.5, Chrome 130.0.1234.987
* Android 14, Firefox 133.0
* iPadOS 17.1, Safari
placeholder: "For each browser specify 1) operating system and version 2) browser name and version"
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Contact Developer Support
url: https://developer.paypal.com/braintree/help
about: If you need help troubleshooting your integration, reach out to Braintree Support. Only open a GitHub issue if you've found an issue with our SDK.
@@ -0,0 +1,30 @@
name: Feature Request
description: Request a new feature.
title: "[FR]: "
labels: ["feature", "request", "enhancement", "triage"]
projects: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request!
- type: textarea
id: feat-summary
attributes:
label: Feature Summary
description: In a few sentences or less, provide a concise description of the feature being requested.
placeholder: "New feature that does ___; it can be used when ..."
validations:
required: true
- type: textarea
id: detailed-description
attributes:
label: Detailed description of new feature.
description: |
Please provide a detailed description of proposed feature.
- What problem would this new feature solve?
- How will this change affect users?
- Is it a modification to existing functionality? Are you requesting a new/modified function input or output? Please provide an example of what the change might look like?
placeholder: "New feature details."
validations:
required: true
@@ -0,0 +1,17 @@
# Summary of changes
-
## Checklist
- [ ] Added a changelog entry
- [ ] Relevant test coverage
- [ ] Tested and confirmed flows affected by this change are functioning as expected
## Authors
> List GitHub usernames for everyone who contributed to this pull request.
### Reviewers
@braintree/team-sdk-js
+17
View File
@@ -0,0 +1,17 @@
name: "Unit Tests"
on: [push]
jobs:
build:
name: "Unit Tests on Ubuntu"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: "18.x"
- run: npm install
- run: npm test
@@ -0,0 +1,24 @@
name: Security
permissions:
contents: write # Needed by both CodeQL and dependency review
pull-requests: write # Needed by dependency review
statuses: write # Needed by dependency review (to post checks)
security-events: write # Needed by CodeQL to upload SARIF
packages: read # Needed by CodeQL for private/internal packs
actions: read # Needed by CodeQL to access internal actions
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
jobs:
codeql-javascript:
uses: braintree/security-workflows/.github/workflows/codeql.yml@main
with:
language: javascript-typescript
dependency-review:
uses: braintree/security-workflows/.github/workflows/dependency-review.yml@main
+1
View File
@@ -0,0 +1 @@
v24
+3
View File
@@ -0,0 +1,3 @@
*-lock.json
dist
coverage
+151
View File
@@ -0,0 +1,151 @@
# CHANGELOG
## 7.1.2
- Update dependencies
- @types/jest to 30.0.0
- @vitest/coverage-v8 to 4.0.16
- chai to 6.2.2
- eslint-plugin-prettier to 5.5.4
- happy-dom to 20.0.11
- prettier to 3.7.4
- typescript to 5.9.3
- vitest to 4.0.16
- Update Node to v24
- Add @types/node 24.0.0
## 7.1.1
- DevDependency Changes
- happy-dom to 15.11.6
- Update (sub-)dependencies
- cross-spawn to 7.0.6
- micromatch to 4.0.8
- vite to 4.5.5
## 7.1.0
- Updated to handle back-slashes
## 7.0.4
- Updates get-func-name to 2.0.2
## 7.0.3
- Dependencies
- Update braces to 3.0.3
## 7.0.2
- Improve sanitization of whitespace escapes
## 7.0.1
- Improve sanitization of HTML entities
## 7.0.0
- Move constant declarations from index file to `constants.ts` file
- Update to node v18
- Dev Dependency Updates
- Update to TypeScript 5
- Other minor dependency updates
## 6.0.4
- Add additional null byte sanitization prior to html decoding (#48)
## 6.0.3
- Add null check to beginning of `sanitizeUrl` function ([#54](https://github.com/braintree/sanitize-url/issues/54))
## 6.0.2
- Fix issue where urls in the form `https://example.com&NewLine;&NewLine;/something` were not properly sanitized
## 6.0.1
- Fix issue where urls in the form `javascript&colon;alert('xss');` were not properly sanitized
- Fix issue where urls in the form `javasc&Tab;ript:alert('XSS');` were not properly sanitized
## 6.0.0
- BREAKING CHANGES
- Decode HTML characters automatically that would result in an XSS vulnerability when rendering links via a server rendered HTML file
```js
// decodes to javacript:alert('XSS')
const vulnerableUrl =
"&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041";
sanitizeUrl(vulnerableUrl); // 'about:blank'
const okUrl = "https://example.com/" + vulnerableUrl;
// since the javascript bit is in the path instead of the protocol
// this is successfully sanitized
sanitizeUrl(okUrl); // 'https://example.com/javascript:alert('XSS');
```
## 5.0.2
- Fix issue where certain invisible white space characters were not being sanitized (#35)
## 5.0.1
- Fix issue where certain safe characters were being filtered out (#31 thanks @akirchmyer)
## 5.0.0
- BREAKING CHANGES
- Sanitize vbscript urls (thanks @vicnicius)
## 4.1.1
- Fixup path to type declaration (closes #25)
## 4.1.0
- Add typescript types
## 4.0.1
- Fix issue where urls with accented characters were incorrectly sanitized
## 4.0.0
- BREAKING CHANGES
- Protocol-less urls (ie: `www.example.com`) will be sanitised and passed on instead of sending out `about:blank` (Thanks @chawes13 #18)
## 3.1.0
- Trim whitespace from urls
## 3.0.0
- BREAKING CHANGES
- Replace blank strings with about:blank
- Replace null values with about:blank
## 2.1.0
- Allow relative urls to be sanitized
## 2.0.2
- Sanitize malicious URLs that begin with `\s`
## 2.0.1
- Sanitize malicious URLs that begin with %20
## 2.0.0
- sanitize data: urls
## 1.0.0
- sanitize javascript: urls
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Braintree
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+39
View File
@@ -0,0 +1,39 @@
# sanitize-url
## Installation
```sh
npm install -S @braintree/sanitize-url
```
## Usage
```js
var sanitizeUrl = require("@braintree/sanitize-url").sanitizeUrl;
sanitizeUrl("https://example.com"); // 'https://example.com'
sanitizeUrl("http://example.com"); // 'http://example.com'
sanitizeUrl("www.example.com"); // 'www.example.com'
sanitizeUrl("mailto:hello@example.com"); // 'mailto:hello@example.com'
sanitizeUrl(
"&#104;&#116;&#116;&#112;&#115;&#0000058//&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;",
); // https://example.com
sanitizeUrl("javascript:alert(document.domain)"); // 'about:blank'
sanitizeUrl("jAvasCrIPT:alert(document.domain)"); // 'about:blank'
sanitizeUrl(decodeURIComponent("JaVaScRiP%0at:alert(document.domain)")); // 'about:blank'
// HTML encoded javascript:alert('XSS')
sanitizeUrl(
"&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041",
); // 'about:blank'
```
## Testing
This library uses [Vitest](https://vitest.dev/). All testing dependencies
will be installed upon `npm install` and the test suite can be executed with
`npm test`. Running the test suite will also run lint checks upon exiting.
npm test
To generate a coverage report, use `npm run coverage`.
+8
View File
@@ -0,0 +1,8 @@
export declare const invalidProtocolRegex: RegExp;
export declare const htmlEntitiesRegex: RegExp;
export declare const htmlCtrlEntityRegex: RegExp;
export declare const ctrlCharactersRegex: RegExp;
export declare const urlSchemeRegex: RegExp;
export declare const whitespaceEscapeCharsRegex: RegExp;
export declare const relativeFirstCharacters: string[];
export declare const BLANK_URL = "about:blank";
+11
View File
@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BLANK_URL = exports.relativeFirstCharacters = exports.whitespaceEscapeCharsRegex = exports.urlSchemeRegex = exports.ctrlCharactersRegex = exports.htmlCtrlEntityRegex = exports.htmlEntitiesRegex = exports.invalidProtocolRegex = void 0;
exports.invalidProtocolRegex = /^([^\w]*)(javascript|data|vbscript)/im;
exports.htmlEntitiesRegex = /&#(\w+)(^\w|;)?/g;
exports.htmlCtrlEntityRegex = /&(newline|tab);/gi;
exports.ctrlCharactersRegex = /[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim;
exports.urlSchemeRegex = /^.+(:|&colon;)/gim;
exports.whitespaceEscapeCharsRegex = /(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g;
exports.relativeFirstCharacters = [".", "/"];
exports.BLANK_URL = "about:blank";
+1
View File
@@ -0,0 +1 @@
export declare function sanitizeUrl(url?: string): string;
+80
View File
@@ -0,0 +1,80 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.sanitizeUrl = sanitizeUrl;
var constants_1 = require("./constants");
function isRelativeUrlWithoutProtocol(url) {
return constants_1.relativeFirstCharacters.indexOf(url[0]) > -1;
}
function decodeHtmlCharacters(str) {
var removedNullByte = str.replace(constants_1.ctrlCharactersRegex, "");
return removedNullByte.replace(constants_1.htmlEntitiesRegex, function (match, dec) {
return String.fromCharCode(dec);
});
}
function isValidUrl(url) {
return URL.canParse(url);
}
function decodeURI(uri) {
try {
return decodeURIComponent(uri);
}
catch (e) {
// Ignoring error
// It is possible that the URI contains a `%` not associated
// with URI/URL-encoding.
return uri;
}
}
function sanitizeUrl(url) {
if (!url) {
return constants_1.BLANK_URL;
}
var charsToDecode;
var decodedUrl = decodeURI(url.trim());
do {
decodedUrl = decodeHtmlCharacters(decodedUrl)
.replace(constants_1.htmlCtrlEntityRegex, "")
.replace(constants_1.ctrlCharactersRegex, "")
.replace(constants_1.whitespaceEscapeCharsRegex, "")
.trim();
decodedUrl = decodeURI(decodedUrl);
charsToDecode =
decodedUrl.match(constants_1.ctrlCharactersRegex) ||
decodedUrl.match(constants_1.htmlEntitiesRegex) ||
decodedUrl.match(constants_1.htmlCtrlEntityRegex) ||
decodedUrl.match(constants_1.whitespaceEscapeCharsRegex);
} while (charsToDecode && charsToDecode.length > 0);
var sanitizedUrl = decodedUrl;
if (!sanitizedUrl) {
return constants_1.BLANK_URL;
}
if (isRelativeUrlWithoutProtocol(sanitizedUrl)) {
return sanitizedUrl;
}
// Remove any leading whitespace before checking the URL scheme
var trimmedUrl = sanitizedUrl.trimStart();
var urlSchemeParseResults = trimmedUrl.match(constants_1.urlSchemeRegex);
if (!urlSchemeParseResults) {
return sanitizedUrl;
}
var urlScheme = urlSchemeParseResults[0].toLowerCase().trim();
if (constants_1.invalidProtocolRegex.test(urlScheme)) {
return constants_1.BLANK_URL;
}
var backSanitized = trimmedUrl.replace(/\\/g, "/");
// Handle special cases for mailto: and custom deep-link protocols
if (urlScheme === "mailto:" || urlScheme.includes("://")) {
return backSanitized;
}
// For http and https URLs, perform additional validation
if (urlScheme === "http:" || urlScheme === "https:") {
if (!isValidUrl(backSanitized)) {
return constants_1.BLANK_URL;
}
var url_1 = new URL(backSanitized);
url_1.protocol = url_1.protocol.toLowerCase();
url_1.hostname = url_1.hostname.toLowerCase();
return url_1.toString();
}
return backSanitized;
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@braintree/sanitize-url",
"version": "7.1.2",
"description": "A url sanitizer",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"author": "",
"scripts": {
"prepublishOnly": "npm run build",
"prebuild": "prettier --write .",
"build": "tsc --declaration",
"lint": "eslint --ext js,ts .",
"posttest": "npm run lint",
"test": "vitest",
"coverage": "vitest run --coverage"
},
"repository": {
"type": "git",
"url": "git+https://github.com/braintree/sanitize-url.git"
},
"keywords": [],
"license": "MIT",
"bugs": {
"url": "https://github.com/braintree/sanitize-url/issues"
},
"homepage": "https://github.com/braintree/sanitize-url#readme",
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@typescript-eslint/eslint-plugin": "^5.54.1",
"@vitest/coverage-v8": "^4.0.16",
"chai": "^6.2.2",
"eslint": "^8.36.0",
"eslint-config-braintree": "^6.0.0-typescript-prep-rc.2",
"eslint-plugin-prettier": "^5.5.4",
"happy-dom": "^20.0.11",
"prettier": "^3.7.4",
"typescript": "^5.9.3",
"vitest": "^4.0.16"
}
}
+263
View File
@@ -0,0 +1,263 @@
/* eslint-disable no-script-url */
import { sanitizeUrl } from "..";
import { BLANK_URL } from "../constants";
describe("sanitizeUrl", () => {
it("does not alter http URLs with alphanumeric characters", () => {
expect(sanitizeUrl("http://example.com/path/to:something")).toBe(
"http://example.com/path/to:something",
);
});
it("does not alter http URLs with ports with alphanumeric characters", () => {
expect(sanitizeUrl("http://example.com:4567/path/to:something")).toBe(
"http://example.com:4567/path/to:something",
);
});
it("does not alter https URLs with alphanumeric characters", () => {
expect(sanitizeUrl("https://example.com")).toBe("https://example.com/");
});
it("does not alter https URLs with ports with alphanumeric characters", () => {
expect(sanitizeUrl("https://example.com:4567/path/to:something")).toBe(
"https://example.com:4567/path/to:something",
);
});
it("does not alter relative-path reference URLs with alphanumeric characters", () => {
expect(sanitizeUrl("./path/to/my.json")).toBe("./path/to/my.json");
});
it("does not alter absolute-path reference URLs with alphanumeric characters", () => {
expect(sanitizeUrl("/path/to/my.json")).toBe("/path/to/my.json");
});
it("does not alter protocol-less network-path URLs with alphanumeric characters", () => {
expect(sanitizeUrl("//google.com/robots.txt")).toBe(
"//google.com/robots.txt",
);
});
it("does not alter protocol-less URLs with alphanumeric characters", () => {
expect(sanitizeUrl("www.example.com")).toBe("www.example.com");
});
it("does not alter deep-link urls with alphanumeric characters", () => {
expect(sanitizeUrl("com.braintreepayments.demo://example")).toBe(
"com.braintreepayments.demo://example",
);
});
it("does not alter mailto urls with alphanumeric characters", () => {
expect(sanitizeUrl("mailto:test@example.com?subject=hello+world")).toBe(
"mailto:test@example.com?subject=hello+world",
);
});
it("does not alter urls with accented characters", () => {
expect(sanitizeUrl("www.example.com/with-áccêntš")).toBe(
"www.example.com/with-áccêntš",
);
});
it("does not strip harmless unicode characters", () => {
expect(sanitizeUrl("www.example.com/лот.рфшишкиü–")).toBe(
"www.example.com/лот.рфшишкиü–",
);
});
it("strips out ctrl chars", () => {
expect(
sanitizeUrl("www.example.com/\u200D\u0000\u001F\x00\x1F\uFEFFfoo"),
).toBe("www.example.com/foo");
});
it(`replaces blank urls with ${BLANK_URL}`, () => {
expect(sanitizeUrl("")).toBe(BLANK_URL);
});
it(`replaces null values with ${BLANK_URL}`, () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
expect(sanitizeUrl(null)).toBe(BLANK_URL);
});
it(`replaces undefined values with ${BLANK_URL}`, () => {
expect(sanitizeUrl()).toBe(BLANK_URL);
});
it("removes whitespace from urls", () => {
expect(sanitizeUrl(" http://example.com/path/to:something ")).toBe(
"http://example.com/path/to:something",
);
});
it("removes newline entities from urls", () => {
expect(sanitizeUrl("https://example.com&NewLine;&NewLine;/something")).toBe(
"https://example.com/something",
);
});
it("decodes html entities", () => {
// all these decode to javascript:alert('xss');
const attackVectors = [
"&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041",
"&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;",
"&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x27&#x58&#x53&#x53&#x27&#x29",
"jav&#x09;ascript:alert('XSS');",
" &#14; javascript:alert('XSS');",
"javasc&Tab;ript: alert('XSS');",
"javasc&#\u0000x09;ript:alert(1)",
"java&#38;&#38;&#35;78&#59;ewLine&#38;newline&#59;&#59;script&#58;alert&#40;&#39;XSS&#39;&#41;",
"java&&#78;ewLine&newline;;script:alert('XSS')",
];
attackVectors.forEach((vector) => {
expect(sanitizeUrl(vector)).toBe(BLANK_URL);
});
// https://example.com/javascript:alert('XSS')
// since the javascript is the url path, and not the protocol,
// this url is technically sanitized
expect(
sanitizeUrl(
"&#104;&#116;&#116;&#112;&#115;&#0000058//&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;/&#0000106&#0000097&#0000118&#0000097&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116&#0000058&#0000097&#0000108&#0000101&#0000114&#0000116&#0000040&#0000039&#0000088&#0000083&#0000083&#0000039&#0000041",
),
).toBe("https://example.com/javascript:alert('XSS')");
});
it("removes whitespace escape sequences", () => {
const attackVectors = [
"javascri\npt:alert('xss')",
"javascri\rpt:alert('xss')",
"javascri\tpt:alert('xss')",
"javascrip\\%74t:alert('XSS')",
"javascrip%5c%72t:alert()",
"javascrip%5Ctt:alert()",
"javascrip%255Ctt:alert()",
"javascrip%25%35Ctt:alert()",
"javascrip%25%35%43tt:alert()",
"javascrip%25%32%35%25%33%35%25%34%33rt:alert()",
"javascrip%255Crt:alert('%25xss')",
];
attackVectors.forEach((vector) => {
expect(sanitizeUrl(vector)).toBe(BLANK_URL);
});
});
it("backslash prefixed attack vectors", () => {
const attackVectors = [
"\fjavascript:alert()",
"\vjavascript:alert()",
"\tjavascript:alert()",
"\njavascript:alert()",
"\rjavascript:alert()",
"\u0000javascript:alert()",
"\u0001javascript:alert()",
];
attackVectors.forEach((vector) => {
expect(sanitizeUrl(vector)).toBe(BLANK_URL);
});
});
it("reverses backslashes", () => {
const attack = "\\j\\av\\a\\s\\cript:alert()";
expect(sanitizeUrl(attack)).toBe("/j/av/a/s/cript:alert()");
});
describe("invalid protocols", () => {
describe.each(["javascript", "data", "vbscript"])("%s", (protocol) => {
it(`replaces ${protocol} urls with ${BLANK_URL}`, () => {
expect(sanitizeUrl(`${protocol}:alert(document.domain)`)).toBe(
BLANK_URL,
);
});
it(`allows ${protocol} urls that start with a letter prefix`, () => {
expect(sanitizeUrl(`not_${protocol}:alert(document.domain)`)).toBe(
`not_${protocol}:alert(document.domain)`,
);
});
it(`disallows ${protocol} urls that start with non-\w characters as a suffix for the protocol`, () => {
expect(sanitizeUrl(`&!*${protocol}:alert(document.domain)`)).toBe(
BLANK_URL,
);
});
it(`disallows ${protocol} urls that use &colon; for the colon portion of the url`, () => {
expect(sanitizeUrl(`${protocol}&colon;alert(document.domain)`)).toBe(
BLANK_URL,
);
expect(sanitizeUrl(`${protocol}&COLON;alert(document.domain)`)).toBe(
BLANK_URL,
);
});
it(`disregards capitalization for ${protocol} urls`, () => {
// upper case every other letter in protocol name
const mixedCapitalizationProtocol = protocol
.split("")
.map((character, index) => {
if (index % 2 === 0) {
return character.toUpperCase();
}
return character;
})
.join("");
expect(
sanitizeUrl(`${mixedCapitalizationProtocol}:alert(document.domain)`),
).toBe(BLANK_URL);
});
it(`ignores invisible ctrl characters in ${protocol} urls`, () => {
const protocolWithControlCharacters = protocol
.split("")
.map((character, index) => {
if (index === 1) {
return character + "%EF%BB%BF%EF%BB%BF";
} else if (index === 2) {
return character + "%e2%80%8b";
}
return character;
})
.join("");
expect(
sanitizeUrl(
decodeURIComponent(
`${protocolWithControlCharacters}:alert(document.domain)`,
),
),
).toBe(BLANK_URL);
});
it(`replaces ${protocol} urls with ${BLANK_URL} when url begins with %20`, () => {
expect(
sanitizeUrl(
decodeURIComponent(
`%20%20%20%20${protocol}:alert(document.domain)`,
),
),
).toBe(BLANK_URL);
});
it(`replaces ${protocol} urls with ${BLANK_URL} when ${protocol} url begins with spaces`, () => {
expect(sanitizeUrl(` ${protocol}:alert(document.domain)`)).toBe(
BLANK_URL,
);
});
it(`does not replace ${protocol}: if it is not in the scheme of the URL`, () => {
expect(sanitizeUrl(`http://example.com#${protocol}:foo`)).toBe(
`http://example.com#${protocol}:foo`,
);
});
});
});
});
+10
View File
@@ -0,0 +1,10 @@
export const invalidProtocolRegex = /^([^\w]*)(javascript|data|vbscript)/im;
export const htmlEntitiesRegex = /&#(\w+)(^\w|;)?/g;
export const htmlCtrlEntityRegex = /&(newline|tab);/gi;
export const ctrlCharactersRegex =
/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim;
export const urlSchemeRegex = /^.+(:|&colon;)/gim;
export const whitespaceEscapeCharsRegex =
/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g;
export const relativeFirstCharacters = [".", "/"];
export const BLANK_URL = "about:blank";
+107
View File
@@ -0,0 +1,107 @@
import {
BLANK_URL,
ctrlCharactersRegex,
htmlCtrlEntityRegex,
htmlEntitiesRegex,
invalidProtocolRegex,
relativeFirstCharacters,
whitespaceEscapeCharsRegex,
urlSchemeRegex,
} from "./constants";
function isRelativeUrlWithoutProtocol(url: string): boolean {
return relativeFirstCharacters.indexOf(url[0]) > -1;
}
function decodeHtmlCharacters(str: string) {
const removedNullByte = str.replace(ctrlCharactersRegex, "");
return removedNullByte.replace(htmlEntitiesRegex, (match, dec) => {
return String.fromCharCode(dec);
});
}
function isValidUrl(url: string): boolean {
return URL.canParse(url);
}
function decodeURI(uri: string): string {
try {
return decodeURIComponent(uri);
} catch (e: unknown) {
// Ignoring error
// It is possible that the URI contains a `%` not associated
// with URI/URL-encoding.
return uri;
}
}
export function sanitizeUrl(url?: string): string {
if (!url) {
return BLANK_URL;
}
let charsToDecode;
let decodedUrl = decodeURI(url.trim());
do {
decodedUrl = decodeHtmlCharacters(decodedUrl)
.replace(htmlCtrlEntityRegex, "")
.replace(ctrlCharactersRegex, "")
.replace(whitespaceEscapeCharsRegex, "")
.trim();
decodedUrl = decodeURI(decodedUrl);
charsToDecode =
decodedUrl.match(ctrlCharactersRegex) ||
decodedUrl.match(htmlEntitiesRegex) ||
decodedUrl.match(htmlCtrlEntityRegex) ||
decodedUrl.match(whitespaceEscapeCharsRegex);
} while (charsToDecode && charsToDecode.length > 0);
const sanitizedUrl = decodedUrl;
if (!sanitizedUrl) {
return BLANK_URL;
}
if (isRelativeUrlWithoutProtocol(sanitizedUrl)) {
return sanitizedUrl;
}
// Remove any leading whitespace before checking the URL scheme
const trimmedUrl = sanitizedUrl.trimStart();
const urlSchemeParseResults = trimmedUrl.match(urlSchemeRegex);
if (!urlSchemeParseResults) {
return sanitizedUrl;
}
const urlScheme = urlSchemeParseResults[0].toLowerCase().trim();
if (invalidProtocolRegex.test(urlScheme)) {
return BLANK_URL;
}
const backSanitized = trimmedUrl.replace(/\\/g, "/");
// Handle special cases for mailto: and custom deep-link protocols
if (urlScheme === "mailto:" || urlScheme.includes("://")) {
return backSanitized;
}
// For http and https URLs, perform additional validation
if (urlScheme === "http:" || urlScheme === "https:") {
if (!isValidUrl(backSanitized)) {
return BLANK_URL;
}
const url = new URL(backSanitized);
url.protocol = url.protocol.toLowerCase();
url.hostname = url.hostname.toLowerCase();
return url.toString();
}
return backSanitized;
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"outDir": "./dist",
"allowJs": true,
"strict": true,
"target": "es5",
"resolveJsonModule": true,
"lib": ["es2015", "dom"]
},
"include": ["./src/**/*"],
"exclude": ["**/__tests__/*"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "happy-dom",
},
});