Skip to content

Commit 05e1989

Browse files
committed
Add httpOnly option and make it the default
Fixes #11
1 parent 430699d commit 05e1989

File tree

4 files changed

+210
-8
lines changed

4 files changed

+210
-8
lines changed

index.d.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,49 @@
1+
export type Options = {
2+
/**
3+
Only allow HTTP(S) protocols.
4+
5+
When set to `false`, any valid absolute URL will be accepted, including potentially unsafe protocols like `javascript:`, `ftp:`, `ws:`, etc.
6+
7+
@default true
8+
9+
@example
10+
```
11+
import isAbsoluteUrl from 'is-absolute-url';
12+
13+
isAbsoluteUrl('javascript:alert(1)');
14+
//=> false
15+
16+
isAbsoluteUrl('javascript:alert(1)', {httpOnly: false});
17+
//=> true
18+
```
19+
*/
20+
readonly httpOnly?: boolean;
21+
};
22+
123
/**
224
Check if a URL is absolute.
325
426
@param url - The URL to check.
27+
@param options - Options to customize the behavior.
528
629
@example
730
```
831
import isAbsoluteUrl from 'is-absolute-url';
932
10-
isAbsoluteUrl('http://sindresorhus.com/foo/bar');
33+
isAbsoluteUrl('https://sindresorhus.com/foo/bar');
1134
//=> true
1235
1336
isAbsoluteUrl('//sindresorhus.com');
1437
//=> false
1538
1639
isAbsoluteUrl('foo/bar');
1740
//=> false
41+
42+
isAbsoluteUrl('javascript:alert(1)');
43+
//=> false
44+
45+
isAbsoluteUrl('javascript:alert(1)', {httpOnly: false});
46+
//=> true
1847
```
1948
*/
20-
export default function isAbsoluteUrl(url: string): boolean;
49+
export default function isAbsoluteUrl(url: string, options?: Options): boolean;

index.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/;
55
// Windows paths like `c:\`
66
const WINDOWS_PATH_REGEX = /^[a-zA-Z]:\\/;
77

8-
export default function isAbsoluteUrl(url) {
8+
// HTTP(S) protocols only for maximum security
9+
const HTTP_PROTOCOLS_REGEX = /^https?:/i;
10+
11+
export default function isAbsoluteUrl(url, options = {}) {
912
if (typeof url !== 'string') {
1013
throw new TypeError(`Expected a \`string\`, got \`${typeof url}\``);
1114
}
@@ -14,5 +17,18 @@ export default function isAbsoluteUrl(url) {
1417
return false;
1518
}
1619

17-
return ABSOLUTE_URL_REGEX.test(url);
20+
if (!ABSOLUTE_URL_REGEX.test(url)) {
21+
return false;
22+
}
23+
24+
// Default httpOnly to true for security
25+
const {httpOnly = true} = options;
26+
27+
// When httpOnly is false, allow any absolute URL
28+
if (!httpOnly) {
29+
return true;
30+
}
31+
32+
// When httpOnly is true, only allow HTTP(S) protocols
33+
return HTTP_PROTOCOLS_REGEX.test(url);
1834
}

readme.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,39 @@ isAbsoluteUrl('//sindresorhus.com');
2121

2222
isAbsoluteUrl('foo/bar');
2323
//=> false
24+
25+
isAbsoluteUrl('javascript:alert(1)');
26+
//=> false
27+
28+
isAbsoluteUrl('javascript:alert(1)', {httpOnly: false});
29+
//=> true
2430
```
2531

32+
## API
33+
34+
### isAbsoluteUrl(url, options?)
35+
36+
#### url
37+
38+
Type: `string`
39+
40+
The URL to check.
41+
42+
#### options
43+
44+
Type: `object`
45+
46+
##### httpOnly
47+
48+
Type: `boolean`\
49+
Default: `true`
50+
51+
Only allow HTTP(S) protocols.
52+
53+
When set to `false`, any valid absolute URL will be accepted, including potentially unsafe protocols like `javascript:`, `ftp:`, `ws:`, etc.
54+
55+
> **Warning**: Setting `httpOnly` to `false` can pose security risks as it will return `true` for URLs with protocols like `javascript:`, `vbscript:`, `data:`, `ftp:`, `ws:`, etc. Only set this to `false` if you understand the implications and have appropriate safeguards in place.
56+
2657
## Related
2758

2859
See [is-relative-url](https://github.com/sindresorhus/is-relative-url) for the inverse.

test.js

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
import test from 'ava';
22
import isAbsoluteUrl from './index.js';
33

4-
test('main', t => {
4+
test('main - default httpOnly behavior', t => {
5+
// Allowed protocols with default httpOnly: true
56
t.true(isAbsoluteUrl('http://sindresorhus.com'));
67
t.true(isAbsoluteUrl('https://sindresorhus.com'));
78
t.true(isAbsoluteUrl('httpS://sindresorhus.com'));
8-
t.true(isAbsoluteUrl('file://sindresorhus.com'));
9-
t.true(isAbsoluteUrl('mailto:[email protected]'));
10-
t.true(isAbsoluteUrl('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D'));
9+
10+
// Blocked protocols with default httpOnly: true
11+
t.false(isAbsoluteUrl('ftp://sindresorhus.com'));
12+
t.false(isAbsoluteUrl('ftps://sindresorhus.com'));
13+
t.false(isAbsoluteUrl('ws://sindresorhus.com'));
14+
t.false(isAbsoluteUrl('wss://sindresorhus.com'));
15+
t.false(isAbsoluteUrl('file://sindresorhus.com'));
16+
t.false(isAbsoluteUrl('mailto:[email protected]'));
17+
t.false(isAbsoluteUrl('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D'));
18+
// eslint-disable-next-line no-script-url
19+
t.false(isAbsoluteUrl('javascript:alert(1)'));
20+
t.false(isAbsoluteUrl('vbscript:alert(1)'));
21+
22+
// Not absolute URLs
1123
t.false(isAbsoluteUrl('//sindresorhus.com'));
1224
t.false(isAbsoluteUrl('/foo/bar'));
1325
t.false(isAbsoluteUrl('foo/bar'));
@@ -17,3 +29,117 @@ test('main', t => {
1729
t.false(isAbsoluteUrl(String.raw`C:\Dev\test-broken`));
1830
t.false(isAbsoluteUrl('ht,tp://sindresorhus.com'));
1931
});
32+
33+
test('httpOnly: true - explicit', t => {
34+
// Should be identical to default behavior
35+
t.true(isAbsoluteUrl('http://example.com', {httpOnly: true}));
36+
t.true(isAbsoluteUrl('https://example.com', {httpOnly: true}));
37+
t.false(isAbsoluteUrl('ftp://example.com', {httpOnly: true}));
38+
// eslint-disable-next-line no-script-url
39+
t.false(isAbsoluteUrl('javascript:alert(1)', {httpOnly: true}));
40+
});
41+
42+
test('httpOnly: false - allow all absolute URLs', t => {
43+
// All protocols should be allowed
44+
t.true(isAbsoluteUrl('http://example.com', {httpOnly: false}));
45+
t.true(isAbsoluteUrl('https://example.com', {httpOnly: false}));
46+
t.true(isAbsoluteUrl('ftp://example.com', {httpOnly: false}));
47+
t.true(isAbsoluteUrl('ws://example.com', {httpOnly: false}));
48+
// eslint-disable-next-line no-script-url
49+
t.true(isAbsoluteUrl('javascript:alert(1)', {httpOnly: false}));
50+
t.true(isAbsoluteUrl('vbscript:alert(1)', {httpOnly: false}));
51+
t.true(isAbsoluteUrl('data:text/html,<h1>XSS</h1>', {httpOnly: false}));
52+
t.true(isAbsoluteUrl('file:///etc/passwd', {httpOnly: false}));
53+
t.true(isAbsoluteUrl('mailto:[email protected]', {httpOnly: false}));
54+
t.true(isAbsoluteUrl('tel:+1234567890', {httpOnly: false}));
55+
t.true(isAbsoluteUrl('custom-scheme://example.com', {httpOnly: false}));
56+
t.true(isAbsoluteUrl('a:b', {httpOnly: false}));
57+
58+
// Still reject non-absolute URLs
59+
t.false(isAbsoluteUrl('//example.com', {httpOnly: false}));
60+
t.false(isAbsoluteUrl('/path/to/file', {httpOnly: false}));
61+
t.false(isAbsoluteUrl('relative/path', {httpOnly: false}));
62+
t.false(isAbsoluteUrl('c:\\', {httpOnly: false}));
63+
});
64+
65+
test('URLs with ports, authentication, query strings, and fragments', t => {
66+
// With httpOnly: true (default)
67+
t.true(isAbsoluteUrl('http://example.com:8080'));
68+
t.true(isAbsoluteUrl('https://example.com:443/path'));
69+
t.true(isAbsoluteUrl('http://user:[email protected]'));
70+
t.true(isAbsoluteUrl('https://user:[email protected]:8080/path'));
71+
t.true(isAbsoluteUrl('http://example.com?query=value'));
72+
t.true(isAbsoluteUrl('https://example.com#fragment'));
73+
t.true(isAbsoluteUrl('http://example.com/path?query=value#fragment'));
74+
75+
// Blocked with httpOnly: true
76+
t.false(isAbsoluteUrl('ftp://example.com:21'));
77+
t.false(isAbsoluteUrl('ws://example.com:80/socket'));
78+
t.false(isAbsoluteUrl('file://localhost:1234'));
79+
t.false(isAbsoluteUrl('custom://example.com:9999'));
80+
});
81+
82+
test('edge cases', t => {
83+
// Mixed case protocols with httpOnly: true
84+
t.true(isAbsoluteUrl('HtTp://example.com'));
85+
t.true(isAbsoluteUrl('HttpS://example.com'));
86+
87+
// Previously allowed protocols now blocked for security
88+
t.false(isAbsoluteUrl('FTps://example.com'));
89+
t.false(isAbsoluteUrl('wSS://example.com'));
90+
91+
// Protocol with minimal content
92+
t.true(isAbsoluteUrl('http:'));
93+
t.true(isAbsoluteUrl('https:'));
94+
t.false(isAbsoluteUrl('ftp:'));
95+
t.false(isAbsoluteUrl('ws:'));
96+
97+
// Invalid absolute URLs
98+
t.false(isAbsoluteUrl('http'));
99+
t.false(isAbsoluteUrl('https'));
100+
t.false(isAbsoluteUrl(':http'));
101+
t.false(isAbsoluteUrl('://example.com'));
102+
103+
// Empty string and whitespace
104+
t.false(isAbsoluteUrl(''));
105+
t.false(isAbsoluteUrl(' '));
106+
t.false(isAbsoluteUrl(' http://example.com')); // Leading space breaks the pattern
107+
t.true(isAbsoluteUrl('http://example.com ')); // Trailing space doesn't affect absolute URL detection
108+
});
109+
110+
test('protocol edge cases and security boundaries', t => {
111+
// Protocol case variations
112+
t.true(isAbsoluteUrl('Http://example.com'));
113+
t.true(isAbsoluteUrl('HTTPS://example.com'));
114+
t.true(isAbsoluteUrl('hTtP://example.com'));
115+
116+
// Protocol boundary testing
117+
t.false(isAbsoluteUrl('http-custom://example.com')); // Should be blocked by httpOnly
118+
t.false(isAbsoluteUrl('https-modified://example.com')); // Should be blocked
119+
t.true(isAbsoluteUrl('http-custom://example.com', {httpOnly: false})); // Allowed when httpOnly is false
120+
121+
// Potential bypass attempts
122+
t.false(isAbsoluteUrl('h\u0000ttp://example.com')); // Null byte
123+
t.false(isAbsoluteUrl(' http://example.com')); // Leading space
124+
t.false(isAbsoluteUrl('\thttp://example.com')); // Leading tab
125+
});
126+
127+
test('unicode and internationalized domain names', t => {
128+
// Unicode in domain names should work
129+
t.true(isAbsoluteUrl('http://example.测试'));
130+
t.true(isAbsoluteUrl('https://xn--fsq.xn--0zwm56d')); // IDN encoded
131+
t.true(isAbsoluteUrl('http://münchen.de'));
132+
133+
// Unicode in other parts
134+
t.true(isAbsoluteUrl('http://example.com/测试'));
135+
t.true(isAbsoluteUrl('http://example.com?query=测试'));
136+
});
137+
138+
test('throws on invalid input', t => {
139+
t.throws(() => isAbsoluteUrl(null), {instanceOf: TypeError});
140+
t.throws(() => isAbsoluteUrl(undefined), {instanceOf: TypeError});
141+
t.throws(() => isAbsoluteUrl(123), {instanceOf: TypeError});
142+
t.throws(() => isAbsoluteUrl({}), {instanceOf: TypeError});
143+
t.throws(() => isAbsoluteUrl([]), {instanceOf: TypeError});
144+
t.throws(() => isAbsoluteUrl(true), {instanceOf: TypeError});
145+
});

0 commit comments

Comments
 (0)