@open-mercato/channel-imap 0.10.1-develop.7748.1.d745ba61aa → 0.10.1-develop.7749.1.ea3edf75e3
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/modules/channel_imap/lib/credentials.js +3 -70
- package/dist/modules/channel_imap/lib/credentials.js.map +3 -3
- package/dist/modules/channel_imap/lib/host-pinning.js +9 -25
- package/dist/modules/channel_imap/lib/host-pinning.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/channel_imap/lib/__tests__/credentials.test.ts +2 -1
- package/src/modules/channel_imap/lib/credentials.ts +16 -87
- package/src/modules/channel_imap/lib/host-pinning.ts +15 -44
|
@@ -1,74 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
"localhost",
|
|
6
|
-
"localhost6",
|
|
7
|
-
"ip6-localhost",
|
|
8
|
-
"ip6-loopback",
|
|
9
|
-
"metadata.google.internal"
|
|
10
|
-
]);
|
|
11
|
-
const PRIVATE_IPV4_PATTERNS = [
|
|
12
|
-
/^(127|10)\./,
|
|
13
|
-
// 127/8 loopback, 10/8 private
|
|
14
|
-
/^172\.(1[6-9]|2[0-9]|3[01])\./,
|
|
15
|
-
// 172.16/12 private
|
|
16
|
-
/^192\.168\./,
|
|
17
|
-
// 192.168/16 private
|
|
18
|
-
/^169\.254\./,
|
|
19
|
-
// link-local + cloud metadata (169.254.169.254)
|
|
20
|
-
/^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./,
|
|
21
|
-
// CGNAT 100.64/10
|
|
22
|
-
/^0\./
|
|
23
|
-
// 0.0.0.0/8 reserved
|
|
24
|
-
];
|
|
25
|
-
const PRIVATE_IPV6_PATTERNS = [
|
|
26
|
-
/^::$/,
|
|
27
|
-
// unspecified
|
|
28
|
-
/^::1$/,
|
|
29
|
-
// loopback
|
|
30
|
-
/^::ffff:/,
|
|
31
|
-
// IPv4-mapped (hex-group form; dotted form is unwrapped first)
|
|
32
|
-
/^(fc|fd)[0-9a-f]{0,2}:/,
|
|
33
|
-
// unique-local fc00::/7
|
|
34
|
-
/^fe80:/,
|
|
35
|
-
// link-local
|
|
36
|
-
/^(0{1,4}:){7}0{0,3}1$/,
|
|
37
|
-
// fully-expanded loopback
|
|
38
|
-
/^(0{1,4}:){7}0{1,4}$/
|
|
39
|
-
// fully-expanded unspecified
|
|
40
|
-
];
|
|
41
|
-
function isDottedDecimalQuad(host) {
|
|
42
|
-
const parts = host.split(".");
|
|
43
|
-
return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
44
|
-
}
|
|
45
|
-
function isObfuscatedIpv4(host) {
|
|
46
|
-
if (host.includes(":")) return false;
|
|
47
|
-
if (/^\d+$/.test(host)) return true;
|
|
48
|
-
if (/(^|\.)0x[0-9a-f]+/.test(host)) return true;
|
|
49
|
-
const labels = host.split(".");
|
|
50
|
-
if (!labels.every((label) => /^[0-9a-f]+$/.test(label))) return false;
|
|
51
|
-
if (isDottedDecimalQuad(host) && !labels.some((label) => label.length > 1 && label.startsWith("0"))) return false;
|
|
52
|
-
return true;
|
|
53
|
-
}
|
|
54
|
-
function normalizeHost(raw) {
|
|
55
|
-
let host = raw.trim().toLowerCase();
|
|
56
|
-
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
57
|
-
const mappedIpv4 = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
58
|
-
if (mappedIpv4) return mappedIpv4[1];
|
|
59
|
-
return host;
|
|
60
|
-
}
|
|
61
|
-
function isInternalHost(rawHost) {
|
|
62
|
-
const host = normalizeHost(rawHost);
|
|
63
|
-
if (!host) return false;
|
|
64
|
-
if (FORBIDDEN_HOST_NAMES.has(host) || host.endsWith(".localhost")) return true;
|
|
65
|
-
if (host.includes(":")) {
|
|
66
|
-
const address = host.replace(/%.*$/, "");
|
|
67
|
-
return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(address)) || isPrivateIPv6(address);
|
|
68
|
-
}
|
|
69
|
-
if (isObfuscatedIpv4(host)) return true;
|
|
70
|
-
return isDottedDecimalQuad(host) && PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(host));
|
|
71
|
-
}
|
|
3
|
+
import { isInternalHost } from "@open-mercato/shared/lib/host-pinning";
|
|
4
|
+
import { isInternalHost as isInternalHost2 } from "@open-mercato/shared/lib/host-pinning";
|
|
72
5
|
function assertSafeHost(host, ctx) {
|
|
73
6
|
if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) return;
|
|
74
7
|
if (!host.trim()) return;
|
|
@@ -103,6 +36,6 @@ const imapChannelStateSchema = z.object({
|
|
|
103
36
|
export {
|
|
104
37
|
imapChannelStateSchema,
|
|
105
38
|
imapCredentialsSchema,
|
|
106
|
-
isInternalHost
|
|
39
|
+
isInternalHost2 as isInternalHost
|
|
107
40
|
};
|
|
108
41
|
//# sourceMappingURL=credentials.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_imap/lib/credentials.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport {
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAClB,SAAS,+BAA+B;AACxC,SAAS,
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { isInternalHost } from '@open-mercato/shared/lib/host-pinning'\n\n/**\n * SSRF guard: reject hostnames that resolve to internal networks. Operators\n * configure their own IMAP/SMTP server, so the host string is attacker-controlled\n * in a per-user-channel context. Blocking these prevents the credential-validation\n * flow from acting as a port scanner or leaking the platform's outbound IP to\n * internal infrastructure (cloud metadata endpoints, kube-apiserver, RDS, etc).\n *\n * Re-exported from `@open-mercato/shared/lib/host-pinning` rather than\n * implemented here: the same guard protects every provider that dials an\n * operator-supplied host:port (IMAP/SMTP here, the SMTP relay in\n * `@open-mercato/channel-smtp`), and an SSRF classifier that drifts between two\n * copies is worse than no shared copy at all. The import path is unchanged.\n *\n * The check is string-based and does NOT by itself catch a public hostname that\n * resolves \u2014 or is DNS-rebound \u2014 to a private address; that gap is closed at\n * connect time by `resolveSafeHostAddress` (`host-pinning.ts`), which resolves\n * the host, rejects any internal resolved address, and pins the connection to the\n * validated IP. The shared classifier also rejects the obfuscated encodings that\n * exist to evade such filters (IPv4-mapped / NAT64 / 6to4 / IPv4-compatible IPv6\n * embedding an internal IPv4, decimal/hex/octal/short-form IPv4). Operators\n * with a genuinely private IMAP host set `OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true`.\n */\nexport { isInternalHost } from '@open-mercato/shared/lib/host-pinning'\n\nfunction assertSafeHost(host: string, ctx: { addIssue: (issue: { code: 'custom'; message: string }) => void }): void {\n if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) return\n if (!host.trim()) return\n if (isInternalHost(host)) {\n ctx.addIssue({\n code: 'custom',\n message:\n 'Host appears to point at a private or loopback address. If this is intentional, an operator must set OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true.',\n })\n }\n}\n\nfunction hostnameSchema(label: 'IMAP' | 'SMTP') {\n return z\n .string()\n .min(1, `${label} host required`)\n .max(253, `${label} host too long`)\n .superRefine((value, ctx) => assertSafeHost(value, ctx))\n}\n\n/**\n * Per-user IMAP+SMTP credentials. Validated whenever a user connects a new\n * channel (`POST /api/communication_channels/channels/connect/credentials`) and\n * before every outbound send / inbound poll.\n *\n * The hub persists this blob inside `IntegrationCredentials.credentials` (encrypted\n * at rest). Do not log credential values; the adapter logs `<redacted>` for any\n * password-shaped key.\n */\nexport const imapCredentialsSchema = z\n .object({\n imapHost: hostnameSchema('IMAP'),\n imapPort: z.coerce\n .number()\n .int()\n .min(1, 'IMAP port must be a positive integer')\n .max(65535, 'IMAP port must be <= 65535'),\n imapTls: z.enum(['tls', 'starttls', 'none']),\n imapUser: z.string().min(1, 'IMAP username required'),\n imapPassword: z.string().min(1, 'IMAP password required'),\n\n smtpHost: hostnameSchema('SMTP'),\n smtpPort: z.coerce\n .number()\n .int()\n .min(1, 'SMTP port must be a positive integer')\n .max(65535, 'SMTP port must be <= 65535'),\n smtpTls: z.enum(['tls', 'starttls', 'none']),\n smtpUser: z.string().min(1, 'SMTP username required'),\n smtpPassword: z.string().min(1, 'SMTP password required'),\n\n fromAddress: z.string().email('From address must be a valid email'),\n })\n // `.passthrough()` (not `.strict()`) so the connect-credential-channel command\n // can stash bookkeeping fields like `userId` alongside the user-entered\n // credentials. Strict was rejecting any extra key with \"Unrecognized key\" and\n // blocking outbound SMTP after a real user connected via the per-user flow.\n .passthrough()\n\nexport type ImapCredentials = z.infer<typeof imapCredentialsSchema>\n\n/**\n * Internal poll-state stored on `CommunicationChannel.channelState` so we can\n * resume polling without re-scanning the entire mailbox each tick.\n *\n * uidValidity \u2014 IMAP UIDVALIDITY for INBOX; if it changes we must full-resync.\n * uidNext \u2014 UIDNEXT for INBOX; subsequent polls fetch `<previous uidNext>:*`.\n */\nexport const imapChannelStateSchema = z\n .object({\n uidValidity: z.union([z.number(), z.string()]).optional(),\n uidNext: z.union([z.number(), z.string()]).optional(),\n lastFolder: z.string().optional(),\n })\n .partial()\n .passthrough()\n\nexport type ImapChannelState = z.infer<typeof imapChannelStateSchema>\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AAClB,SAAS,+BAA+B;AACxC,SAAS,sBAAsB;AAwB/B,SAAS,kBAAAA,uBAAsB;AAE/B,SAAS,eAAe,MAAc,KAA+E;AACnH,MAAI,wBAAwB,QAAQ,IAAI,sCAAsC,KAAK,EAAG;AACtF,MAAI,CAAC,KAAK,KAAK,EAAG;AAClB,MAAI,eAAe,IAAI,GAAG;AACxB,QAAI,SAAS;AAAA,MACX,MAAM;AAAA,MACN,SACE;AAAA,IACJ,CAAC;AAAA,EACH;AACF;AAEA,SAAS,eAAe,OAAwB;AAC9C,SAAO,EACJ,OAAO,EACP,IAAI,GAAG,GAAG,KAAK,gBAAgB,EAC/B,IAAI,KAAK,GAAG,KAAK,gBAAgB,EACjC,YAAY,CAAC,OAAO,QAAQ,eAAe,OAAO,GAAG,CAAC;AAC3D;AAWO,MAAM,wBAAwB,EAClC,OAAO;AAAA,EACN,UAAU,eAAe,MAAM;AAAA,EAC/B,UAAU,EAAE,OACT,OAAO,EACP,IAAI,EACJ,IAAI,GAAG,sCAAsC,EAC7C,IAAI,OAAO,4BAA4B;AAAA,EAC1C,SAAS,EAAE,KAAK,CAAC,OAAO,YAAY,MAAM,CAAC;AAAA,EAC3C,UAAU,EAAE,OAAO,EAAE,IAAI,GAAG,wBAAwB;AAAA,EACpD,cAAc,EAAE,OAAO,EAAE,IAAI,GAAG,wBAAwB;AAAA,EAExD,UAAU,eAAe,MAAM;AAAA,EAC/B,UAAU,EAAE,OACT,OAAO,EACP,IAAI,EACJ,IAAI,GAAG,sCAAsC,EAC7C,IAAI,OAAO,4BAA4B;AAAA,EAC1C,SAAS,EAAE,KAAK,CAAC,OAAO,YAAY,MAAM,CAAC;AAAA,EAC3C,UAAU,EAAE,OAAO,EAAE,IAAI,GAAG,wBAAwB;AAAA,EACpD,cAAc,EAAE,OAAO,EAAE,IAAI,GAAG,wBAAwB;AAAA,EAExD,aAAa,EAAE,OAAO,EAAE,MAAM,oCAAoC;AACpE,CAAC,EAKA,YAAY;AAWR,MAAM,yBAAyB,EACnC,OAAO;AAAA,EACN,aAAa,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS;AAAA,EACxD,SAAS,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS;AAAA,EACpD,YAAY,EAAE,OAAO,EAAE,SAAS;AAClC,CAAC,EACA,QAAQ,EACR,YAAY;",
|
|
6
|
+
"names": ["isInternalHost"]
|
|
7
7
|
}
|
|
@@ -1,32 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
resolveSafeHostAddress as resolveSafeHostAddressShared
|
|
3
|
+
} from "@open-mercato/shared/lib/host-pinning";
|
|
3
4
|
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
4
|
-
import { isInternalHost } from "./credentials.js";
|
|
5
5
|
const INTERNAL_RESOLVED_MESSAGE = "Host resolves to a private or loopback address. If this is intentional, an operator must set OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true.";
|
|
6
6
|
const UNRESOLVABLE_MESSAGE = "Host did not resolve to any address.";
|
|
7
|
-
function stripBrackets(host) {
|
|
8
|
-
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
9
|
-
}
|
|
10
7
|
async function resolveSafeHostAddress(host, options = {}) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return { host: trimmed };
|
|
18
|
-
}
|
|
19
|
-
const resolve = options.lookup ?? ((hostname) => dnsLookup(hostname, { all: true, verbatim: true }));
|
|
20
|
-
const records = await resolve(trimmed);
|
|
21
|
-
if (!Array.isArray(records) || records.length === 0) {
|
|
22
|
-
throw new Error(`[internal] ${UNRESOLVABLE_MESSAGE}`);
|
|
23
|
-
}
|
|
24
|
-
for (const record of records) {
|
|
25
|
-
if (isInternalHost(record.address)) {
|
|
26
|
-
throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
return { host: records[0].address, servername: trimmed };
|
|
8
|
+
return resolveSafeHostAddressShared(host, {
|
|
9
|
+
...options,
|
|
10
|
+
allowInternal: parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false),
|
|
11
|
+
internalMessage: INTERNAL_RESOLVED_MESSAGE,
|
|
12
|
+
unresolvableMessage: UNRESOLVABLE_MESSAGE
|
|
13
|
+
});
|
|
30
14
|
}
|
|
31
15
|
export {
|
|
32
16
|
resolveSafeHostAddress
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_imap/lib/host-pinning.ts"],
|
|
4
|
-
"sourcesContent": ["import {
|
|
5
|
-
"mappings": "AAAA,
|
|
4
|
+
"sourcesContent": ["import {\n resolveSafeHostAddress as resolveSafeHostAddressShared,\n type HostLookup,\n} from '@open-mercato/shared/lib/host-pinning'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\nexport type { HostLookup }\n\nconst INTERNAL_RESOLVED_MESSAGE =\n 'Host resolves to a private or loopback address. If this is intentional, an operator must set OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true.'\n\nconst UNRESOLVABLE_MESSAGE = 'Host did not resolve to any address.'\n\n/**\n * Resolve + pin an IMAP/SMTP host to a validated public IP at connect time.\n * See `@open-mercato/shared/lib/host-pinning` for the guard itself; this wrapper\n * only binds it to this provider's escape-hatch variable\n * (`OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS`) and its operator-facing wording.\n */\nexport async function resolveSafeHostAddress(\n host: string,\n options: { lookup?: HostLookup } = {},\n): Promise<{ host: string; servername?: string }> {\n return resolveSafeHostAddressShared(host, {\n ...options,\n allowInternal: parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false),\n internalMessage: INTERNAL_RESOLVED_MESSAGE,\n unresolvableMessage: UNRESOLVABLE_MESSAGE,\n })\n}\n"],
|
|
5
|
+
"mappings": "AAAA;AAAA,EACE,0BAA0B;AAAA,OAErB;AACP,SAAS,+BAA+B;AAIxC,MAAM,4BACJ;AAEF,MAAM,uBAAuB;AAQ7B,eAAsB,uBACpB,MACA,UAAmC,CAAC,GACY;AAChD,SAAO,6BAA6B,MAAM;AAAA,IACxC,GAAG;AAAA,IACH,eAAe,wBAAwB,QAAQ,IAAI,sCAAsC,KAAK;AAAA,IAC9F,iBAAiB;AAAA,IACjB,qBAAqB;AAAA,EACvB,CAAC;AACH;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/channel-imap",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7749.1.ea3edf75e3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,9 +62,9 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@open-mercato/core": "0.10.1-develop.
|
|
66
|
-
"@open-mercato/queue": "0.10.1-develop.
|
|
67
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
65
|
+
"@open-mercato/core": "0.10.1-develop.7749.1.ea3edf75e3",
|
|
66
|
+
"@open-mercato/queue": "0.10.1-develop.7749.1.ea3edf75e3",
|
|
67
|
+
"@open-mercato/ui": "0.10.1-develop.7749.1.ea3edf75e3",
|
|
68
68
|
"@types/mailparser": "^3.4.5",
|
|
69
69
|
"@types/nodemailer": "^8.0.1",
|
|
70
70
|
"imapflow": "^1.7.6",
|
|
@@ -73,12 +73,12 @@
|
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
76
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
76
|
+
"@open-mercato/shared": "0.10.1-develop.7749.1.ea3edf75e3",
|
|
77
77
|
"react": "^19.0.0",
|
|
78
78
|
"react-dom": "^19.0.0"
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
81
|
+
"@open-mercato/shared": "0.10.1-develop.7749.1.ea3edf75e3",
|
|
82
82
|
"@types/jest": "^30.0.0",
|
|
83
83
|
"@types/react": "^19.2.18",
|
|
84
84
|
"@types/react-dom": "^19.2.5",
|
|
@@ -124,6 +124,7 @@ describe('isInternalHost (SSRF guard)', () => {
|
|
|
124
124
|
'::7f00:1%lo0',
|
|
125
125
|
'::1%lo0',
|
|
126
126
|
'fe80::1%eth0',
|
|
127
|
+
'203.0.113.10',
|
|
127
128
|
]
|
|
128
129
|
|
|
129
130
|
const allowed = [
|
|
@@ -132,7 +133,7 @@ describe('isInternalHost (SSRF guard)', () => {
|
|
|
132
133
|
'mail.proton.me',
|
|
133
134
|
'8.8.8.8',
|
|
134
135
|
'1.1.1.1',
|
|
135
|
-
'
|
|
136
|
+
'93.184.216.34',
|
|
136
137
|
'2001:4860:4860::8888',
|
|
137
138
|
'2606:4700:4700::1111',
|
|
138
139
|
'64:ff9b::808:808',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
3
|
-
import {
|
|
3
|
+
import { isInternalHost } from '@open-mercato/shared/lib/host-pinning'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* SSRF guard: reject hostnames that resolve to internal networks. Operators
|
|
@@ -9,93 +9,22 @@ import { isPrivateIPv6 } from '@open-mercato/shared/lib/network'
|
|
|
9
9
|
* flow from acting as a port scanner or leaking the platform's outbound IP to
|
|
10
10
|
* internal infrastructure (cloud metadata endpoints, kube-apiserver, RDS, etc).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
'ip6-loopback',
|
|
27
|
-
'metadata.google.internal',
|
|
28
|
-
])
|
|
29
|
-
|
|
30
|
-
const PRIVATE_IPV4_PATTERNS: RegExp[] = [
|
|
31
|
-
/^(127|10)\./, // 127/8 loopback, 10/8 private
|
|
32
|
-
/^172\.(1[6-9]|2[0-9]|3[01])\./, // 172.16/12 private
|
|
33
|
-
/^192\.168\./, // 192.168/16 private
|
|
34
|
-
/^169\.254\./, // link-local + cloud metadata (169.254.169.254)
|
|
35
|
-
/^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./, // CGNAT 100.64/10
|
|
36
|
-
/^0\./, // 0.0.0.0/8 reserved
|
|
37
|
-
]
|
|
38
|
-
|
|
39
|
-
const PRIVATE_IPV6_PATTERNS: RegExp[] = [
|
|
40
|
-
/^::$/, // unspecified
|
|
41
|
-
/^::1$/, // loopback
|
|
42
|
-
/^::ffff:/, // IPv4-mapped (hex-group form; dotted form is unwrapped first)
|
|
43
|
-
/^(fc|fd)[0-9a-f]{0,2}:/, // unique-local fc00::/7
|
|
44
|
-
/^fe80:/, // link-local
|
|
45
|
-
/^(0{1,4}:){7}0{0,3}1$/, // fully-expanded loopback
|
|
46
|
-
/^(0{1,4}:){7}0{1,4}$/, // fully-expanded unspecified
|
|
47
|
-
]
|
|
48
|
-
|
|
49
|
-
function isDottedDecimalQuad(host: string): boolean {
|
|
50
|
-
const parts = host.split('.')
|
|
51
|
-
return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* True when `host` is an obfuscated IPv4 encoding — decimal integer
|
|
56
|
-
* (`2130706433`), hex (`0x7f.0.0.1`), octal (`0177.0.0.1`) or short form
|
|
57
|
-
* (`127.1`). These forms exist almost exclusively to bypass SSRF string filters,
|
|
58
|
-
* so we reject them outright; legitimate operators use a hostname or a standard
|
|
59
|
-
* dotted-decimal quad.
|
|
60
|
-
*/
|
|
61
|
-
function isObfuscatedIpv4(host: string): boolean {
|
|
62
|
-
if (host.includes(':')) return false
|
|
63
|
-
if (/^\d+$/.test(host)) return true
|
|
64
|
-
if (/(^|\.)0x[0-9a-f]+/.test(host)) return true
|
|
65
|
-
const labels = host.split('.')
|
|
66
|
-
if (!labels.every((label) => /^[0-9a-f]+$/.test(label))) return false
|
|
67
|
-
if (isDottedDecimalQuad(host) && !labels.some((label) => label.length > 1 && label.startsWith('0'))) return false
|
|
68
|
-
return true
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function normalizeHost(raw: string): string {
|
|
72
|
-
let host = raw.trim().toLowerCase()
|
|
73
|
-
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)
|
|
74
|
-
const mappedIpv4 = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
|
|
75
|
-
if (mappedIpv4) return mappedIpv4[1]
|
|
76
|
-
return host
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Classify a host as internal/loopback/metadata, ignoring the operator escape
|
|
81
|
-
* hatch. Exported so the SSRF guard can be asserted directly in unit tests.
|
|
12
|
+
* Re-exported from `@open-mercato/shared/lib/host-pinning` rather than
|
|
13
|
+
* implemented here: the same guard protects every provider that dials an
|
|
14
|
+
* operator-supplied host:port (IMAP/SMTP here, the SMTP relay in
|
|
15
|
+
* `@open-mercato/channel-smtp`), and an SSRF classifier that drifts between two
|
|
16
|
+
* copies is worse than no shared copy at all. The import path is unchanged.
|
|
17
|
+
*
|
|
18
|
+
* The check is string-based and does NOT by itself catch a public hostname that
|
|
19
|
+
* resolves — or is DNS-rebound — to a private address; that gap is closed at
|
|
20
|
+
* connect time by `resolveSafeHostAddress` (`host-pinning.ts`), which resolves
|
|
21
|
+
* the host, rejects any internal resolved address, and pins the connection to the
|
|
22
|
+
* validated IP. The shared classifier also rejects the obfuscated encodings that
|
|
23
|
+
* exist to evade such filters (IPv4-mapped / NAT64 / 6to4 / IPv4-compatible IPv6
|
|
24
|
+
* embedding an internal IPv4, decimal/hex/octal/short-form IPv4). Operators
|
|
25
|
+
* with a genuinely private IMAP host set `OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true`.
|
|
82
26
|
*/
|
|
83
|
-
export
|
|
84
|
-
const host = normalizeHost(rawHost)
|
|
85
|
-
if (!host) return false
|
|
86
|
-
if (FORBIDDEN_HOST_NAMES.has(host) || host.endsWith('.localhost')) return true
|
|
87
|
-
if (host.includes(':')) {
|
|
88
|
-
const address = host.replace(/%.*$/, '')
|
|
89
|
-
return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(address)) || isPrivateIPv6(address)
|
|
90
|
-
}
|
|
91
|
-
if (isObfuscatedIpv4(host)) return true
|
|
92
|
-
// Only treat the private-range patterns as internal for a real dotted-decimal
|
|
93
|
-
// quad. Otherwise a hostname whose first label merely looks like a private
|
|
94
|
-
// range (e.g. `0.mx.example.com`, `10.example.com`) is wrongly rejected.
|
|
95
|
-
// Obfuscated/short IPv4 forms were already caught above, so anything reaching
|
|
96
|
-
// here is either a quad or a genuine hostname.
|
|
97
|
-
return isDottedDecimalQuad(host) && PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(host))
|
|
98
|
-
}
|
|
27
|
+
export { isInternalHost } from '@open-mercato/shared/lib/host-pinning'
|
|
99
28
|
|
|
100
29
|
function assertSafeHost(host: string, ctx: { addIssue: (issue: { code: 'custom'; message: string }) => void }): void {
|
|
101
30
|
if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) return
|
|
@@ -1,59 +1,30 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
resolveSafeHostAddress as resolveSafeHostAddressShared,
|
|
3
|
+
type HostLookup,
|
|
4
|
+
} from '@open-mercato/shared/lib/host-pinning'
|
|
3
5
|
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
4
|
-
import { isInternalHost } from './credentials'
|
|
5
6
|
|
|
6
|
-
export type
|
|
7
|
+
export type { HostLookup }
|
|
7
8
|
|
|
8
9
|
const INTERNAL_RESOLVED_MESSAGE =
|
|
9
10
|
'Host resolves to a private or loopback address. If this is intentional, an operator must set OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true.'
|
|
10
11
|
|
|
11
12
|
const UNRESOLVABLE_MESSAGE = 'Host did not resolve to any address.'
|
|
12
13
|
|
|
13
|
-
function stripBrackets(host: string): string {
|
|
14
|
-
return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
|
|
15
|
-
}
|
|
16
|
-
|
|
17
14
|
/**
|
|
18
|
-
* Resolve
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* here, and the returned IP is what the caller actually connects to (no second
|
|
23
|
-
* lookup the attacker could race).
|
|
24
|
-
*
|
|
25
|
-
* - Literal IPs (already SSRF-checked by the credential schema) are returned
|
|
26
|
-
* unchanged with no `servername`.
|
|
27
|
-
* - Hostnames are resolved to every A/AAAA record; if ANY resolved address is
|
|
28
|
-
* internal the call throws. The validated IP is returned as `host` and the
|
|
29
|
-
* original hostname as `servername`, so TLS SNI + certificate hostname
|
|
30
|
-
* verification still target the real host even though we dial the IP.
|
|
31
|
-
* - Honors `OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS`: when set, resolution is
|
|
32
|
-
* skipped and the host is used verbatim (operators with a genuinely internal
|
|
33
|
-
* mail server).
|
|
15
|
+
* Resolve + pin an IMAP/SMTP host to a validated public IP at connect time.
|
|
16
|
+
* See `@open-mercato/shared/lib/host-pinning` for the guard itself; this wrapper
|
|
17
|
+
* only binds it to this provider's escape-hatch variable
|
|
18
|
+
* (`OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS`) and its operator-facing wording.
|
|
34
19
|
*/
|
|
35
20
|
export async function resolveSafeHostAddress(
|
|
36
21
|
host: string,
|
|
37
22
|
options: { lookup?: HostLookup } = {},
|
|
38
23
|
): Promise<{ host: string; servername?: string }> {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return { host: trimmed }
|
|
46
|
-
}
|
|
47
|
-
const resolve =
|
|
48
|
-
options.lookup ?? ((hostname: string) => dnsLookup(hostname, { all: true, verbatim: true }))
|
|
49
|
-
const records = await resolve(trimmed)
|
|
50
|
-
if (!Array.isArray(records) || records.length === 0) {
|
|
51
|
-
throw new Error(`[internal] ${UNRESOLVABLE_MESSAGE}`)
|
|
52
|
-
}
|
|
53
|
-
for (const record of records) {
|
|
54
|
-
if (isInternalHost(record.address)) {
|
|
55
|
-
throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`)
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return { host: records[0].address, servername: trimmed }
|
|
24
|
+
return resolveSafeHostAddressShared(host, {
|
|
25
|
+
...options,
|
|
26
|
+
allowInternal: parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false),
|
|
27
|
+
internalMessage: INTERNAL_RESOLVED_MESSAGE,
|
|
28
|
+
unresolvableMessage: UNRESOLVABLE_MESSAGE,
|
|
29
|
+
})
|
|
59
30
|
}
|