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 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.4.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 DISCORD_DOMAINS = [
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 (!DISCORD_DOMAINS.includes(hostname)) return new InvalidDomainError(`${hostname} is not discord domain.`);
137
- return await fetch(input, {
138
- ...init,
139
- signal: AbortSignal.timeout(DISCORD_TIMEOUT)
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
- if (await isLocalUrl(reqUrl)) return new LocalAddressError(`${reqUrl} is local address.`);
149
- response = await fetch(reqUrl, {
150
- ...currentInit,
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 (url.protocol !== "http:" && url.protocol !== "https:") return new HeaderError(`${url.protocol} is not allowed.`);
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?.headers);
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 return response;
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 DISCORD_INVITE_LINK_START = [
184
- "https://discord.com/invite/",
185
- "https://ptb.discord.com/invite/",
186
- "https://canary.discord.com/invite/"
258
+ const DISCORD_DOMAINS = [
259
+ "discord.com",
260
+ "ptb.discord.com",
261
+ "canary.discord.com"
187
262
  ];
188
- async function isUsedCf(res) {
189
- try {
190
- const response = res.clone();
191
- if (response.status !== 403) return false;
192
- const { JSDOM } = await import("jsdom");
193
- return new JSDOM(await response.text()).window.document.querySelector("title")?.textContent === "Just a moment...";
194
- } catch {
195
- return false;
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 response = await safeFetch(url, {
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: DISCORD_INVITE_LINK_START.some((value) => resUrl.startsWith(value)),
207
- isUsedCf: await isUsedCf(response)
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 DISCORD_INVITE_LINK_START: string[];
51
- declare function isUsedCf(res: Response): Promise<boolean>;
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
- declare const DISCORD_DOMAINS: string[];
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, DISCORD_INVITE_LINK_START, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, InvalidDomainError, IsInviteLink, LocalAddressError, MAX_BYTES, RedirectError, SafeUrl, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, safeFetch, safeFetchForDiscord, safeUrl };
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 DISCORD_INVITE_LINK_START: string[];
51
- declare function isUsedCf(res: Response): Promise<boolean>;
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
- declare const DISCORD_DOMAINS: string[];
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, DISCORD_INVITE_LINK_START, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, InvalidDomainError, IsInviteLink, LocalAddressError, MAX_BYTES, RedirectError, SafeUrl, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, safeFetch, safeFetchForDiscord, safeUrl };
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.4.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 DISCORD_DOMAINS = [
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 (!DISCORD_DOMAINS.includes(hostname)) return new InvalidDomainError(`${hostname} is not discord domain.`);
136
- return await fetch(input, {
137
- ...init,
138
- signal: AbortSignal.timeout(DISCORD_TIMEOUT)
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
- if (await isLocalUrl(reqUrl)) return new LocalAddressError(`${reqUrl} is local address.`);
148
- response = await fetch(reqUrl, {
149
- ...currentInit,
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 (url.protocol !== "http:" && url.protocol !== "https:") return new HeaderError(`${url.protocol} is not allowed.`);
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?.headers);
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 return response;
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 DISCORD_INVITE_LINK_START = [
183
- "https://discord.com/invite/",
184
- "https://ptb.discord.com/invite/",
185
- "https://canary.discord.com/invite/"
257
+ const DISCORD_DOMAINS = [
258
+ "discord.com",
259
+ "ptb.discord.com",
260
+ "canary.discord.com"
186
261
  ];
187
- async function isUsedCf(res) {
188
- try {
189
- const response = res.clone();
190
- if (response.status !== 403) return false;
191
- const { JSDOM } = await import("jsdom");
192
- return new JSDOM(await response.text()).window.document.querySelector("title")?.textContent === "Just a moment...";
193
- } catch {
194
- return false;
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 response = await safeFetch(url, {
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: DISCORD_INVITE_LINK_START.some((value) => resUrl.startsWith(value)),
206
- isUsedCf: await isUsedCf(response)
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, DISCORD_INVITE_LINK_START, DISCORD_TIMEOUT, DISTOPIA_VERSION, HeaderError, InvalidDomainError, LocalAddressError, MAX_BYTES, RedirectError, isInviteLink, isLocalHostname, isLocalIPv4, isLocalIPv6, isLocalUrl, isUsedCf, isValidSize, config as oxfmtCfg, config$1 as oxlintCfg, safeFetch, safeFetchForDiscord, safeUrl };
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.4.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.54.0",
46
- "oxlint": "1.60.0",
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"