@zhin.js/plugin-rss 1.1.18 → 1.1.20
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/LICENSE +21 -0
- package/README.md +15 -4
- package/commands/rss-add/[url]/index.js +1 -1
- package/commands/rss-add/[url]/index.ts +1 -1
- package/commands/rss-preview/[url]/index.js +1 -1
- package/commands/rss-preview/[url]/index.ts +1 -1
- package/lib/admin-i18n.d.ts +6 -2
- package/lib/admin-i18n.d.ts.map +1 -1
- package/lib/admin-i18n.js +4 -0
- package/lib/admin-i18n.js.map +1 -1
- package/lib/admin-tasks.d.ts +5 -1
- package/lib/admin-tasks.d.ts.map +1 -1
- package/lib/admin-tasks.js +30 -12
- package/lib/admin-tasks.js.map +1 -1
- package/lib/console.d.ts.map +1 -1
- package/lib/console.js +91 -2
- package/lib/console.js.map +1 -1
- package/lib/feed.d.ts +9 -1
- package/lib/feed.d.ts.map +1 -1
- package/lib/feed.js +27 -7
- package/lib/feed.js.map +1 -1
- package/lib/poll.d.ts +7 -2
- package/lib/poll.d.ts.map +1 -1
- package/lib/poll.js +56 -23
- package/lib/poll.js.map +1 -1
- package/lib/public-fetch.d.ts +34 -0
- package/lib/public-fetch.d.ts.map +1 -0
- package/lib/public-fetch.js +144 -0
- package/lib/public-fetch.js.map +1 -0
- package/package.json +14 -19
- package/pages/index/index.js +59 -5
- package/pages/index/index.ts +59 -5
- package/plugin.js +4 -1
- package/schema.json +46 -3
- package/skills/rss/tools/rss_preview_feed/index.js +1 -1
- package/skills/rss/tools/rss_preview_feed/index.ts +1 -1
- package/src/admin-i18n.ts +4 -0
- package/src/admin-tasks.ts +13 -3
- package/src/console.ts +92 -2
- package/src/feed.ts +30 -7
- package/src/poll.ts +58 -23
- package/src/public-fetch.ts +160 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound address policy for URLs that come from chat users or third-party pages (SSRF guard).
|
|
3
|
+
*
|
|
4
|
+
* This process connects to the target itself, so a URL is requested only when its host is a
|
|
5
|
+
* public name and every address it resolves to is public. Redirects are followed by hand and
|
|
6
|
+
* each hop is checked again. The check and the connection use separate DNS lookups, so a
|
|
7
|
+
* rebinding resolver can still race them; that needs a connection-level lookup hook.
|
|
8
|
+
*
|
|
9
|
+
* Plugins may not depend on each other, so this small module is duplicated in the plugins that
|
|
10
|
+
* fetch user-supplied URLs. Keep the copies identical.
|
|
11
|
+
*/
|
|
12
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
13
|
+
import { BlockList, isIP } from 'node:net';
|
|
14
|
+
|
|
15
|
+
export class BlockedUrlError extends Error {
|
|
16
|
+
readonly code = 'blocked_url';
|
|
17
|
+
constructor(reason: string) {
|
|
18
|
+
super(`refusing to fetch: ${reason}`);
|
|
19
|
+
this.name = 'BlockedUrlError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const blockedV4 = new BlockList();
|
|
24
|
+
for (const [base, bits] of [
|
|
25
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
|
|
26
|
+
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16],
|
|
27
|
+
['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
28
|
+
] as const) blockedV4.addSubnet(base, bits, 'ipv4');
|
|
29
|
+
// Host-internal endpoints that sit in otherwise public space (Alibaba metadata, Azure WireServer).
|
|
30
|
+
for (const address of ['100.100.100.200', '168.63.129.16']) blockedV4.addAddress(address, 'ipv4');
|
|
31
|
+
|
|
32
|
+
// IPv6 is an allow-list: global unicast only, minus the special ranges inside it.
|
|
33
|
+
const globalV6 = new BlockList();
|
|
34
|
+
globalV6.addSubnet('2000::', 3, 'ipv6');
|
|
35
|
+
const blockedV6 = new BlockList();
|
|
36
|
+
for (const [base, bits] of [
|
|
37
|
+
['2001::', 32], ['2001:2::', 48], ['2001:10::', 28], ['2001:db8::', 32], ['2002::', 16], ['3fff::', 20],
|
|
38
|
+
] as const) blockedV6.addSubnet(base, bits, 'ipv6');
|
|
39
|
+
const mappedV4 = new BlockList();
|
|
40
|
+
mappedV4.addSubnet('::ffff:0:0', 96, 'ipv6');
|
|
41
|
+
|
|
42
|
+
/** True when a literal IP address is globally routable and not a known host-internal endpoint. */
|
|
43
|
+
export function isPublicAddress(address: string): boolean {
|
|
44
|
+
const family = isIP(address);
|
|
45
|
+
if (family === 4) return !blockedV4.check(address, 'ipv4');
|
|
46
|
+
if (family !== 6) return false;
|
|
47
|
+
// BlockList matches IPv4 rules against IPv4-mapped IPv6 addresses.
|
|
48
|
+
if (mappedV4.check(address, 'ipv6')) return !blockedV4.check(address, 'ipv6');
|
|
49
|
+
return globalV6.check(address, 'ipv6') && !blockedV6.check(address, 'ipv6');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const BLOCKED_SUFFIXES = [
|
|
53
|
+
'localhost', 'local', 'localdomain', 'internal', 'intranet', 'private', 'corp', 'home', 'lan',
|
|
54
|
+
'arpa', 'test', 'invalid', 'example', 'onion', 'metadata.goog',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** Resolves a host name to every address it maps to. */
|
|
58
|
+
export type HostLookup = (hostname: string) => Promise<readonly string[]>;
|
|
59
|
+
|
|
60
|
+
const systemLookup: HostLookup = async (hostname) =>
|
|
61
|
+
(await dnsLookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse `input` and refuse anything that is not an http(s) URL on a public host.
|
|
65
|
+
* Fails closed: an unresolvable name, or a name with any non-public address, is refused.
|
|
66
|
+
*/
|
|
67
|
+
export async function assertPublicUrl(
|
|
68
|
+
input: string | URL,
|
|
69
|
+
lookup: HostLookup = systemLookup,
|
|
70
|
+
allowPrivateNetwork = false,
|
|
71
|
+
): Promise<URL> {
|
|
72
|
+
let url: URL;
|
|
73
|
+
try {
|
|
74
|
+
url = new URL(input);
|
|
75
|
+
} catch {
|
|
76
|
+
throw new BlockedUrlError('not a valid URL');
|
|
77
|
+
}
|
|
78
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new BlockedUrlError('only http and https are allowed');
|
|
79
|
+
if (url.username || url.password) throw new BlockedUrlError('credentials in the URL are not allowed');
|
|
80
|
+
// Operator opt-in (never chat users): scheme and credential rules still apply, the address policy does not.
|
|
81
|
+
if (allowPrivateNetwork) return url;
|
|
82
|
+
// The WHATWG parser already canonicalised odd IPv4 spellings (0x7f.1, 2130706433) to dotted quads.
|
|
83
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
|
|
84
|
+
if (isIP(host)) {
|
|
85
|
+
if (!isPublicAddress(host)) throw new BlockedUrlError(`${host} is not a public address`);
|
|
86
|
+
return url;
|
|
87
|
+
}
|
|
88
|
+
if (!host.includes('.') || BLOCKED_SUFFIXES.some((suffix) => host === suffix || host.endsWith(`.${suffix}`))) {
|
|
89
|
+
throw new BlockedUrlError(`${host} is not a public host name`);
|
|
90
|
+
}
|
|
91
|
+
let addresses: readonly string[];
|
|
92
|
+
try {
|
|
93
|
+
addresses = await lookup(host);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new BlockedUrlError(`${host} could not be resolved`);
|
|
96
|
+
}
|
|
97
|
+
if (addresses.length === 0) throw new BlockedUrlError(`${host} could not be resolved`);
|
|
98
|
+
const internal = addresses.find((address) => !isPublicAddress(address));
|
|
99
|
+
if (internal !== undefined) throw new BlockedUrlError(`${host} resolves to a non-public address`);
|
|
100
|
+
return url;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface PublicFetchOptions {
|
|
104
|
+
/** Bounds every hop and the body read of the returned response. */
|
|
105
|
+
readonly timeoutMs?: number;
|
|
106
|
+
readonly maxRedirects?: number;
|
|
107
|
+
readonly init?: Omit<RequestInit, 'redirect' | 'signal'>;
|
|
108
|
+
/** Extra per-hop rule on top of the address policy (for example a host allow-list). */
|
|
109
|
+
readonly allow?: (url: URL) => boolean;
|
|
110
|
+
readonly lookup?: HostLookup;
|
|
111
|
+
/** Skip the public-address requirement. Only for an explicit operator setting; default false. */
|
|
112
|
+
readonly allowPrivateNetwork?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface PublicResponse {
|
|
116
|
+
readonly response: Response;
|
|
117
|
+
/** URL of the hop that produced `response`. */
|
|
118
|
+
readonly url: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fetch with redirects followed by hand so that every hop passes the address policy first. */
|
|
122
|
+
export async function fetchPublic(input: string, options: PublicFetchOptions = {}): Promise<PublicResponse> {
|
|
123
|
+
const signal = AbortSignal.timeout(options.timeoutMs ?? 10_000);
|
|
124
|
+
const maxRedirects = options.maxRedirects ?? 5;
|
|
125
|
+
let current = input;
|
|
126
|
+
for (let hop = 0; hop <= maxRedirects; hop += 1) {
|
|
127
|
+
const url = await assertPublicUrl(current, options.lookup, options.allowPrivateNetwork);
|
|
128
|
+
if (options.allow && !options.allow(url)) throw new BlockedUrlError(`${url.hostname} is outside the allow-list`);
|
|
129
|
+
const response = await fetch(url.href, { ...options.init, redirect: 'manual', signal });
|
|
130
|
+
const location = response.status >= 300 && response.status < 400 ? response.headers.get('location') : null;
|
|
131
|
+
if (!location) return { response, url: url.href };
|
|
132
|
+
await response.body?.cancel();
|
|
133
|
+
current = new URL(location, url).href;
|
|
134
|
+
}
|
|
135
|
+
throw new BlockedUrlError('too many redirects');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Read a body with a byte cap; the stream is cancelled and `undefined` returned once it is exceeded. */
|
|
139
|
+
export async function readCappedBody(response: Response, maxBytes: number): Promise<Buffer | undefined> {
|
|
140
|
+
const declared = Number(response.headers.get('content-length'));
|
|
141
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
142
|
+
await response.body?.cancel();
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (!response.body) return Buffer.alloc(0);
|
|
146
|
+
const chunks: Uint8Array[] = [];
|
|
147
|
+
let total = 0;
|
|
148
|
+
const reader = response.body.getReader();
|
|
149
|
+
for (;;) {
|
|
150
|
+
const { done, value } = await reader.read();
|
|
151
|
+
if (done) break;
|
|
152
|
+
total += value.byteLength;
|
|
153
|
+
if (total > maxBytes) {
|
|
154
|
+
await reader.cancel();
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
chunks.push(value);
|
|
158
|
+
}
|
|
159
|
+
return Buffer.concat(chunks);
|
|
160
|
+
}
|