openclaw-groupme 0.4.2 → 0.4.3
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.js +14 -0
- package/dist/src/accounts.js +119 -0
- package/dist/src/channel.js +366 -0
- package/dist/src/config-schema.js +86 -0
- package/dist/src/groupme-api.js +80 -0
- package/dist/src/history.js +37 -0
- package/dist/src/inbound.js +308 -0
- package/dist/src/monitor.js +234 -0
- package/dist/src/normalize.js +30 -0
- package/dist/src/onboarding.js +422 -0
- package/dist/src/parse.js +217 -0
- package/dist/src/policy.js +18 -0
- package/dist/src/rate-limit.js +95 -0
- package/dist/src/replay-cache.js +55 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/security.js +332 -0
- package/dist/src/send.js +271 -0
- package/dist/src/types.js +1 -0
- package/package.json +5 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const DEFAULT_MAX_TRACKED_KEYS = 10_000;
|
|
2
|
+
function allowInWindow(params) {
|
|
3
|
+
const { state, key, limit, windowMs, now } = params;
|
|
4
|
+
const current = state.get(key) ?? [];
|
|
5
|
+
const minTs = now - windowMs;
|
|
6
|
+
const retained = current.filter((ts) => ts > minTs);
|
|
7
|
+
if (retained.length >= limit) {
|
|
8
|
+
state.set(key, retained);
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
retained.push(now);
|
|
12
|
+
state.set(key, retained);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
export class GroupMeRateLimiter {
|
|
16
|
+
windowMs;
|
|
17
|
+
maxRequestsPerIp;
|
|
18
|
+
maxRequestsPerSender;
|
|
19
|
+
maxConcurrent;
|
|
20
|
+
maxTrackedKeys;
|
|
21
|
+
byIp = new Map();
|
|
22
|
+
bySender = new Map();
|
|
23
|
+
inFlight = 0;
|
|
24
|
+
constructor(params) {
|
|
25
|
+
this.windowMs = Math.max(1, Math.floor(params.windowMs));
|
|
26
|
+
this.maxRequestsPerIp = Math.max(1, Math.floor(params.maxRequestsPerIp));
|
|
27
|
+
this.maxRequestsPerSender = Math.max(1, Math.floor(params.maxRequestsPerSender));
|
|
28
|
+
this.maxConcurrent = Math.max(1, Math.floor(params.maxConcurrent));
|
|
29
|
+
this.maxTrackedKeys = DEFAULT_MAX_TRACKED_KEYS;
|
|
30
|
+
}
|
|
31
|
+
evaluate(params, now = Date.now()) {
|
|
32
|
+
const ipKey = params.ip.trim() || "unknown";
|
|
33
|
+
const senderKey = params.senderId.trim() || "unknown";
|
|
34
|
+
this.pruneState(this.byIp, now);
|
|
35
|
+
this.pruneState(this.bySender, now);
|
|
36
|
+
this.capStateSize(this.byIp);
|
|
37
|
+
this.capStateSize(this.bySender);
|
|
38
|
+
if (this.inFlight >= this.maxConcurrent) {
|
|
39
|
+
return { kind: "rejected", scope: "concurrency" };
|
|
40
|
+
}
|
|
41
|
+
if (!allowInWindow({
|
|
42
|
+
state: this.byIp,
|
|
43
|
+
key: ipKey,
|
|
44
|
+
limit: this.maxRequestsPerIp,
|
|
45
|
+
windowMs: this.windowMs,
|
|
46
|
+
now,
|
|
47
|
+
})) {
|
|
48
|
+
return { kind: "rejected", scope: "ip" };
|
|
49
|
+
}
|
|
50
|
+
if (!allowInWindow({
|
|
51
|
+
state: this.bySender,
|
|
52
|
+
key: senderKey,
|
|
53
|
+
limit: this.maxRequestsPerSender,
|
|
54
|
+
windowMs: this.windowMs,
|
|
55
|
+
now,
|
|
56
|
+
})) {
|
|
57
|
+
return { kind: "rejected", scope: "sender" };
|
|
58
|
+
}
|
|
59
|
+
this.inFlight += 1;
|
|
60
|
+
let released = false;
|
|
61
|
+
return {
|
|
62
|
+
kind: "accepted",
|
|
63
|
+
release: () => {
|
|
64
|
+
if (released) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
released = true;
|
|
68
|
+
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
inflightCount() {
|
|
73
|
+
return this.inFlight;
|
|
74
|
+
}
|
|
75
|
+
pruneState(state, now) {
|
|
76
|
+
const minTs = now - this.windowMs;
|
|
77
|
+
for (const [key, timestamps] of state) {
|
|
78
|
+
const retained = timestamps.filter((ts) => ts > minTs);
|
|
79
|
+
if (retained.length === 0) {
|
|
80
|
+
state.delete(key);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
state.set(key, retained);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
capStateSize(state) {
|
|
87
|
+
while (state.size > this.maxTrackedKeys) {
|
|
88
|
+
const oldest = state.keys().next().value;
|
|
89
|
+
if (!oldest) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
state.delete(oldest);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export class GroupMeReplayCache {
|
|
3
|
+
ttlMs;
|
|
4
|
+
maxEntries;
|
|
5
|
+
entries = new Map();
|
|
6
|
+
constructor(params) {
|
|
7
|
+
this.ttlMs = Math.max(1, Math.floor(params.ttlSeconds * 1000));
|
|
8
|
+
this.maxEntries = Math.max(1, Math.floor(params.maxEntries));
|
|
9
|
+
}
|
|
10
|
+
checkAndRemember(key, now = Date.now()) {
|
|
11
|
+
this.pruneExpired(now);
|
|
12
|
+
const existing = this.entries.get(key);
|
|
13
|
+
if (existing && existing.expiresAt > now) {
|
|
14
|
+
return { kind: "duplicate", key };
|
|
15
|
+
}
|
|
16
|
+
this.entries.delete(key);
|
|
17
|
+
this.entries.set(key, { expiresAt: now + this.ttlMs });
|
|
18
|
+
this.evictOverflow();
|
|
19
|
+
return { kind: "accepted", key };
|
|
20
|
+
}
|
|
21
|
+
size() {
|
|
22
|
+
return this.entries.size;
|
|
23
|
+
}
|
|
24
|
+
pruneExpired(now) {
|
|
25
|
+
for (const [key, entry] of this.entries) {
|
|
26
|
+
if (entry.expiresAt > now) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
this.entries.delete(key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
evictOverflow() {
|
|
33
|
+
while (this.entries.size > this.maxEntries) {
|
|
34
|
+
const oldest = this.entries.keys().next().value;
|
|
35
|
+
if (!oldest) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.entries.delete(oldest);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function buildReplayKey(message) {
|
|
43
|
+
const id = message.id.trim();
|
|
44
|
+
if (id) {
|
|
45
|
+
return `id:${id}`;
|
|
46
|
+
}
|
|
47
|
+
const sourceGuid = message.sourceGuid.trim();
|
|
48
|
+
if (sourceGuid) {
|
|
49
|
+
return `source_guid:${sourceGuid}`;
|
|
50
|
+
}
|
|
51
|
+
const fallback = createHash("sha256")
|
|
52
|
+
.update(`${message.groupId}\u0000${message.senderId}\u0000${message.createdAt}\u0000${message.text}`)
|
|
53
|
+
.digest("hex");
|
|
54
|
+
return `fallback:${fallback}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { BlockList, isIP } from "node:net";
|
|
3
|
+
import { readTrimmed } from "./accounts.js";
|
|
4
|
+
const IPV4_MAX_CIDR_PREFIX = 32;
|
|
5
|
+
const IPV6_MAX_CIDR_PREFIX = 128;
|
|
6
|
+
function positiveIntOrDefault(value, fallback) {
|
|
7
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
8
|
+
return Math.floor(value);
|
|
9
|
+
}
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
function normalizeIpCandidate(raw) {
|
|
13
|
+
let value = raw.trim();
|
|
14
|
+
if (!value) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
if (value.includes(",")) {
|
|
18
|
+
value = value.split(",")[0]?.trim() ?? "";
|
|
19
|
+
}
|
|
20
|
+
if (value.startsWith("[")) {
|
|
21
|
+
const endIndex = value.indexOf("]");
|
|
22
|
+
if (endIndex > 0) {
|
|
23
|
+
value = value.slice(1, endIndex);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const zoneIndex = value.indexOf("%");
|
|
27
|
+
if (zoneIndex > 0) {
|
|
28
|
+
value = value.slice(0, zoneIndex);
|
|
29
|
+
}
|
|
30
|
+
if (value.startsWith("::ffff:")) {
|
|
31
|
+
const mapped = value.slice("::ffff:".length);
|
|
32
|
+
if (isIP(mapped) === 4) {
|
|
33
|
+
value = mapped;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (isIP(value) === 0) {
|
|
37
|
+
const maybeWithPort = value.split(":");
|
|
38
|
+
if (maybeWithPort.length === 2 &&
|
|
39
|
+
/^\d+$/.test(maybeWithPort[1] ?? "") &&
|
|
40
|
+
isIP(maybeWithPort[0] ?? "") === 4) {
|
|
41
|
+
value = maybeWithPort[0] ?? "";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return isIP(value) === 0 ? "" : value;
|
|
45
|
+
}
|
|
46
|
+
function getHeaderValue(headers, key) {
|
|
47
|
+
const raw = headers[key];
|
|
48
|
+
if (typeof raw === "string") {
|
|
49
|
+
return raw.trim();
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(raw)) {
|
|
52
|
+
return raw[0]?.trim() ?? "";
|
|
53
|
+
}
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
function normalizeHost(value) {
|
|
57
|
+
let host = value.trim().toLowerCase();
|
|
58
|
+
if (!host) {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
if (host.includes(",")) {
|
|
62
|
+
host = host.split(",")[0]?.trim() ?? "";
|
|
63
|
+
}
|
|
64
|
+
if (!host) {
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
if (host.startsWith("[")) {
|
|
68
|
+
const endBracket = host.indexOf("]");
|
|
69
|
+
if (endBracket <= 0) {
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
return host.slice(1, endBracket);
|
|
73
|
+
}
|
|
74
|
+
if (host.includes("@")) {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
const maybeWithoutPort = host.split(":");
|
|
78
|
+
if (maybeWithoutPort.length === 2 &&
|
|
79
|
+
/^\d+$/.test(maybeWithoutPort[1] ?? "")) {
|
|
80
|
+
host = maybeWithoutPort[0] ?? "";
|
|
81
|
+
}
|
|
82
|
+
return host.trim();
|
|
83
|
+
}
|
|
84
|
+
function parseProxyRules(entries) {
|
|
85
|
+
const rules = [];
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const raw = entry.trim();
|
|
88
|
+
if (!raw) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (raw.includes("/")) {
|
|
92
|
+
const [network, prefixRaw] = raw.split("/");
|
|
93
|
+
const normalizedNetwork = normalizeIpCandidate(network ?? "");
|
|
94
|
+
const ipVersion = isIP(normalizedNetwork);
|
|
95
|
+
if (!ipVersion) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const prefix = Number(prefixRaw);
|
|
99
|
+
const maxPrefix = ipVersion === 4 ? IPV4_MAX_CIDR_PREFIX : IPV6_MAX_CIDR_PREFIX;
|
|
100
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
rules.push({ kind: "cidr", value: `${normalizedNetwork}/${prefix}` });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const normalizedIp = normalizeIpCandidate(raw);
|
|
107
|
+
if (normalizedIp) {
|
|
108
|
+
rules.push({ kind: "ip", value: normalizedIp });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return rules;
|
|
112
|
+
}
|
|
113
|
+
function createTrustedProxyMatcher(entries) {
|
|
114
|
+
const rules = parseProxyRules(entries);
|
|
115
|
+
if (rules.length === 0) {
|
|
116
|
+
return () => false;
|
|
117
|
+
}
|
|
118
|
+
const blockList = new BlockList();
|
|
119
|
+
for (const rule of rules) {
|
|
120
|
+
if (rule.kind === "ip") {
|
|
121
|
+
const version = isIP(rule.value);
|
|
122
|
+
if (version === 4) {
|
|
123
|
+
blockList.addAddress(rule.value, "ipv4");
|
|
124
|
+
}
|
|
125
|
+
else if (version === 6) {
|
|
126
|
+
blockList.addAddress(rule.value, "ipv6");
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const [network, prefixRaw] = rule.value.split("/");
|
|
131
|
+
const prefix = Number(prefixRaw);
|
|
132
|
+
const version = isIP(network);
|
|
133
|
+
if (version === 4) {
|
|
134
|
+
blockList.addSubnet(network, prefix, "ipv4");
|
|
135
|
+
}
|
|
136
|
+
else if (version === 6) {
|
|
137
|
+
blockList.addSubnet(network, prefix, "ipv6");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return (ip) => {
|
|
141
|
+
const normalized = normalizeIpCandidate(ip);
|
|
142
|
+
if (!normalized) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (isIP(normalized) === 4) {
|
|
146
|
+
return blockList.check(normalized, "ipv4");
|
|
147
|
+
}
|
|
148
|
+
return blockList.check(normalized, "ipv6");
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function extractCallbackToken(callbackUrl) {
|
|
152
|
+
const raw = callbackUrl?.trim() ?? "";
|
|
153
|
+
if (!raw) {
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const parsed = new URL(raw, "http://localhost");
|
|
158
|
+
return parsed.searchParams.get("k")?.trim() ?? "";
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
throw new Error(`Invalid callbackUrl: unable to parse "${raw}"`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export function resolveGroupMeSecurity(accountConfig) {
|
|
165
|
+
const security = (accountConfig.security ?? {});
|
|
166
|
+
const callbackToken = extractCallbackToken(accountConfig.callbackUrl);
|
|
167
|
+
const groupId = readTrimmed(accountConfig.groupId) ?? "";
|
|
168
|
+
const allowedMimePrefixes = Array.isArray(security.media?.allowedMimePrefixes)
|
|
169
|
+
? security.media.allowedMimePrefixes
|
|
170
|
+
.map((prefix) => readTrimmed(prefix))
|
|
171
|
+
.filter((entry) => Boolean(entry))
|
|
172
|
+
: ["image/"];
|
|
173
|
+
const trustedProxyCidrs = Array.isArray(security.proxy?.trustedProxyCidrs)
|
|
174
|
+
? security.proxy.trustedProxyCidrs
|
|
175
|
+
.map((entry) => readTrimmed(entry))
|
|
176
|
+
.filter((entry) => Boolean(entry))
|
|
177
|
+
: [];
|
|
178
|
+
const allowedPublicHosts = Array.isArray(security.proxy?.allowedPublicHosts)
|
|
179
|
+
? security.proxy.allowedPublicHosts
|
|
180
|
+
.map((entry) => normalizeHost(readTrimmed(entry) ?? ""))
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
: [];
|
|
183
|
+
return {
|
|
184
|
+
callbackToken,
|
|
185
|
+
callbackRejectStatus: 404,
|
|
186
|
+
groupId,
|
|
187
|
+
replay: {
|
|
188
|
+
enabled: true,
|
|
189
|
+
ttlSeconds: positiveIntOrDefault(security.replay?.ttlSeconds, 600),
|
|
190
|
+
maxEntries: positiveIntOrDefault(security.replay?.maxEntries, 10_000),
|
|
191
|
+
},
|
|
192
|
+
rateLimit: {
|
|
193
|
+
enabled: true,
|
|
194
|
+
windowMs: positiveIntOrDefault(security.rateLimit?.windowMs, 60_000),
|
|
195
|
+
maxRequestsPerIp: positiveIntOrDefault(security.rateLimit?.maxRequestsPerIp, 120),
|
|
196
|
+
maxRequestsPerSender: positiveIntOrDefault(security.rateLimit?.maxRequestsPerSender, 60),
|
|
197
|
+
maxConcurrent: positiveIntOrDefault(security.rateLimit?.maxConcurrent, 8),
|
|
198
|
+
},
|
|
199
|
+
media: {
|
|
200
|
+
allowPrivateNetworks: security.media?.allowPrivateNetworks === true,
|
|
201
|
+
maxDownloadBytes: positiveIntOrDefault(security.media?.maxDownloadBytes, 15 * 1024 * 1024),
|
|
202
|
+
requestTimeoutMs: positiveIntOrDefault(security.media?.requestTimeoutMs, 10_000),
|
|
203
|
+
allowedMimePrefixes: allowedMimePrefixes.length > 0 ? allowedMimePrefixes : ["image/"],
|
|
204
|
+
},
|
|
205
|
+
logging: {
|
|
206
|
+
redactSecrets: security.logging?.redactSecrets !== false,
|
|
207
|
+
logRejectedRequests: security.logging?.logRejectedRequests !== false,
|
|
208
|
+
},
|
|
209
|
+
commandBypass: {
|
|
210
|
+
requireAllowFrom: security.commandBypass?.requireAllowFrom !== false,
|
|
211
|
+
requireMentionForCommands: security.commandBypass?.requireMentionForCommands === true,
|
|
212
|
+
},
|
|
213
|
+
proxy: {
|
|
214
|
+
enabled: security.proxy != null,
|
|
215
|
+
trustedProxyCidrs,
|
|
216
|
+
allowedPublicHosts,
|
|
217
|
+
requireHttpsProto: security.proxy?.requireHttpsProto === true,
|
|
218
|
+
rejectStatus: security.proxy?.rejectStatus ?? 403,
|
|
219
|
+
isTrustedProxy: createTrustedProxyMatcher(trustedProxyCidrs),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function safeEqualToken(left, right) {
|
|
224
|
+
const leftHash = createHash("sha256").update(left).digest();
|
|
225
|
+
const rightHash = createHash("sha256").update(right).digest();
|
|
226
|
+
return timingSafeEqual(leftHash, rightHash);
|
|
227
|
+
}
|
|
228
|
+
export function verifyCallbackAuth(params) {
|
|
229
|
+
const expectedToken = params.security.callbackToken;
|
|
230
|
+
if (!expectedToken) {
|
|
231
|
+
return { ok: false, reason: "disabled" };
|
|
232
|
+
}
|
|
233
|
+
const inboundToken = params.url.searchParams.get("k")?.trim() ?? "";
|
|
234
|
+
if (!inboundToken) {
|
|
235
|
+
return { ok: false, reason: "missing" };
|
|
236
|
+
}
|
|
237
|
+
if (safeEqualToken(inboundToken, expectedToken)) {
|
|
238
|
+
return { ok: true, tokenId: "active" };
|
|
239
|
+
}
|
|
240
|
+
return { ok: false, reason: "mismatch" };
|
|
241
|
+
}
|
|
242
|
+
export function checkGroupBinding(params) {
|
|
243
|
+
if (params.groupId !== params.inboundGroupId) {
|
|
244
|
+
return { ok: false, reason: "mismatch" };
|
|
245
|
+
}
|
|
246
|
+
return { ok: true };
|
|
247
|
+
}
|
|
248
|
+
export function redactCallbackUrl(raw, security) {
|
|
249
|
+
if (!security.callbackToken) {
|
|
250
|
+
return raw;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const parsed = new URL(raw, "http://localhost");
|
|
254
|
+
if (parsed.searchParams.has("k")) {
|
|
255
|
+
parsed.searchParams.set("k", "[redacted]");
|
|
256
|
+
}
|
|
257
|
+
const serialized = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
258
|
+
return (serialized || raw).replaceAll("%5Bredacted%5D", "[redacted]");
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return raw.replaceAll(security.callbackToken, "[redacted]");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
export function validateProxyRequest(params) {
|
|
265
|
+
const remoteIp = normalizeIpCandidate(params.remoteAddress) || "unknown";
|
|
266
|
+
const proxyConfig = params.security.proxy;
|
|
267
|
+
const defaultProto = params.socketEncrypted
|
|
268
|
+
? "https"
|
|
269
|
+
: "http";
|
|
270
|
+
const hostHeader = normalizeHost(getHeaderValue(params.headers, "host"));
|
|
271
|
+
if (!proxyConfig.enabled) {
|
|
272
|
+
return {
|
|
273
|
+
ok: true,
|
|
274
|
+
context: {
|
|
275
|
+
remoteIp,
|
|
276
|
+
clientIp: remoteIp,
|
|
277
|
+
host: hostHeader,
|
|
278
|
+
proto: defaultProto,
|
|
279
|
+
fromTrustedProxy: false,
|
|
280
|
+
usingForwardedHeaders: false,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const fromTrustedProxy = proxyConfig.trustedProxyCidrs.length > 0 &&
|
|
285
|
+
proxyConfig.isTrustedProxy(remoteIp);
|
|
286
|
+
const forwardedFor = normalizeIpCandidate(getHeaderValue(params.headers, "x-forwarded-for"));
|
|
287
|
+
const forwardedHost = normalizeHost(getHeaderValue(params.headers, "x-forwarded-host"));
|
|
288
|
+
const forwardedProtoRaw = getHeaderValue(params.headers, "x-forwarded-proto")
|
|
289
|
+
.split(",")[0]
|
|
290
|
+
?.trim()
|
|
291
|
+
.toLowerCase();
|
|
292
|
+
const forwardedProto = forwardedProtoRaw === "http" || forwardedProtoRaw === "https"
|
|
293
|
+
? forwardedProtoRaw
|
|
294
|
+
: null;
|
|
295
|
+
const usingForwardedHeaders = fromTrustedProxy && Boolean(forwardedFor || forwardedHost || forwardedProto);
|
|
296
|
+
const effectiveClientIp = usingForwardedHeaders && forwardedFor ? forwardedFor : remoteIp;
|
|
297
|
+
const effectiveHost = usingForwardedHeaders && forwardedHost ? forwardedHost : hostHeader;
|
|
298
|
+
const effectiveProto = usingForwardedHeaders && forwardedProto ? forwardedProto : defaultProto;
|
|
299
|
+
if (!effectiveHost) {
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
reason: "missing_host",
|
|
303
|
+
status: proxyConfig.rejectStatus,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (proxyConfig.allowedPublicHosts.length > 0 &&
|
|
307
|
+
!proxyConfig.allowedPublicHosts.includes(effectiveHost)) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
reason: "host_not_allowed",
|
|
311
|
+
status: proxyConfig.rejectStatus,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (proxyConfig.requireHttpsProto && effectiveProto !== "https") {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
reason: "proto_not_https",
|
|
318
|
+
status: proxyConfig.rejectStatus,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
context: {
|
|
324
|
+
remoteIp,
|
|
325
|
+
clientIp: effectiveClientIp || "unknown",
|
|
326
|
+
host: effectiveHost,
|
|
327
|
+
proto: effectiveProto,
|
|
328
|
+
fromTrustedProxy,
|
|
329
|
+
usingForwardedHeaders,
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|