@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.
@@ -1,74 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
3
- import { isPrivateIPv6 } from "@open-mercato/shared/lib/network";
4
- const FORBIDDEN_HOST_NAMES = /* @__PURE__ */ new Set([
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 { isPrivateIPv6 } from '@open-mercato/shared/lib/network'\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 * The check is string-based: it rejects literal internal IPs, `localhost`, and\n * the obfuscated encodings that exist to evade such filters (IPv4-mapped IPv6,\n * NAT64 / 6to4 / IPv4-compatible IPv6 embedding an internal IPv4,\n * decimal/hex/octal/short-form IPv4, bracketed and expanded IPv6). It does NOT\n * by itself catch a public hostname that resolves \u2014 or is DNS-rebound \u2014 to a\n * private address; that gap is closed at connect time by `resolveSafeHostAddress`\n * (`host-pinning.ts`), which resolves the host, rejects any internal resolved\n * address, and pins the connection to the validated IP. Operators with a\n * genuinely private IMAP host set `OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true`.\n */\nconst FORBIDDEN_HOST_NAMES = new Set([\n 'localhost',\n 'localhost6',\n 'ip6-localhost',\n 'ip6-loopback',\n 'metadata.google.internal',\n])\n\nconst PRIVATE_IPV4_PATTERNS: RegExp[] = [\n /^(127|10)\\./, // 127/8 loopback, 10/8 private\n /^172\\.(1[6-9]|2[0-9]|3[01])\\./, // 172.16/12 private\n /^192\\.168\\./, // 192.168/16 private\n /^169\\.254\\./, // link-local + cloud metadata (169.254.169.254)\n /^100\\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\\./, // CGNAT 100.64/10\n /^0\\./, // 0.0.0.0/8 reserved\n]\n\nconst PRIVATE_IPV6_PATTERNS: RegExp[] = [\n /^::$/, // unspecified\n /^::1$/, // loopback\n /^::ffff:/, // IPv4-mapped (hex-group form; dotted form is unwrapped first)\n /^(fc|fd)[0-9a-f]{0,2}:/, // unique-local fc00::/7\n /^fe80:/, // link-local\n /^(0{1,4}:){7}0{0,3}1$/, // fully-expanded loopback\n /^(0{1,4}:){7}0{1,4}$/, // fully-expanded unspecified\n]\n\nfunction isDottedDecimalQuad(host: string): boolean {\n const parts = host.split('.')\n return parts.length === 4 && parts.every((part) => /^\\d{1,3}$/.test(part) && Number(part) <= 255)\n}\n\n/**\n * True when `host` is an obfuscated IPv4 encoding \u2014 decimal integer\n * (`2130706433`), hex (`0x7f.0.0.1`), octal (`0177.0.0.1`) or short form\n * (`127.1`). These forms exist almost exclusively to bypass SSRF string filters,\n * so we reject them outright; legitimate operators use a hostname or a standard\n * dotted-decimal quad.\n */\nfunction isObfuscatedIpv4(host: string): boolean {\n if (host.includes(':')) return false\n if (/^\\d+$/.test(host)) return true\n if (/(^|\\.)0x[0-9a-f]+/.test(host)) return true\n const labels = host.split('.')\n if (!labels.every((label) => /^[0-9a-f]+$/.test(label))) return false\n if (isDottedDecimalQuad(host) && !labels.some((label) => label.length > 1 && label.startsWith('0'))) return false\n return true\n}\n\nfunction normalizeHost(raw: string): string {\n let host = raw.trim().toLowerCase()\n if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)\n const mappedIpv4 = host.match(/^::ffff:(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})$/)\n if (mappedIpv4) return mappedIpv4[1]\n return host\n}\n\n/**\n * Classify a host as internal/loopback/metadata, ignoring the operator escape\n * hatch. Exported so the SSRF guard can be asserted directly in unit tests.\n */\nexport function isInternalHost(rawHost: string): boolean {\n const host = normalizeHost(rawHost)\n if (!host) return false\n if (FORBIDDEN_HOST_NAMES.has(host) || host.endsWith('.localhost')) return true\n if (host.includes(':')) {\n const address = host.replace(/%.*$/, '')\n return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(address)) || isPrivateIPv6(address)\n }\n if (isObfuscatedIpv4(host)) return true\n // Only treat the private-range patterns as internal for a real dotted-decimal\n // quad. Otherwise a hostname whose first label merely looks like a private\n // range (e.g. `0.mx.example.com`, `10.example.com`) is wrongly rejected.\n // Obfuscated/short IPv4 forms were already caught above, so anything reaching\n // here is either a quad or a genuine hostname.\n return isDottedDecimalQuad(host) && PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(host))\n}\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": "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",
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 { lookup as dnsLookup } from "node:dns/promises";
2
- import { isIP } from "node:net";
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
- const trimmed = host.trim();
12
- if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) {
13
- return { host: trimmed };
14
- }
15
- if (isIP(stripBrackets(trimmed)) !== 0) {
16
- if (isInternalHost(trimmed)) throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`);
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 { lookup as dnsLookup } from 'node:dns/promises'\nimport { isIP } from 'node:net'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { isInternalHost } from './credentials'\n\nexport type HostLookup = (hostname: string) => Promise<Array<{ address: string; family: number }>>\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\nfunction stripBrackets(host: string): string {\n return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host\n}\n\n/**\n * Resolve `host` to an IP and assert every resolved address is public, then pin\n * the connection to that IP. Closes the DNS-rebinding gap the string-only SSRF\n * guard (`isInternalHost`) leaves open: a public hostname that resolves \u2014 or is\n * rebound between validation and connect \u2014 to an internal address is rejected\n * here, and the returned IP is what the caller actually connects to (no second\n * lookup the attacker could race).\n *\n * - Literal IPs (already SSRF-checked by the credential schema) are returned\n * unchanged with no `servername`.\n * - Hostnames are resolved to every A/AAAA record; if ANY resolved address is\n * internal the call throws. The validated IP is returned as `host` and the\n * original hostname as `servername`, so TLS SNI + certificate hostname\n * verification still target the real host even though we dial the IP.\n * - Honors `OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS`: when set, resolution is\n * skipped and the host is used verbatim (operators with a genuinely internal\n * mail server).\n */\nexport async function resolveSafeHostAddress(\n host: string,\n options: { lookup?: HostLookup } = {},\n): Promise<{ host: string; servername?: string }> {\n const trimmed = host.trim()\n if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) {\n return { host: trimmed }\n }\n if (isIP(stripBrackets(trimmed)) !== 0) {\n if (isInternalHost(trimmed)) throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`)\n return { host: trimmed }\n }\n const resolve =\n options.lookup ?? ((hostname: string) => dnsLookup(hostname, { all: true, verbatim: true }))\n const records = await resolve(trimmed)\n if (!Array.isArray(records) || records.length === 0) {\n throw new Error(`[internal] ${UNRESOLVABLE_MESSAGE}`)\n }\n for (const record of records) {\n if (isInternalHost(record.address)) {\n throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`)\n }\n }\n return { host: records[0].address, servername: trimmed }\n}\n"],
5
- "mappings": "AAAA,SAAS,UAAU,iBAAiB;AACpC,SAAS,YAAY;AACrB,SAAS,+BAA+B;AACxC,SAAS,sBAAsB;AAI/B,MAAM,4BACJ;AAEF,MAAM,uBAAuB;AAE7B,SAAS,cAAc,MAAsB;AAC3C,SAAO,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,IAAI,KAAK,MAAM,GAAG,EAAE,IAAI;AAC1E;AAoBA,eAAsB,uBACpB,MACA,UAAmC,CAAC,GACY;AAChD,QAAM,UAAU,KAAK,KAAK;AAC1B,MAAI,wBAAwB,QAAQ,IAAI,sCAAsC,KAAK,GAAG;AACpF,WAAO,EAAE,MAAM,QAAQ;AAAA,EACzB;AACA,MAAI,KAAK,cAAc,OAAO,CAAC,MAAM,GAAG;AACtC,QAAI,eAAe,OAAO,EAAG,OAAM,IAAI,MAAM,cAAc,yBAAyB,EAAE;AACtF,WAAO,EAAE,MAAM,QAAQ;AAAA,EACzB;AACA,QAAM,UACJ,QAAQ,WAAW,CAAC,aAAqB,UAAU,UAAU,EAAE,KAAK,MAAM,UAAU,KAAK,CAAC;AAC5F,QAAM,UAAU,MAAM,QAAQ,OAAO;AACrC,MAAI,CAAC,MAAM,QAAQ,OAAO,KAAK,QAAQ,WAAW,GAAG;AACnD,UAAM,IAAI,MAAM,cAAc,oBAAoB,EAAE;AAAA,EACtD;AACA,aAAW,UAAU,SAAS;AAC5B,QAAI,eAAe,OAAO,OAAO,GAAG;AAClC,YAAM,IAAI,MAAM,cAAc,yBAAyB,EAAE;AAAA,IAC3D;AAAA,EACF;AACA,SAAO,EAAE,MAAM,QAAQ,CAAC,EAAE,SAAS,YAAY,QAAQ;AACzD;",
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.7748.1.d745ba61aa",
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.7748.1.d745ba61aa",
66
- "@open-mercato/queue": "0.10.1-develop.7748.1.d745ba61aa",
67
- "@open-mercato/ui": "0.10.1-develop.7748.1.d745ba61aa",
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.7748.1.d745ba61aa",
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.7748.1.d745ba61aa",
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
- '203.0.113.10',
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 { isPrivateIPv6 } from '@open-mercato/shared/lib/network'
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
- * The check is string-based: it rejects literal internal IPs, `localhost`, and
13
- * the obfuscated encodings that exist to evade such filters (IPv4-mapped IPv6,
14
- * NAT64 / 6to4 / IPv4-compatible IPv6 embedding an internal IPv4,
15
- * decimal/hex/octal/short-form IPv4, bracketed and expanded IPv6). It does NOT
16
- * by itself catch a public hostname that resolves — or is DNS-rebound — to a
17
- * private address; that gap is closed at connect time by `resolveSafeHostAddress`
18
- * (`host-pinning.ts`), which resolves the host, rejects any internal resolved
19
- * address, and pins the connection to the validated IP. Operators with a
20
- * genuinely private IMAP host set `OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true`.
21
- */
22
- const FORBIDDEN_HOST_NAMES = new Set([
23
- 'localhost',
24
- 'localhost6',
25
- 'ip6-localhost',
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 function isInternalHost(rawHost: string): boolean {
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 { lookup as dnsLookup } from 'node:dns/promises'
2
- import { isIP } from 'node:net'
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 HostLookup = (hostname: string) => Promise<Array<{ address: string; family: number }>>
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 `host` to an IP and assert every resolved address is public, then pin
19
- * the connection to that IP. Closes the DNS-rebinding gap the string-only SSRF
20
- * guard (`isInternalHost`) leaves open: a public hostname that resolves — or is
21
- * rebound between validation and connect — to an internal address is rejected
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
- const trimmed = host.trim()
40
- if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) {
41
- return { host: trimmed }
42
- }
43
- if (isIP(stripBrackets(trimmed)) !== 0) {
44
- if (isInternalHost(trimmed)) throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`)
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
  }