Skip to content

Commit 9a9a88a

Browse files
fraxkenCopilot
andauthored
refactor(probe/isLiteral): implement a new ShadyURL class with ipaddr.js (#423)
* refactor(probe/isLiteral): implement a new ShadyURL class with ipaddr.js * chore: remove duplicate .cd domain Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 0f28c4e commit 9a9a88a

File tree

6 files changed

+310
-30
lines changed

6 files changed

+310
-30
lines changed

.changeset/sweet-cobras-carry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nodesecure/js-x-ray": minor
3+
---
4+
5+
Implement a new ShadyURL class with ipaddr.js to detect malicious URL/ips

workspaces/js-x-ray/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@nodesecure/tracer": "^3.0.0",
5151
"digraph-js": "2.2.4",
5252
"frequency-set": "^2.1.0",
53+
"ipaddr.js": "2.2.0",
5354
"meriyah": "^6.0.0",
5455
"safe-regex": "^2.1.1",
5556
"ts-pattern": "^5.0.6"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Import Third-party Dependencies
2+
import ipaddress from "ipaddr.js";
3+
4+
// CONSTANTS
5+
const kShadyLinkRegExps = [
6+
/(http[s]?:\/\/(bit\.ly|ipinfo\.io|httpbin\.org|api\.ipify\.org).*)$/,
7+
/(http[s]?:\/\/.*\.(link|xyz|tk|ml|ga|cf|gq|pw|top|club|mw|bd|ke|am|sbs|date|quest|cd|bid|ws|icu|cam|uno|email|stream))$/
8+
];
9+
10+
export class ShadyURL {
11+
static isSafe(
12+
input: string
13+
): boolean {
14+
if (!URL.canParse(input)) {
15+
return true;
16+
}
17+
18+
const parsedUrl = new URL(input);
19+
const hostname = parsedUrl.hostname;
20+
if (ipaddress.isValid(hostname)) {
21+
if (this.#isPrivateIPAddress(hostname)) {
22+
return true;
23+
}
24+
}
25+
26+
const scheme = parsedUrl.protocol.replace(":", "");
27+
if (scheme !== "https") {
28+
return false;
29+
}
30+
31+
return kShadyLinkRegExps.every((regex) => !regex.test(input));
32+
}
33+
34+
static #isPrivateIPAddress(
35+
ipAddress: string
36+
): boolean {
37+
let ip = ipaddress.parse(ipAddress);
38+
39+
if (ip instanceof ipaddress.IPv6 && ip.isIPv4MappedAddress()) {
40+
ip = ip.toIPv4Address();
41+
}
42+
43+
const range = ip.range();
44+
if (range !== "unicast") {
45+
return true;
46+
}
47+
48+
return false;
49+
}
50+
}

workspaces/js-x-ray/src/probes/isLiteral.ts

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,11 @@ import type { ESTree } from "meriyah";
88
// Import Internal Dependencies
99
import { SourceFile } from "../SourceFile.js";
1010
import { generateWarning } from "../warnings.js";
11+
import { ShadyURL } from "../ShadyURL.js";
1112
import type { Literal } from "../types/estree.js";
1213

13-
const kMapRegexIps = Object.freeze({
14-
// eslint-disable-next-line @stylistic/max-len
15-
regexIPv4: /^(https?:\/\/)(?!127\.)(?!.*:(?:0{1,3}|25[6-9])\.)(?!.*:(?:25[6-9])\.(?:0{1,3}|25[6-9])\.)(?!.*:(?:25[6-9])\.(?:25[6-9])\.(?:0{1,3}|25[6-9])\.)(?!.*:(?:25[6-9])\.(?:25[6-9])\.(?:25[6-9])\.(?:0{1,3}|25[6-9]))((?:\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])\.){3}(?:\d{1,2}|1\d{2}|2[0-4]\d|25[0-5])(?::\d{1,5})?(\/[^\s]*)?$/,
16-
regexIPv6: /^(https?:\/\/)(\[[0-9A-Fa-f:]+\])(?::\d{1,5})?(\/[^\s]*)?$/
17-
});
18-
1914
// CONSTANTS
2015
const kNodeDeps = new Set(builtinModules);
21-
const kShadyLinkRegExps = [
22-
kMapRegexIps.regexIPv4,
23-
kMapRegexIps.regexIPv6,
24-
/(http[s]?:\/\/(bit\.ly|ipinfo\.io|httpbin\.org|api\.ipify\.org).*)$/,
25-
/(http[s]?:\/\/.*\.(link|xyz|tk|ml|ga|cf|gq|pw|top|club|mw|bd|ke|am|sbs|date|quest|cd|bid|cd|ws|icu|cam|uno|email|stream))$/
26-
];
27-
2816
/**
2917
* @description Search for Literal AST Node
3018
* @see https://github.com/estree/estree/blob/master/es5.md#literal
@@ -67,16 +55,14 @@ function main(
6755
}
6856
// Else we are checking all other string with our suspect method
6957
else {
70-
for (const regex of kShadyLinkRegExps) {
71-
if (regex.test(node.value)) {
72-
sourceFile.warnings.push(
73-
generateWarning(
74-
"shady-link", { value: node.value, location }
75-
)
76-
);
58+
if (!ShadyURL.isSafe(node.value)) {
59+
sourceFile.warnings.push(
60+
generateWarning(
61+
"shady-link", { value: node.value, location }
62+
)
63+
);
7764

78-
return;
79-
}
65+
return;
8066
}
8167

8268
sourceFile.analyzeLiteral(node);
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
// Import Node.js Dependencies
2+
import { describe, it } from "node:test";
3+
import assert from "node:assert/strict";
4+
5+
// Import Internal Dependencies
6+
import { ShadyURL } from "../src/ShadyURL.js";
7+
8+
describe("ShadyURL.isSafe()", () => {
9+
describe("when input is not a valid URL", () => {
10+
it("should return true for an invalid URL", () => {
11+
assert.equal(ShadyURL.isSafe("not-a-url"), true);
12+
});
13+
14+
it("should return true for an empty string", () => {
15+
assert.equal(ShadyURL.isSafe(""), true);
16+
});
17+
18+
it("should return true for a malformed URL", () => {
19+
assert.equal(ShadyURL.isSafe("http://"), true);
20+
});
21+
});
22+
23+
describe("when URL contains an IP address", () => {
24+
describe("private IP addresses", () => {
25+
it("should return true for localhost IPv4", () => {
26+
assert.equal(ShadyURL.isSafe("https://127.0.0.1/path"), true);
27+
});
28+
29+
it("should return true for private IPv4 (10.x.x.x)", () => {
30+
assert.equal(ShadyURL.isSafe("https://10.0.0.1/path"), true);
31+
});
32+
33+
it("should return true for private IPv4 (192.168.x.x)", () => {
34+
assert.equal(ShadyURL.isSafe("https://192.168.1.1/path"), true);
35+
});
36+
37+
it("should return true for private IPv4 (172.16.x.x)", () => {
38+
assert.equal(ShadyURL.isSafe("https://172.16.0.1/path"), true);
39+
});
40+
41+
it("should return true for IPv6 loopback address", () => {
42+
assert.equal(ShadyURL.isSafe("https://[::1]/path"), true);
43+
});
44+
45+
it("should return true for IPv4-mapped IPv6 private address", () => {
46+
assert.equal(ShadyURL.isSafe("https://[::ffff:127.0.0.1]/path"), true);
47+
});
48+
49+
it("should return true for IPv4-mapped IPv6 private address (192.168.x.x)", () => {
50+
assert.equal(ShadyURL.isSafe("https://[::ffff:192.168.1.1]/path"), true);
51+
});
52+
});
53+
54+
describe("public IP addresses", () => {
55+
it("should return false for public IPv4 with HTTP", () => {
56+
assert.equal(ShadyURL.isSafe("http://8.8.8.8/path"), false);
57+
});
58+
59+
it("should return true for public IPv4 with HTTPS", () => {
60+
assert.equal(ShadyURL.isSafe("https://8.8.8.8/path"), true);
61+
});
62+
63+
it("should return false for public IPv6 with HTTP", () => {
64+
assert.equal(ShadyURL.isSafe("http://[2001:4860:4860::8888]/path"), false);
65+
});
66+
67+
it("should return true for public IPv6 with HTTPS", () => {
68+
assert.equal(ShadyURL.isSafe("https://[2001:4860:4860::8888]/path"), true);
69+
});
70+
});
71+
});
72+
73+
describe("when URL scheme is not HTTPS", () => {
74+
it("should return false for HTTP URL", () => {
75+
assert.equal(ShadyURL.isSafe("http://example.com"), false);
76+
});
77+
78+
it("should return false for FTP URL", () => {
79+
assert.equal(ShadyURL.isSafe("ftp://example.com"), false);
80+
});
81+
});
82+
83+
describe("when URL matches shady link patterns", () => {
84+
describe("known shady domains", () => {
85+
it("should return false for bit.ly", () => {
86+
assert.equal(ShadyURL.isSafe("https://bit.ly/abc123"), false);
87+
});
88+
89+
it("should return false for ipinfo.io", () => {
90+
assert.equal(ShadyURL.isSafe("https://ipinfo.io/json"), false);
91+
});
92+
93+
it("should return false for httpbin.org", () => {
94+
assert.equal(ShadyURL.isSafe("https://httpbin.org/get"), false);
95+
});
96+
97+
it("should return false for api.ipify.org", () => {
98+
assert.equal(ShadyURL.isSafe("https://api.ipify.org"), false);
99+
});
100+
});
101+
102+
describe("suspicious TLDs", () => {
103+
it("should return false for .link TLD", () => {
104+
assert.equal(ShadyURL.isSafe("https://malicious.link"), false);
105+
});
106+
107+
it("should return false for .xyz TLD", () => {
108+
assert.equal(ShadyURL.isSafe("https://malicious.xyz"), false);
109+
});
110+
111+
it("should return false for .tk TLD", () => {
112+
assert.equal(ShadyURL.isSafe("https://malicious.tk"), false);
113+
});
114+
115+
it("should return false for .ml TLD", () => {
116+
assert.equal(ShadyURL.isSafe("https://malicious.ml"), false);
117+
});
118+
119+
it("should return false for .ga TLD", () => {
120+
assert.equal(ShadyURL.isSafe("https://malicious.ga"), false);
121+
});
122+
123+
it("should return false for .cf TLD", () => {
124+
assert.equal(ShadyURL.isSafe("https://malicious.cf"), false);
125+
});
126+
127+
it("should return false for .gq TLD", () => {
128+
assert.equal(ShadyURL.isSafe("https://malicious.gq"), false);
129+
});
130+
131+
it("should return false for .pw TLD", () => {
132+
assert.equal(ShadyURL.isSafe("https://malicious.pw"), false);
133+
});
134+
135+
it("should return false for .top TLD", () => {
136+
assert.equal(ShadyURL.isSafe("https://malicious.top"), false);
137+
});
138+
139+
it("should return false for .club TLD", () => {
140+
assert.equal(ShadyURL.isSafe("https://malicious.club"), false);
141+
});
142+
143+
it("should return false for .mw TLD", () => {
144+
assert.equal(ShadyURL.isSafe("https://malicious.mw"), false);
145+
});
146+
147+
it("should return false for .bd TLD", () => {
148+
assert.equal(ShadyURL.isSafe("https://malicious.bd"), false);
149+
});
150+
151+
it("should return false for .ke TLD", () => {
152+
assert.equal(ShadyURL.isSafe("https://malicious.ke"), false);
153+
});
154+
155+
it("should return false for .am TLD", () => {
156+
assert.equal(ShadyURL.isSafe("https://malicious.am"), false);
157+
});
158+
159+
it("should return false for .sbs TLD", () => {
160+
assert.equal(ShadyURL.isSafe("https://malicious.sbs"), false);
161+
});
162+
163+
it("should return false for .date TLD", () => {
164+
assert.equal(ShadyURL.isSafe("https://malicious.date"), false);
165+
});
166+
167+
it("should return false for .quest TLD", () => {
168+
assert.equal(ShadyURL.isSafe("https://malicious.quest"), false);
169+
});
170+
171+
it("should return false for .cd TLD", () => {
172+
assert.equal(ShadyURL.isSafe("https://malicious.cd"), false);
173+
});
174+
175+
it("should return false for .bid TLD", () => {
176+
assert.equal(ShadyURL.isSafe("https://malicious.bid"), false);
177+
});
178+
179+
it("should return false for .ws TLD", () => {
180+
assert.equal(ShadyURL.isSafe("https://malicious.ws"), false);
181+
});
182+
183+
it("should return false for .icu TLD", () => {
184+
assert.equal(ShadyURL.isSafe("https://malicious.icu"), false);
185+
});
186+
187+
it("should return false for .cam TLD", () => {
188+
assert.equal(ShadyURL.isSafe("https://malicious.cam"), false);
189+
});
190+
191+
it("should return false for .uno TLD", () => {
192+
assert.equal(ShadyURL.isSafe("https://malicious.uno"), false);
193+
});
194+
195+
it("should return false for .email TLD", () => {
196+
assert.equal(ShadyURL.isSafe("https://malicious.email"), false);
197+
});
198+
199+
it("should return false for .stream TLD", () => {
200+
assert.equal(ShadyURL.isSafe("https://malicious.stream"), false);
201+
});
202+
});
203+
});
204+
205+
describe("when URL is safe", () => {
206+
it("should return true for a standard HTTPS URL", () => {
207+
assert.equal(ShadyURL.isSafe("https://example.com"), true);
208+
});
209+
210+
it("should return true for a HTTPS URL with path", () => {
211+
assert.equal(ShadyURL.isSafe("https://example.com/path/to/resource"), true);
212+
});
213+
214+
it("should return true for a HTTPS URL with query params", () => {
215+
assert.equal(ShadyURL.isSafe("https://example.com?foo=bar"), true);
216+
});
217+
218+
it("should return true for npm registry URL", () => {
219+
assert.equal(ShadyURL.isSafe("https://registry.npmjs.org/package"), true);
220+
});
221+
222+
it("should return true for GitHub URL", () => {
223+
assert.equal(ShadyURL.isSafe("https://github.com/NodeSecure/js-x-ray"), true);
224+
});
225+
226+
it("should return true for .com TLD", () => {
227+
assert.equal(ShadyURL.isSafe("https://safe-website.com"), true);
228+
});
229+
230+
it("should return true for .org TLD", () => {
231+
assert.equal(ShadyURL.isSafe("https://safe-website.org"), true);
232+
});
233+
234+
it("should return true for .io TLD (not in shady list)", () => {
235+
assert.equal(ShadyURL.isSafe("https://safe-website.io"), true);
236+
});
237+
});
238+
});

0 commit comments

Comments
 (0)