distopia 0.4.0 → 0.5.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/dist/index.cjs +119 -45
- package/dist/index.d.cts +12 -5
- package/dist/index.d.mts +12 -5
- package/dist/index.mjs +115 -45
- package/package.json +5 -6
package/dist/index.cjs
CHANGED
|
@@ -3,8 +3,9 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
|
3
3
|
let oxfmt = require("oxfmt");
|
|
4
4
|
let oxlint = require("oxlint");
|
|
5
5
|
let node_dns = require("node:dns");
|
|
6
|
+
let zod = require("zod");
|
|
6
7
|
//#region package.json
|
|
7
|
-
var version = "0.
|
|
8
|
+
var version = "0.5.0";
|
|
8
9
|
//#endregion
|
|
9
10
|
//#region ../template/src/oxfmt.ts
|
|
10
11
|
const config = (0, oxfmt.defineConfig)({
|
|
@@ -61,7 +62,7 @@ function isLocalIPv4(host) {
|
|
|
61
62
|
c,
|
|
62
63
|
d
|
|
63
64
|
]) if (!Number.isInteger(v) || v < 0 || v > 255) return false;
|
|
64
|
-
return a === 0 || a === 127 || a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
|
|
65
|
+
return a === 0 || a === 127 || a === 10 || a === 100 && b >= 64 && b <= 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
|
|
65
66
|
}
|
|
66
67
|
function isLocalIPv6(addr) {
|
|
67
68
|
const h = addr.toLowerCase();
|
|
@@ -90,12 +91,23 @@ async function isLocalUrl(url) {
|
|
|
90
91
|
if (/^\d/.test(hostname)) return isLocalIPv4(hostname);
|
|
91
92
|
return await isLocalHostname(hostname);
|
|
92
93
|
}
|
|
94
|
+
async function isHttpProtocol(url) {
|
|
95
|
+
const protocol = URL.parse(url.toString())?.protocol;
|
|
96
|
+
return protocol === "http:" || protocol === "https:";
|
|
97
|
+
}
|
|
93
98
|
//#endregion
|
|
94
99
|
//#region ../../src/infrastructure/http/src/dns.ts
|
|
95
100
|
async function isLocalHostname(hostname) {
|
|
96
101
|
const [v4, v6] = await Promise.all([node_dns.promises.resolve4(hostname).catch(() => []), node_dns.promises.resolve6(hostname).catch(() => [])]);
|
|
97
102
|
return v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip));
|
|
98
103
|
}
|
|
104
|
+
async function resolveHostnameToSafeIp(hostname) {
|
|
105
|
+
const [v4, v6] = await Promise.all([node_dns.promises.resolve4(hostname).catch(() => []), node_dns.promises.resolve6(hostname).catch(() => [])]);
|
|
106
|
+
if (v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip))) return null;
|
|
107
|
+
if (v4[0] !== void 0) return v4[0];
|
|
108
|
+
if (v6[0] !== void 0) return v6[0];
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
99
111
|
//#endregion
|
|
100
112
|
//#region ../../src/infrastructure/http/src/redirect.ts
|
|
101
113
|
const DEFAULT_MAX_REDIRECT = 10;
|
|
@@ -125,29 +137,65 @@ const DEFAULT_TIMEOUT = 10 * 1e3;
|
|
|
125
137
|
const DISCORD_TIMEOUT = 30 * 1e3;
|
|
126
138
|
//#endregion
|
|
127
139
|
//#region ../../src/infrastructure/http/src/safefetch.ts
|
|
128
|
-
const
|
|
140
|
+
const ALLOW_DISCORD_DOMAINS = [
|
|
129
141
|
"discord.com",
|
|
130
142
|
"discordapp.com",
|
|
131
143
|
"discord.gg"
|
|
132
144
|
];
|
|
145
|
+
async function resolveToPinnedUrl(url, init) {
|
|
146
|
+
const { hostname, host, protocol } = new URL(url);
|
|
147
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) return new LocalAddressError(`${url} is local address.`);
|
|
148
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
149
|
+
if (isLocalIPv6(hostname.slice(1, -1))) return new LocalAddressError(`${url} is local address.`);
|
|
150
|
+
return {
|
|
151
|
+
url,
|
|
152
|
+
init
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (/^\d/.test(hostname)) {
|
|
156
|
+
if (isLocalIPv4(hostname)) return new LocalAddressError(`${url} is local address.`);
|
|
157
|
+
return {
|
|
158
|
+
url,
|
|
159
|
+
init
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const resolvedIp = await resolveHostnameToSafeIp(hostname);
|
|
163
|
+
if (resolvedIp === null) return new LocalAddressError(`${url} is local address.`);
|
|
164
|
+
const pinnedUrlObj = new URL(url);
|
|
165
|
+
pinnedUrlObj.hostname = resolvedIp.includes(":") ? `[${resolvedIp}]` : resolvedIp;
|
|
166
|
+
const headers = new Headers(init.headers);
|
|
167
|
+
headers.set("Host", host);
|
|
168
|
+
const pinnedInit = {
|
|
169
|
+
...init,
|
|
170
|
+
headers,
|
|
171
|
+
...protocol === "https:" ? { tls: { serverName: hostname } } : {}
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
url: pinnedUrlObj.href,
|
|
175
|
+
init: pinnedInit
|
|
176
|
+
};
|
|
177
|
+
}
|
|
133
178
|
async function safeFetchForDiscord(input, init) {
|
|
134
|
-
if (await isLocalUrl(input)) return new LocalAddressError(`${input} is local address.`);
|
|
135
179
|
const hostname = new URL(input).hostname;
|
|
136
|
-
if (!
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
180
|
+
if (!ALLOW_DISCORD_DOMAINS.includes(hostname)) return new InvalidDomainError(`${hostname} is not discord domain.`);
|
|
181
|
+
const pinned = await resolveToPinnedUrl(input, init ?? {});
|
|
182
|
+
if (pinned instanceof LocalAddressError) return pinned;
|
|
183
|
+
return await fetch(pinned.url, {
|
|
184
|
+
...pinned.init,
|
|
185
|
+
signal: AbortSignal.timeout(DISCORD_TIMEOUT),
|
|
186
|
+
redirect: "manual"
|
|
140
187
|
});
|
|
141
188
|
}
|
|
142
|
-
async function safeFetch(input, init) {
|
|
189
|
+
async function safeFetch(input, init, options) {
|
|
143
190
|
let reqUrl = input;
|
|
144
|
-
let currentInit = init;
|
|
191
|
+
let currentInit = init ?? {};
|
|
145
192
|
let redirectCount = 0;
|
|
146
193
|
let response;
|
|
147
194
|
while (true) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
195
|
+
const pinned = await resolveToPinnedUrl(reqUrl, currentInit);
|
|
196
|
+
if (pinned instanceof LocalAddressError) return pinned;
|
|
197
|
+
response = await fetch(pinned.url, {
|
|
198
|
+
...pinned.init,
|
|
151
199
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT),
|
|
152
200
|
redirect: "manual"
|
|
153
201
|
});
|
|
@@ -162,9 +210,10 @@ async function safeFetch(input, init) {
|
|
|
162
210
|
} catch {
|
|
163
211
|
return new HeaderError(`${location} is invalid.`);
|
|
164
212
|
}
|
|
165
|
-
if (
|
|
213
|
+
if (options?.detectDiscordProtocol && url.protocol === "discord://") return response;
|
|
214
|
+
if (!await isHttpProtocol(url)) return new HeaderError(`${url.protocol} is not allowed.`);
|
|
166
215
|
if (new URL(reqUrl).origin !== url.origin) {
|
|
167
|
-
const headers = new Headers(currentInit
|
|
216
|
+
const headers = new Headers(currentInit.headers);
|
|
168
217
|
headers.delete("authorization");
|
|
169
218
|
headers.delete("cookie");
|
|
170
219
|
currentInit = {
|
|
@@ -174,66 +223,89 @@ async function safeFetch(input, init) {
|
|
|
174
223
|
}
|
|
175
224
|
reqUrl = url.href;
|
|
176
225
|
redirectCount += 1;
|
|
177
|
-
} else
|
|
226
|
+
} else {
|
|
227
|
+
const finalUrl = reqUrl;
|
|
228
|
+
return new Proxy(response, { get(target, prop) {
|
|
229
|
+
if (prop === "url") return finalUrl;
|
|
230
|
+
const value = Reflect.get(target, prop, target);
|
|
231
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
232
|
+
} });
|
|
233
|
+
}
|
|
178
234
|
if (redirectCount > 10) return new RedirectError(`Redirect count is over 10`);
|
|
179
235
|
}
|
|
180
236
|
}
|
|
181
237
|
//#endregion
|
|
238
|
+
//#region ../../src/infrastructure/http/src/safeurl.ts
|
|
239
|
+
function safeUrl(strings, ...values) {
|
|
240
|
+
let result = "";
|
|
241
|
+
for (const [index, str] of strings.entries()) {
|
|
242
|
+
result += str;
|
|
243
|
+
const value = values[index];
|
|
244
|
+
if (value !== void 0) result += encodeURIComponent(value);
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
const safeUrlSchema = zod.z.string().refine((url) => {
|
|
249
|
+
const parsed = URL.parse(url);
|
|
250
|
+
return parsed !== null && (parsed.protocol === "http:" || parsed.protocol === "https:");
|
|
251
|
+
}, { message: "URL must be a valid http or https URL" }).transform((url) => url);
|
|
252
|
+
function validateSafeUrl(url) {
|
|
253
|
+
const result = safeUrlSchema.safeParse(url);
|
|
254
|
+
return result.success ? result.data : null;
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
182
257
|
//#region ../../src/infrastructure/http/src/invite.ts
|
|
183
|
-
const
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
258
|
+
const DISCORD_DOMAINS = [
|
|
259
|
+
"discord.com",
|
|
260
|
+
"ptb.discord.com",
|
|
261
|
+
"canary.discord.com"
|
|
187
262
|
];
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
263
|
+
const INVITE_PROTOCOL = [
|
|
264
|
+
"discord:",
|
|
265
|
+
"http:",
|
|
266
|
+
"https:"
|
|
267
|
+
];
|
|
268
|
+
async function isDiscordInviteLink(url) {
|
|
269
|
+
const parsedUrl = URL.parse(url.toString());
|
|
270
|
+
if (parsedUrl === null) return false;
|
|
271
|
+
return INVITE_PROTOCOL.includes(parsedUrl.protocol) && DISCORD_DOMAINS.includes(parsedUrl.host) && parsedUrl.pathname.startsWith("/invite/");
|
|
272
|
+
}
|
|
273
|
+
function isUsedCf(res) {
|
|
274
|
+
return res.headers.get("cf-mitigated") === "challenge";
|
|
197
275
|
}
|
|
198
276
|
async function isInviteLink(url) {
|
|
199
|
-
const
|
|
277
|
+
const safeUrl = validateSafeUrl(url);
|
|
278
|
+
if (safeUrl === null) return new LocalAddressError(`${url} is not a safe URL.`);
|
|
279
|
+
const response = await safeFetch(safeUrl, {
|
|
200
280
|
method: "GET",
|
|
201
281
|
headers: { "User-Agent": "Mozilla/5.0" }
|
|
202
|
-
});
|
|
282
|
+
}, { detectDiscordProtocol: true });
|
|
203
283
|
if (response instanceof Error) return response;
|
|
204
284
|
const resUrl = response.url;
|
|
285
|
+
const location = response.headers.get("location");
|
|
205
286
|
return {
|
|
206
|
-
content:
|
|
207
|
-
isUsedCf:
|
|
287
|
+
content: await isDiscordInviteLink(resUrl) || location !== null && await isDiscordInviteLink(location),
|
|
288
|
+
isUsedCf: isUsedCf(response)
|
|
208
289
|
};
|
|
209
290
|
}
|
|
210
291
|
//#endregion
|
|
211
|
-
//#region ../../src/infrastructure/http/src/safeurl.ts
|
|
212
|
-
function safeUrl(strings, ...values) {
|
|
213
|
-
let result = "";
|
|
214
|
-
for (const [index, str] of strings.entries()) {
|
|
215
|
-
result += str;
|
|
216
|
-
const value = values[index];
|
|
217
|
-
if (value !== void 0) result += encodeURIComponent(value);
|
|
218
|
-
}
|
|
219
|
-
return result;
|
|
220
|
-
}
|
|
221
|
-
//#endregion
|
|
222
292
|
//#region src/index.ts
|
|
223
293
|
const DISTOPIA_VERSION = version;
|
|
224
294
|
//#endregion
|
|
295
|
+
exports.ALLOW_DISCORD_DOMAINS = ALLOW_DISCORD_DOMAINS;
|
|
225
296
|
exports.BodySizeError = BodySizeError;
|
|
226
297
|
exports.DEFAULT_MAX_REDIRECT = DEFAULT_MAX_REDIRECT;
|
|
227
298
|
exports.DEFAULT_TIMEOUT = DEFAULT_TIMEOUT;
|
|
228
299
|
exports.DISCORD_DOMAINS = DISCORD_DOMAINS;
|
|
229
|
-
exports.DISCORD_INVITE_LINK_START = DISCORD_INVITE_LINK_START;
|
|
230
300
|
exports.DISCORD_TIMEOUT = DISCORD_TIMEOUT;
|
|
231
301
|
exports.DISTOPIA_VERSION = DISTOPIA_VERSION;
|
|
232
302
|
exports.HeaderError = HeaderError;
|
|
303
|
+
exports.INVITE_PROTOCOL = INVITE_PROTOCOL;
|
|
233
304
|
exports.InvalidDomainError = InvalidDomainError;
|
|
234
305
|
exports.LocalAddressError = LocalAddressError;
|
|
235
306
|
exports.MAX_BYTES = MAX_BYTES;
|
|
236
307
|
exports.RedirectError = RedirectError;
|
|
308
|
+
exports.isHttpProtocol = isHttpProtocol;
|
|
237
309
|
exports.isInviteLink = isInviteLink;
|
|
238
310
|
exports.isLocalHostname = isLocalHostname;
|
|
239
311
|
exports.isLocalIPv4 = isLocalIPv4;
|
|
@@ -243,6 +315,8 @@ exports.isUsedCf = isUsedCf;
|
|
|
243
315
|
exports.isValidSize = isValidSize;
|
|
244
316
|
exports.oxfmtCfg = config;
|
|
245
317
|
exports.oxlintCfg = config$1;
|
|
318
|
+
exports.resolveHostnameToSafeIp = resolveHostnameToSafeIp;
|
|
246
319
|
exports.safeFetch = safeFetch;
|
|
247
320
|
exports.safeFetchForDiscord = safeFetchForDiscord;
|
|
248
321
|
exports.safeUrl = safeUrl;
|
|
322
|
+
exports.validateSafeUrl = validateSafeUrl;
|
package/dist/index.d.cts
CHANGED
|
@@ -41,14 +41,16 @@ declare class RedirectError extends Error {}
|
|
|
41
41
|
//#endregion
|
|
42
42
|
//#region ../../src/infrastructure/http/src/dns.d.ts
|
|
43
43
|
declare function isLocalHostname(hostname: string): Promise<boolean>;
|
|
44
|
+
declare function resolveHostnameToSafeIp(hostname: string): Promise<string | null>;
|
|
44
45
|
//#endregion
|
|
45
46
|
//#region ../../src/infrastructure/http/src/invite.d.ts
|
|
46
47
|
type IsInviteLink = {
|
|
47
48
|
content: boolean;
|
|
48
49
|
isUsedCf: boolean;
|
|
49
50
|
};
|
|
50
|
-
declare const
|
|
51
|
-
declare
|
|
51
|
+
declare const DISCORD_DOMAINS: string[];
|
|
52
|
+
declare const INVITE_PROTOCOL: string[];
|
|
53
|
+
declare function isUsedCf(res: Response): boolean;
|
|
52
54
|
declare function isInviteLink(url: string): Promise<IsInviteLink | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
|
|
53
55
|
//#endregion
|
|
54
56
|
//#region ../../src/infrastructure/http/src/redirect.d.ts
|
|
@@ -60,11 +62,15 @@ type Branded<T, Brand> = T & {
|
|
|
60
62
|
};
|
|
61
63
|
type SafeUrl = Branded<string, "distopiaSafeUrl">;
|
|
62
64
|
declare function safeUrl(strings: TemplateStringsArray, ...values: (string | number)[]): SafeUrl;
|
|
65
|
+
declare function validateSafeUrl(url: string): SafeUrl | null;
|
|
63
66
|
//#endregion
|
|
64
67
|
//#region ../../src/infrastructure/http/src/safefetch.d.ts
|
|
65
|
-
|
|
68
|
+
type SafeFetchOptions = {
|
|
69
|
+
detectDiscordProtocol?: boolean;
|
|
70
|
+
};
|
|
71
|
+
declare const ALLOW_DISCORD_DOMAINS: string[];
|
|
66
72
|
declare function safeFetchForDiscord(input: SafeUrl, init?: RequestInit): Promise<Response | LocalAddressError | InvalidDomainError>;
|
|
67
|
-
declare function safeFetch(input: SafeUrl, init?: RequestInit): Promise<Response | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
|
|
73
|
+
declare function safeFetch(input: SafeUrl, init?: RequestInit, options?: SafeFetchOptions): Promise<Response | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
|
|
68
74
|
//#endregion
|
|
69
75
|
//#region ../../src/infrastructure/http/src/size.d.ts
|
|
70
76
|
declare const MAX_BYTES: number;
|
|
@@ -78,8 +84,9 @@ declare const DISCORD_TIMEOUT: number;
|
|
|
78
84
|
declare function isLocalIPv4(host: string): boolean;
|
|
79
85
|
declare function isLocalIPv6(addr: string): boolean;
|
|
80
86
|
declare function isLocalUrl(url: string): Promise<boolean>;
|
|
87
|
+
declare function isHttpProtocol(url: string | URL): Promise<boolean>;
|
|
81
88
|
//#endregion
|
|
82
89
|
//#region src/index.d.ts
|
|
83
90
|
declare const DISTOPIA_VERSION: string;
|
|
84
91
|
//#endregion
|
|
85
|
-
export { BodySizeError, Branded, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS,
|
|
92
|
+
export { ALLOW_DISCORD_DOMAINS, BodySizeError, Branded, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, INVITE_PROTOCOL, InvalidDomainError, IsInviteLink, LocalAddressError, MAX_BYTES, RedirectError, SafeFetchOptions, SafeUrl, isHttpProtocol, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, resolveHostnameToSafeIp, safeFetch, safeFetchForDiscord, safeUrl, validateSafeUrl };
|
package/dist/index.d.mts
CHANGED
|
@@ -41,14 +41,16 @@ declare class RedirectError extends Error {}
|
|
|
41
41
|
//#endregion
|
|
42
42
|
//#region ../../src/infrastructure/http/src/dns.d.ts
|
|
43
43
|
declare function isLocalHostname(hostname: string): Promise<boolean>;
|
|
44
|
+
declare function resolveHostnameToSafeIp(hostname: string): Promise<string | null>;
|
|
44
45
|
//#endregion
|
|
45
46
|
//#region ../../src/infrastructure/http/src/invite.d.ts
|
|
46
47
|
type IsInviteLink = {
|
|
47
48
|
content: boolean;
|
|
48
49
|
isUsedCf: boolean;
|
|
49
50
|
};
|
|
50
|
-
declare const
|
|
51
|
-
declare
|
|
51
|
+
declare const DISCORD_DOMAINS: string[];
|
|
52
|
+
declare const INVITE_PROTOCOL: string[];
|
|
53
|
+
declare function isUsedCf(res: Response): boolean;
|
|
52
54
|
declare function isInviteLink(url: string): Promise<IsInviteLink | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
|
|
53
55
|
//#endregion
|
|
54
56
|
//#region ../../src/infrastructure/http/src/redirect.d.ts
|
|
@@ -60,11 +62,15 @@ type Branded<T, Brand> = T & {
|
|
|
60
62
|
};
|
|
61
63
|
type SafeUrl = Branded<string, "distopiaSafeUrl">;
|
|
62
64
|
declare function safeUrl(strings: TemplateStringsArray, ...values: (string | number)[]): SafeUrl;
|
|
65
|
+
declare function validateSafeUrl(url: string): SafeUrl | null;
|
|
63
66
|
//#endregion
|
|
64
67
|
//#region ../../src/infrastructure/http/src/safefetch.d.ts
|
|
65
|
-
|
|
68
|
+
type SafeFetchOptions = {
|
|
69
|
+
detectDiscordProtocol?: boolean;
|
|
70
|
+
};
|
|
71
|
+
declare const ALLOW_DISCORD_DOMAINS: string[];
|
|
66
72
|
declare function safeFetchForDiscord(input: SafeUrl, init?: RequestInit): Promise<Response | LocalAddressError | InvalidDomainError>;
|
|
67
|
-
declare function safeFetch(input: SafeUrl, init?: RequestInit): Promise<Response | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
|
|
73
|
+
declare function safeFetch(input: SafeUrl, init?: RequestInit, options?: SafeFetchOptions): Promise<Response | LocalAddressError | HeaderError | RedirectError | BodySizeError>;
|
|
68
74
|
//#endregion
|
|
69
75
|
//#region ../../src/infrastructure/http/src/size.d.ts
|
|
70
76
|
declare const MAX_BYTES: number;
|
|
@@ -78,8 +84,9 @@ declare const DISCORD_TIMEOUT: number;
|
|
|
78
84
|
declare function isLocalIPv4(host: string): boolean;
|
|
79
85
|
declare function isLocalIPv6(addr: string): boolean;
|
|
80
86
|
declare function isLocalUrl(url: string): Promise<boolean>;
|
|
87
|
+
declare function isHttpProtocol(url: string | URL): Promise<boolean>;
|
|
81
88
|
//#endregion
|
|
82
89
|
//#region src/index.d.ts
|
|
83
90
|
declare const DISTOPIA_VERSION: string;
|
|
84
91
|
//#endregion
|
|
85
|
-
export { BodySizeError, Branded, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS,
|
|
92
|
+
export { ALLOW_DISCORD_DOMAINS, BodySizeError, Branded, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, INVITE_PROTOCOL, InvalidDomainError, IsInviteLink, LocalAddressError, MAX_BYTES, RedirectError, SafeFetchOptions, SafeUrl, isHttpProtocol, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, resolveHostnameToSafeIp, safeFetch, safeFetchForDiscord, safeUrl, validateSafeUrl };
|
package/dist/index.mjs
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import { defineConfig } from "oxfmt";
|
|
3
3
|
import { defineConfig as defineConfig$1 } from "oxlint";
|
|
4
4
|
import { promises } from "node:dns";
|
|
5
|
+
import { z } from "zod";
|
|
5
6
|
//#region package.json
|
|
6
|
-
var version = "0.
|
|
7
|
+
var version = "0.5.0";
|
|
7
8
|
//#endregion
|
|
8
9
|
//#region ../template/src/oxfmt.ts
|
|
9
10
|
const config = defineConfig({
|
|
@@ -60,7 +61,7 @@ function isLocalIPv4(host) {
|
|
|
60
61
|
c,
|
|
61
62
|
d
|
|
62
63
|
]) if (!Number.isInteger(v) || v < 0 || v > 255) return false;
|
|
63
|
-
return a === 0 || a === 127 || a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
|
|
64
|
+
return a === 0 || a === 127 || a === 10 || a === 100 && b >= 64 && b <= 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254;
|
|
64
65
|
}
|
|
65
66
|
function isLocalIPv6(addr) {
|
|
66
67
|
const h = addr.toLowerCase();
|
|
@@ -89,12 +90,23 @@ async function isLocalUrl(url) {
|
|
|
89
90
|
if (/^\d/.test(hostname)) return isLocalIPv4(hostname);
|
|
90
91
|
return await isLocalHostname(hostname);
|
|
91
92
|
}
|
|
93
|
+
async function isHttpProtocol(url) {
|
|
94
|
+
const protocol = URL.parse(url.toString())?.protocol;
|
|
95
|
+
return protocol === "http:" || protocol === "https:";
|
|
96
|
+
}
|
|
92
97
|
//#endregion
|
|
93
98
|
//#region ../../src/infrastructure/http/src/dns.ts
|
|
94
99
|
async function isLocalHostname(hostname) {
|
|
95
100
|
const [v4, v6] = await Promise.all([promises.resolve4(hostname).catch(() => []), promises.resolve6(hostname).catch(() => [])]);
|
|
96
101
|
return v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip));
|
|
97
102
|
}
|
|
103
|
+
async function resolveHostnameToSafeIp(hostname) {
|
|
104
|
+
const [v4, v6] = await Promise.all([promises.resolve4(hostname).catch(() => []), promises.resolve6(hostname).catch(() => [])]);
|
|
105
|
+
if (v4.some((ip) => isLocalIPv4(ip)) || v6.some((ip) => isLocalIPv6(ip))) return null;
|
|
106
|
+
if (v4[0] !== void 0) return v4[0];
|
|
107
|
+
if (v6[0] !== void 0) return v6[0];
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
98
110
|
//#endregion
|
|
99
111
|
//#region ../../src/infrastructure/http/src/redirect.ts
|
|
100
112
|
const DEFAULT_MAX_REDIRECT = 10;
|
|
@@ -124,29 +136,65 @@ const DEFAULT_TIMEOUT = 10 * 1e3;
|
|
|
124
136
|
const DISCORD_TIMEOUT = 30 * 1e3;
|
|
125
137
|
//#endregion
|
|
126
138
|
//#region ../../src/infrastructure/http/src/safefetch.ts
|
|
127
|
-
const
|
|
139
|
+
const ALLOW_DISCORD_DOMAINS = [
|
|
128
140
|
"discord.com",
|
|
129
141
|
"discordapp.com",
|
|
130
142
|
"discord.gg"
|
|
131
143
|
];
|
|
144
|
+
async function resolveToPinnedUrl(url, init) {
|
|
145
|
+
const { hostname, host, protocol } = new URL(url);
|
|
146
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) return new LocalAddressError(`${url} is local address.`);
|
|
147
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
148
|
+
if (isLocalIPv6(hostname.slice(1, -1))) return new LocalAddressError(`${url} is local address.`);
|
|
149
|
+
return {
|
|
150
|
+
url,
|
|
151
|
+
init
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (/^\d/.test(hostname)) {
|
|
155
|
+
if (isLocalIPv4(hostname)) return new LocalAddressError(`${url} is local address.`);
|
|
156
|
+
return {
|
|
157
|
+
url,
|
|
158
|
+
init
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const resolvedIp = await resolveHostnameToSafeIp(hostname);
|
|
162
|
+
if (resolvedIp === null) return new LocalAddressError(`${url} is local address.`);
|
|
163
|
+
const pinnedUrlObj = new URL(url);
|
|
164
|
+
pinnedUrlObj.hostname = resolvedIp.includes(":") ? `[${resolvedIp}]` : resolvedIp;
|
|
165
|
+
const headers = new Headers(init.headers);
|
|
166
|
+
headers.set("Host", host);
|
|
167
|
+
const pinnedInit = {
|
|
168
|
+
...init,
|
|
169
|
+
headers,
|
|
170
|
+
...protocol === "https:" ? { tls: { serverName: hostname } } : {}
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
url: pinnedUrlObj.href,
|
|
174
|
+
init: pinnedInit
|
|
175
|
+
};
|
|
176
|
+
}
|
|
132
177
|
async function safeFetchForDiscord(input, init) {
|
|
133
|
-
if (await isLocalUrl(input)) return new LocalAddressError(`${input} is local address.`);
|
|
134
178
|
const hostname = new URL(input).hostname;
|
|
135
|
-
if (!
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
179
|
+
if (!ALLOW_DISCORD_DOMAINS.includes(hostname)) return new InvalidDomainError(`${hostname} is not discord domain.`);
|
|
180
|
+
const pinned = await resolveToPinnedUrl(input, init ?? {});
|
|
181
|
+
if (pinned instanceof LocalAddressError) return pinned;
|
|
182
|
+
return await fetch(pinned.url, {
|
|
183
|
+
...pinned.init,
|
|
184
|
+
signal: AbortSignal.timeout(DISCORD_TIMEOUT),
|
|
185
|
+
redirect: "manual"
|
|
139
186
|
});
|
|
140
187
|
}
|
|
141
|
-
async function safeFetch(input, init) {
|
|
188
|
+
async function safeFetch(input, init, options) {
|
|
142
189
|
let reqUrl = input;
|
|
143
|
-
let currentInit = init;
|
|
190
|
+
let currentInit = init ?? {};
|
|
144
191
|
let redirectCount = 0;
|
|
145
192
|
let response;
|
|
146
193
|
while (true) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
194
|
+
const pinned = await resolveToPinnedUrl(reqUrl, currentInit);
|
|
195
|
+
if (pinned instanceof LocalAddressError) return pinned;
|
|
196
|
+
response = await fetch(pinned.url, {
|
|
197
|
+
...pinned.init,
|
|
150
198
|
signal: AbortSignal.timeout(DEFAULT_TIMEOUT),
|
|
151
199
|
redirect: "manual"
|
|
152
200
|
});
|
|
@@ -161,9 +209,10 @@ async function safeFetch(input, init) {
|
|
|
161
209
|
} catch {
|
|
162
210
|
return new HeaderError(`${location} is invalid.`);
|
|
163
211
|
}
|
|
164
|
-
if (
|
|
212
|
+
if (options?.detectDiscordProtocol && url.protocol === "discord://") return response;
|
|
213
|
+
if (!await isHttpProtocol(url)) return new HeaderError(`${url.protocol} is not allowed.`);
|
|
165
214
|
if (new URL(reqUrl).origin !== url.origin) {
|
|
166
|
-
const headers = new Headers(currentInit
|
|
215
|
+
const headers = new Headers(currentInit.headers);
|
|
167
216
|
headers.delete("authorization");
|
|
168
217
|
headers.delete("cookie");
|
|
169
218
|
currentInit = {
|
|
@@ -173,52 +222,73 @@ async function safeFetch(input, init) {
|
|
|
173
222
|
}
|
|
174
223
|
reqUrl = url.href;
|
|
175
224
|
redirectCount += 1;
|
|
176
|
-
} else
|
|
225
|
+
} else {
|
|
226
|
+
const finalUrl = reqUrl;
|
|
227
|
+
return new Proxy(response, { get(target, prop) {
|
|
228
|
+
if (prop === "url") return finalUrl;
|
|
229
|
+
const value = Reflect.get(target, prop, target);
|
|
230
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
231
|
+
} });
|
|
232
|
+
}
|
|
177
233
|
if (redirectCount > 10) return new RedirectError(`Redirect count is over 10`);
|
|
178
234
|
}
|
|
179
235
|
}
|
|
180
236
|
//#endregion
|
|
237
|
+
//#region ../../src/infrastructure/http/src/safeurl.ts
|
|
238
|
+
function safeUrl(strings, ...values) {
|
|
239
|
+
let result = "";
|
|
240
|
+
for (const [index, str] of strings.entries()) {
|
|
241
|
+
result += str;
|
|
242
|
+
const value = values[index];
|
|
243
|
+
if (value !== void 0) result += encodeURIComponent(value);
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
const safeUrlSchema = z.string().refine((url) => {
|
|
248
|
+
const parsed = URL.parse(url);
|
|
249
|
+
return parsed !== null && (parsed.protocol === "http:" || parsed.protocol === "https:");
|
|
250
|
+
}, { message: "URL must be a valid http or https URL" }).transform((url) => url);
|
|
251
|
+
function validateSafeUrl(url) {
|
|
252
|
+
const result = safeUrlSchema.safeParse(url);
|
|
253
|
+
return result.success ? result.data : null;
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
181
256
|
//#region ../../src/infrastructure/http/src/invite.ts
|
|
182
|
-
const
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
257
|
+
const DISCORD_DOMAINS = [
|
|
258
|
+
"discord.com",
|
|
259
|
+
"ptb.discord.com",
|
|
260
|
+
"canary.discord.com"
|
|
186
261
|
];
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
262
|
+
const INVITE_PROTOCOL = [
|
|
263
|
+
"discord:",
|
|
264
|
+
"http:",
|
|
265
|
+
"https:"
|
|
266
|
+
];
|
|
267
|
+
async function isDiscordInviteLink(url) {
|
|
268
|
+
const parsedUrl = URL.parse(url.toString());
|
|
269
|
+
if (parsedUrl === null) return false;
|
|
270
|
+
return INVITE_PROTOCOL.includes(parsedUrl.protocol) && DISCORD_DOMAINS.includes(parsedUrl.host) && parsedUrl.pathname.startsWith("/invite/");
|
|
271
|
+
}
|
|
272
|
+
function isUsedCf(res) {
|
|
273
|
+
return res.headers.get("cf-mitigated") === "challenge";
|
|
196
274
|
}
|
|
197
275
|
async function isInviteLink(url) {
|
|
198
|
-
const
|
|
276
|
+
const safeUrl = validateSafeUrl(url);
|
|
277
|
+
if (safeUrl === null) return new LocalAddressError(`${url} is not a safe URL.`);
|
|
278
|
+
const response = await safeFetch(safeUrl, {
|
|
199
279
|
method: "GET",
|
|
200
280
|
headers: { "User-Agent": "Mozilla/5.0" }
|
|
201
|
-
});
|
|
281
|
+
}, { detectDiscordProtocol: true });
|
|
202
282
|
if (response instanceof Error) return response;
|
|
203
283
|
const resUrl = response.url;
|
|
284
|
+
const location = response.headers.get("location");
|
|
204
285
|
return {
|
|
205
|
-
content:
|
|
206
|
-
isUsedCf:
|
|
286
|
+
content: await isDiscordInviteLink(resUrl) || location !== null && await isDiscordInviteLink(location),
|
|
287
|
+
isUsedCf: isUsedCf(response)
|
|
207
288
|
};
|
|
208
289
|
}
|
|
209
290
|
//#endregion
|
|
210
|
-
//#region ../../src/infrastructure/http/src/safeurl.ts
|
|
211
|
-
function safeUrl(strings, ...values) {
|
|
212
|
-
let result = "";
|
|
213
|
-
for (const [index, str] of strings.entries()) {
|
|
214
|
-
result += str;
|
|
215
|
-
const value = values[index];
|
|
216
|
-
if (value !== void 0) result += encodeURIComponent(value);
|
|
217
|
-
}
|
|
218
|
-
return result;
|
|
219
|
-
}
|
|
220
|
-
//#endregion
|
|
221
291
|
//#region src/index.ts
|
|
222
292
|
const DISTOPIA_VERSION = version;
|
|
223
293
|
//#endregion
|
|
224
|
-
export { BodySizeError, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS,
|
|
294
|
+
export { ALLOW_DISCORD_DOMAINS, BodySizeError, DEFAULT_MAX_REDIRECT, DEFAULT_TIMEOUT, DISCORD_DOMAINS, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, INVITE_PROTOCOL, InvalidDomainError, LocalAddressError, MAX_BYTES, RedirectError, isHttpProtocol, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, resolveHostnameToSafeIp, safeFetch, safeFetchForDiscord, safeUrl, validateSafeUrl };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "distopia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Library for Distopia.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"distopia",
|
|
@@ -38,13 +38,12 @@
|
|
|
38
38
|
"prepublishOnly": "bun run build"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@types/jsdom": "28.0.3",
|
|
42
41
|
"@types/node": "24.13.2",
|
|
43
|
-
"jsdom": "29.1.1",
|
|
44
42
|
"jsr": "0.14.3",
|
|
45
|
-
"oxfmt": "0.
|
|
46
|
-
"oxlint": "1.
|
|
47
|
-
"tsdown": "0.22.2"
|
|
43
|
+
"oxfmt": "0.56.0",
|
|
44
|
+
"oxlint": "1.71.0",
|
|
45
|
+
"tsdown": "0.22.2",
|
|
46
|
+
"zod": "4.4.3"
|
|
48
47
|
},
|
|
49
48
|
"devDependencies": {
|
|
50
49
|
"bun": "1.3.13"
|