Skip to content

feat(generic_imap): add a generic IMAP/SMTP mailbox provider - #242

Merged
l1shen merged 5 commits into
oomol-lab:mainfrom
cabestian:pr/generic-imap-pub
Aug 1, 2026
Merged

feat(generic_imap): add a generic IMAP/SMTP mailbox provider#242
l1shen merged 5 commits into
oomol-lab:mainfrom
cabestian:pr/generic-imap-pub

Conversation

@cabestian

@cabestian cabestian commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

Adds a generic_imap provider so that any mailbox exposing IMAP over implicit TLS (port 993) can be connected with an application password, regardless of the mail host.

Open Connector currently ships provider-specific mail integrations (qq_mail, netease_mail) whose IMAP/SMTP hosts are hardcoded. A mailbox on any other host (OVH, Fastmail, self-hosted Dovecot, corporate mail, ...) cannot be connected at all today.

How

  • The provider is built on the shared MailRuntimeConfig factory (src/mail/imap-smtp/), so it exposes the same 12 actions as the existing mail providers (send_email, list_folders, search_emails, get_email, download_attachment, mark_email_read, mark_email_unread, move_email, delete_email, get_folder_status, reply_email, forward_email). No new mail plumbing.
  • Credential fields: email, password (application password, secret), imapHost (required), smtpHost (optional, defaults to the IMAP host with the imap. prefix replaced by smtp., port 465).
  • Marked nodeOnly like the other mail providers: IMAP/SMTP sockets are not reliable from Cloudflare Workers, and the description says so. The generated Cloudflare registry excludes it.
  • testAction is list_folders, so the connection test authenticates against the user-supplied host before the credential is saved.

SSRF hardening

This is the first mail provider whose hosts come from user input, so the hosts are guarded in two places: when the credential is read, and again when the socket is opened.

At credential time

  • Each host must be a bare dotted hostname (no scheme, userinfo, port, or path), so nothing can smuggle a different target past the URL parser. IP literals still match this syntax gate on purpose: they are then classified by the shared guard.
  • The host is then passed to the shared assertPublicHttpUrl (src/core/request.ts), so the provider inherits the existing cloud-metadata, loopback, link-local, reserved-range, and IPv6 blocklists rather than re-implementing them.
  • Private targets (RFC 1918, CGNAT 100.64.0.0/10, private-suffix hostnames) stay blocked unless the deployment opts in through OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK, mirroring the existing Dokploy behaviour. Loopback and metadata targets stay blocked even then. The flag is read per call via isPrivateNetworkAccessAllowed(), so it always reflects the current bootstrap configuration.
  • docs/configuration.md is updated to list the IMAP Mailbox host next to Dokploy as an opt-in consumer of the private-network flag.

At connect time (DNS rebinding)

A hostname check alone is not sufficient: the DNS record can point at an internal address by the time the socket is opened, and IMAP/SMTP connect over raw TLS, so the guarded fetch never sees these hosts. pinMailHost in src/mail/imap-smtp/protocol.ts closes that window:

  • the host is resolved (dns.lookup with all: true), and every returned address is screened with the shared isBlockedIpAddress. A host answering with one public and one private address is rejected outright, since the connection library is free to pick either one;
  • the connection is then made to the resolved literal address, with the hostname carried separately as servername. Both imapflow and nodemailer skip their own lookup when the host is already an IP (net.isIP), so no second, unchecked resolution can happen, while SNI and certificate verification still run against the name the user typed;
  • an IP-literal host skips resolution but is still screened;
  • OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK is honoured here too, and loopback/metadata stay blocked even when it is set.

This is gated behind a new enforceHostNetworkPolicy flag on MailRuntimeConfig, set only by generic_imap. Providers with hardcoded hosts (qq_mail, netease_mail) keep their current behaviour exactly: no resolution, no extra DNS round-trip.

Tests

src/providers/generic_imap/network-access.test.ts (9 cases) covers the credential-time policy:

  • private/reserved IP literals rejected by default (incl. 169.254.169.254, 100.100.100.200, 0.0.0.0);
  • public mailbox hosts accepted;
  • loopback/link-local/metadata still blocked when OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK=true, while RFC 1918/CGNAT/private hostnames become allowed;
  • non-bare-hostname inputs rejected (URL, host:port, userinfo, path, trailing dot, IPv6 literal, whitespace);
  • the guard applied to the SMTP host too, including the derived one;
  • host normalization (lowercasing) and the derived SMTP host following the normalized value;
  • the offending field named in the rejection message.

src/mail/imap-smtp/host-pinning.test.ts (9 cases) covers the connect-time policy:

  • a hostname resolving to loopback or to the cloud-metadata address is rejected, and no client is constructed;
  • a host answering with one public and one private address is rejected as a whole;
  • the resolved address is pinned as host while the hostname is preserved as servername, for IMAP and for SMTP;
  • providers without the flag are untouched: the hostname is passed through and no resolution happens;
  • a private address is accepted when the deployment opted in, while loopback stays blocked in that same mode;
  • an IP-literal host is screened without being resolved.

How to test

npm install
npm run typecheck   # regenerates the provider registries, then typechecks
npm test            # full suite
npx vitest run src/providers/generic_imap/network-access.test.ts src/mail/imap-smtp/host-pinning.test.ts

For a live check, connect an "IMAP Mailbox" credential from the catalog UI with any IMAP-enabled mailbox and an application password; the list_folders test action runs on save. Trying 127.0.0.1 or 169.254.169.254 as the IMAP host is rejected with a 400 naming the field, and a hostname that resolves to either is rejected when the connection is attempted.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Summary by CodeRabbit

  • New Features

    • Added support for connecting to generic IMAP mailboxes with custom credentials.
    • Added configurable IMAP and SMTP hosts, SMTP port selection, and automatic SMTP host defaults.
    • Added application-password guidance, credential validation, mailbox testing, and mail actions.
    • Added protected private-network access controls, blocked-host protection, DNS validation, and clearer connection errors for mail server connections.
  • Documentation

    • Clarified that Dokploy and IMAP Mailbox hosts can target private networks when enabled.

Walkthrough

This change adds a Node-only generic IMAP provider with custom credentials, shared mail actions, runtime executors, and credential validators. It validates and normalizes IMAP and SMTP settings, derives SMTP hosts, and applies deployment-controlled network policies. Mail connections resolve and pin permitted addresses while preserving the original TLS hostname. SMTP validation uses the configured port and enforces STARTTLS on non-465 ports. Tests cover provider validation and mailbox host enforcement. Documentation lists IMAP Mailbox for private-network access.

Sequence Diagram(s)

sequenceDiagram
  participant Provider
  participant genericImapRuntimeConfig
  participant NetworkPolicy
  participant MailProtocol
  participant IMAPOrSMTPTransport
  Provider->>genericImapRuntimeConfig: validate and normalize credentials
  genericImapRuntimeConfig->>NetworkPolicy: check IMAP and SMTP hosts
  NetworkPolicy-->>genericImapRuntimeConfig: allow or reject hosts
  Provider->>MailProtocol: create protocol with host policy
  MailProtocol->>NetworkPolicy: resolve and screen mailbox addresses
  NetworkPolicy-->>MailProtocol: return safe address or reject
  MailProtocol->>IMAPOrSMTPTransport: connect using pinned address and original TLS hostname
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title follows the required <type>(<scope>): <subject> format and accurately describes the generic IMAP/SMTP provider change.
Description check ✅ Passed The description clearly explains the provider, configuration, SSRF protections, DNS pinning, tests, and usage.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/providers/generic_imap/config.ts`:
- Around line 58-82: Re-validate credential.imapHost and credential.smtpHost at
connection time in the IMAP/SMTP protocol flow before passing them to ImapFlow
or nodemailer.createTransport. Resolve each hostname and apply the shared SSRF
policy to every resolved IPv4/IPv6 address, including loopback, link-local,
metadata, and private-network ranges, while preserving the existing
allowPrivateNetwork behavior; do not rely solely on assertMailHost’s save-time
validation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 982839bd-350e-4c1b-99ce-f5c074158803

📥 Commits

Reviewing files that changed from the base of the PR and between b31e166 and 1c0943f.

📒 Files selected for processing (6)
  • docs/configuration.md
  • src/providers/generic_imap/actions.ts
  • src/providers/generic_imap/config.ts
  • src/providers/generic_imap/definition.ts
  • src/providers/generic_imap/executors.ts
  • src/providers/generic_imap/network-access.test.ts

Comment thread src/providers/generic_imap/config.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/mail/imap-smtp/protocol.ts (1)

536-559: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Nest SMTP servername under tls.

Nodemailer passes SNI through transportOptions.tls.servername, but src/mail/imap-smtp/protocol.ts sets servername as a top-level SMTPTransport.Options property. With IP-based SMTP target pinning, certificate validation can run against the IP instead of the original host. Nest the value under tls and update the object-shape assertion in src/mail/imap-smtp/host-pinning.test.ts to check config.tls.servername.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/imap-smtp/protocol.ts` around lines 536 - 559, Update
createSmtpTransport so target.servername is placed under the transportConfig.tls
object rather than as a top-level property, preserving the original hostname for
TLS SNI when the pinned target is an IP; update the corresponding assertion in
host-pinning tests to validate config.tls.servername.
🧹 Nitpick comments (1)
src/mail/imap-smtp/protocol.ts (1)

493-523: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test covers the "could not be resolved" branch.

pinMailHost throws MailProtocolError("network", ...) when addresses.length is 0. None of the tests in host-pinning.test.ts exercise this branch (all mocked resolveHostAddresses implementations return at least one address). Add a test with resolveHostAddresses: async () => [] to confirm the error and message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/imap-smtp/protocol.ts` around lines 493 - 523, Update the tests for
pinMailHost in host-pinning.test.ts to cover an empty resolution result by
providing resolveHostAddresses: async () => []. Assert that the call throws a
MailProtocolError with network classification and the message stating that the
configured field and host could not be resolved.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/mail/imap-smtp/protocol.ts`:
- Around line 536-559: Update createSmtpTransport so target.servername is placed
under the transportConfig.tls object rather than as a top-level property,
preserving the original hostname for TLS SNI when the pinned target is an IP;
update the corresponding assertion in host-pinning tests to validate
config.tls.servername.

---

Nitpick comments:
In `@src/mail/imap-smtp/protocol.ts`:
- Around line 493-523: Update the tests for pinMailHost in host-pinning.test.ts
to cover an empty resolution result by providing resolveHostAddresses: async ()
=> []. Assert that the call throws a MailProtocolError with network
classification and the message stating that the configured field and host could
not be resolved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3004c2cc-e1c3-4134-bca8-1fcda0b8106c

📥 Commits

Reviewing files that changed from the base of the PR and between 1c0943f and f2b0466.

📒 Files selected for processing (4)
  • src/mail/imap-smtp/host-pinning.test.ts
  • src/mail/imap-smtp/protocol.ts
  • src/mail/imap-smtp/runtime.ts
  • src/providers/generic_imap/config.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/providers/generic_imap/config.ts

@BlackHole1
BlackHole1 requested review from BlackHole1 and l1shen July 31, 2026 15:17

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/providers/generic_imap/config.ts`:
- Around line 134-135: Update isBareHostname to accept valid IPv6 literals
alongside existing DNS hostnames, using IP-literal validation rather than
broadening the hostname regex. When constructing the synthetic URL passed to the
shared SSRF guard, wrap IPv6 literals in brackets while preserving existing
hostname and IPv4 handling so blocked IPv6 ranges remain screened.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 90898237-8158-4fc5-a328-04aa15a96c8b

📥 Commits

Reviewing files that changed from the base of the PR and between f2b0466 and b60d66b.

📒 Files selected for processing (7)
  • src/mail/imap-smtp/errors.ts
  • src/mail/imap-smtp/host-pinning.test.ts
  • src/mail/imap-smtp/protocol.ts
  • src/mail/imap-smtp/runtime.ts
  • src/providers/generic_imap/config.ts
  • src/providers/generic_imap/definition.ts
  • src/providers/generic_imap/network-access.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/providers/generic_imap/definition.ts
  • src/mail/imap-smtp/runtime.ts
  • src/mail/imap-smtp/protocol.ts

Comment thread src/providers/generic_imap/config.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/mail/imap-smtp/host-pinning.test.ts (1)

45-49: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a named interface for the test-case contract.

addressFamilyCases uses an inline object-shaped contract. Define AddressFamilyCase as an interface and use AddressFamilyCase[].

As per coding guidelines, prefer interface for object-shaped contracts.

Proposed refactor
-const addressFamilyCases: Array<{
+interface AddressFamilyCase {
   deployment: string;
   interfaces: Record<string, NetworkInterfaceInfo[]>;
   pinned: string;
-}> = [
+}
+
+const addressFamilyCases: AddressFamilyCase[] = [
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/imap-smtp/host-pinning.test.ts` around lines 45 - 49, Define a named
AddressFamilyCase interface for the test-case object contract, then update
addressFamilyCases to use AddressFamilyCase[] instead of its inline object type.
Keep the existing deployment, interfaces, and pinned fields unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/mail/imap-smtp/host-pinning.test.ts`:
- Around line 45-49: Define a named AddressFamilyCase interface for the
test-case object contract, then update addressFamilyCases to use
AddressFamilyCase[] instead of its inline object type. Keep the existing
deployment, interfaces, and pinned fields unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ea86547f-0f52-4457-a7fb-155569be1368

📥 Commits

Reviewing files that changed from the base of the PR and between b60d66b and 7b534f7.

📒 Files selected for processing (2)
  • src/mail/imap-smtp/host-pinning.test.ts
  • src/mail/imap-smtp/protocol.ts

@BlackHole1

Copy link
Copy Markdown
Member

PTAL @l1shen

cabestian and others added 5 commits August 1, 2026 20:46
OpenConnector ships provider-specific mail integrations (qq_mail,
netease_mail) whose hosts are hardcoded, but no generic IMAP provider,
so a mailbox on any other host cannot be connected at all.

Add a `generic_imap` provider built on the shared `MailRuntimeConfig`
factory, taking the IMAP/SMTP hosts as user-supplied credential fields.
It is the first mail provider whose host comes from user input, so the
host is validated against the shared SSRF policy (`assertPublicHttpUrl`
gated by `isPrivateNetworkAccessAllowed`) rather than a bespoke hostname
check, per AGENTS.md. IMAP/SMTP use implicit-TLS sockets rather than
providerFetch, so the credential path is the only place the policy can
be applied.

Marked `nodeOnly` like the other mail providers: IMAP/SMTP sockets are
not reliable from Cloudflare Workers.
The credential-time hostname check cannot stand alone: the DNS record may
point at an internal address by the time the socket is opened, and IMAP and
SMTP connect over raw TLS, so the guarded fetch never sees these hosts.

Resolve the host and screen every returned address with the shared
isBlockedIpAddress guard, then connect to the resolved literal address and
carry the hostname separately as servername. Both imapflow and nodemailer
skip their own lookup when the host is already an IP, so no second,
unchecked resolution can happen, while SNI and certificate verification
still run against the name the user typed. A host answering with one public
and one private address is rejected outright, since the connection library
is free to pick either one.

Gated behind enforceHostNetworkPolicy, set only by generic_imap: providers
with hardcoded hosts keep their exact current behaviour.
…table

Review follow-ups on the generic_imap provider and the connect-time host
pinning it introduces.

Connect-time pinning:

- Pin an address the deployment can actually route instead of `addresses[0]`.
  Pinning takes address selection away from the transport, and both transports
  did more than take the first answer: nodemailer resolves A before AAAA, skips
  families with no external interface, and retries the rest on a connect
  failure, while imapflow leaves the choice to Node's Happy Eyeballs. A lone
  AAAA answer on an IPv4-only deployment therefore turned a working mailbox
  into ENETUNREACH. The screened list is now filtered to routable families and
  prefers IPv4, matching nodemailer's own ordering.
- Stop echoing the rejected address back to the caller. `IMAP host x resolves
  to a blocked address (10.42.7.19)` made the connector an internal
  name-to-address oracle; the shared egress guard withholds the address for the
  same reason.
- Report a refused host as 400 through a new `blocked_host` error kind, the
  same status the credential-time guard already returns for a literal address,
  instead of 502.

Provider:

- Add an optional SMTP port. iCloud and Outlook.com offer submission on 587
  only, and since the connection test verifies SMTP as well as IMAP, 465 being
  hardcoded made those mailboxes unconnectable rather than merely unsendable.
  Ports other than 465 negotiate STARTTLS with `requireTLS`, so the password is
  never sent to a server that does not upgrade.
- Name the endpoint in connect-phase transport failures, so a bare "Connection
  timeout" says which host and port refused the connection.
- Derive the SMTP host from a leading `imap` label rather than an `imap.`
  prefix, so `imap-mail.outlook.com` pairs with `smtp-mail.outlook.com`.
- Accept single-label hosts: a Compose service name or a MagicDNS short name is
  exactly the mailbox a private-network deployment connects. The shared guard
  still owns the decision and still blocks `localhost`.
- Add `homepageUrl`, the only provider definition of 1211 without one, so the
  console renders an icon.

Tests:

- Source the guarded protocol config from `genericImapRuntimeConfig`, so
  dropping the provider's opt-in fails the suite instead of silently disarming
  the guard.
- Cover dual-stack pinning, address non-disclosure, STARTTLS selection, the
  host derivation table, single-label hosts, and SMTP port parsing.
…anch

Follow-ups from automated review of the previous commit.

- Inject the local interface table through `MailProtocolDependencies`, like
  every other environment dependency in this module. `hasInterfaceForFamily`
  read `os.networkInterfaces()` directly, so it had no test at all: replacing
  its body with `return true` or `return false` left the whole suite green, and
  the one test that exercised the preference only passed because the machine
  running it happens to have an IPv4 interface. It now covers a dual-stack
  deployment, an IPv4-only container, an IPv6-only host, and a loopback-only
  container, and both mutations fail.
- Cover the empty-resolution branch of `pinMailHost`. An answer with no
  addresses must fail closed: the screening loop would otherwise pass vacuously
  and pin an undefined host.
- Record why `servername` sits at the top level of both client configs rather
  than under `tls`. Both libraries read it there (imap-flow.js:282,
  smtp-connection/index.js:84) and fall back to the host only when it is
  absent, so nesting is optional but dropping it makes a pinned literal address
  be checked against the certificate as an IP. Verified end to end against real
  local TLS servers on the implicit-TLS and STARTTLS paths.
@l1shen
l1shen force-pushed the pr/generic-imap-pub branch from 7b534f7 to 945a1ae Compare August 1, 2026 12:56
@l1shen
l1shen merged commit e1e1e07 into oomol-lab:main Aug 1, 2026
2 of 3 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/guarded-fetch.ts`:
- Around line 365-370: Update the screening loop around isBlockedIpAddress to
discard entries whose address is not a valid IP literal, reusing the already
imported isIpAddress helper. Ensure GuardedEgressTarget.addresses returns only
entries with string addresses that pass IP validation and private/reserved
address screening, while preserving the existing error for blocked IPs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 21b8d9f7-b23b-40eb-941b-20a4b104e55c

📥 Commits

Reviewing files that changed from the base of the PR and between 7b534f7 and 945a1ae.

📒 Files selected for processing (12)
  • docs/configuration.md
  • src/core/guarded-fetch.test.ts
  • src/core/guarded-fetch.ts
  • src/mail/imap-smtp/errors.ts
  • src/mail/imap-smtp/host-pinning.test.ts
  • src/mail/imap-smtp/protocol.ts
  • src/mail/imap-smtp/runtime.ts
  • src/providers/generic_imap/actions.ts
  • src/providers/generic_imap/config.ts
  • src/providers/generic_imap/definition.ts
  • src/providers/generic_imap/executors.ts
  • src/providers/generic_imap/network-access.test.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • src/mail/imap-smtp/errors.ts
  • docs/configuration.md
  • src/providers/generic_imap/executors.ts
  • src/mail/imap-smtp/host-pinning.test.ts
  • src/providers/generic_imap/config.ts
  • src/providers/generic_imap/actions.ts
  • src/providers/generic_imap/network-access.test.ts
  • src/providers/generic_imap/definition.ts
  • src/mail/imap-smtp/runtime.ts

Comment thread src/core/guarded-fetch.ts
Comment on lines 365 to +370
for (const entry of results) {
if (entry && typeof entry.address === "string" && isBlockedIpAddress(entry.address, policy.allowPrivateNetwork)) {
throw policy.createError(`${fieldName} must not resolve to private or reserved IP addresses`);
}
}
return results;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Return only verified IP entries from the screening loop.

The loop only screens entries whose address is a string. Any other entry passes through unchecked, and the whole results array is now returned to callers. pinMailHost in src/mail/imap-smtp/protocol.ts feeds these entries into createPinnedLookup, which hands them to node:net and to imapflow. A custom lookup (deps.lookup, or a workerd DoH lookup that maps CNAME answers into { address: "target.example.com.", family: 4 }) can therefore produce a "pinned" entry that is a hostname. The transport then resolves that name itself, which defeats the pin and the address screening.

The module default lookup already filters non-IP answers. Apply the same filter inside the shared screening so every consumer of GuardedEgressTarget.addresses receives only validated IP literals.

🛡️ Proposed fix to screen and filter non-address entries
-  for (const entry of results) {
-    if (entry && typeof entry.address === "string" && isBlockedIpAddress(entry.address, policy.allowPrivateNetwork)) {
-      throw policy.createError(`${fieldName} must not resolve to private or reserved IP addresses`);
-    }
-  }
-  return results;
+  const screened: ResolvedAddress[] = [];
+  for (const entry of results) {
+    if (!entry || typeof entry.address !== "string" || !isIpAddress(entry.address)) {
+      continue;
+    }
+    if (isBlockedIpAddress(entry.address, policy.allowPrivateNetwork)) {
+      throw policy.createError(`${fieldName} must not resolve to private or reserved IP addresses`);
+    }
+    screened.push(entry);
+  }
+  if (screened.length === 0) {
+    throw policy.createResolutionError(`${fieldName} could not be resolved for validation`);
+  }
+  return screened;

isIpAddress is already imported in this module.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const entry of results) {
if (entry && typeof entry.address === "string" && isBlockedIpAddress(entry.address, policy.allowPrivateNetwork)) {
throw policy.createError(`${fieldName} must not resolve to private or reserved IP addresses`);
}
}
return results;
const screened: ResolvedAddress[] = [];
for (const entry of results) {
if (!entry || typeof entry.address !== "string" || !isIpAddress(entry.address)) {
continue;
}
if (isBlockedIpAddress(entry.address, policy.allowPrivateNetwork)) {
throw policy.createError(`${fieldName} must not resolve to private or reserved IP addresses`);
}
screened.push(entry);
}
if (screened.length === 0) {
throw policy.createResolutionError(`${fieldName} could not be resolved for validation`);
}
return screened;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/guarded-fetch.ts` around lines 365 - 370, Update the screening loop
around isBlockedIpAddress to discard entries whose address is not a valid IP
literal, reusing the already imported isIpAddress helper. Ensure
GuardedEgressTarget.addresses returns only entries with string addresses that
pass IP validation and private/reserved address screening, while preserving the
existing error for blocked IPs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants