Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/(app)/apps/email/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { getCurrentUser } from "@/lib/auth"
import { encryptSecret, decryptSecret } from "@/lib/encryption"
import { testImapConnection } from "@/lib/email-sync/imap-client"
import { validateImapTarget } from "@/lib/email-sync/imap-validation"
import { runEmailSync } from "@/lib/email-sync/ingest"
import { getAppData, setAppData } from "@/models/apps"
import { randomUUID } from "crypto"
Expand All @@ -25,6 +26,8 @@ export async function addEmailServerAction(
const appData = (await getAppData(user, "email")) as EmailAppData | null
const currentData = appData || getDefaultAppData()

await validateImapTarget(serverData.host, serverData.port)

const newServer: EmailServer = {
...serverData,
id: randomUUID(),
Expand Down Expand Up @@ -61,6 +64,16 @@ export async function updateEmailServerAction(
return { success: false, error: "No email servers found" }
}

if (serverData.host !== undefined && serverData.port !== undefined) {
await validateImapTarget(serverData.host, serverData.port)
} else if (serverData.host !== undefined || serverData.port !== undefined) {
const existingServer = appData.servers.find((server) => server.id === serverId)
if (!existingServer) {
return { success: false, error: "Server not found" }
}
await validateImapTarget(serverData.host ?? existingServer.host, serverData.port ?? existingServer.port)
}

const patch = { ...serverData }
if (typeof patch.password === "string" && patch.password.length > 0) {
patch.password = encryptSecret(patch.password)
Expand Down
26 changes: 22 additions & 4 deletions lib/email-sync/imap-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import imaps from "imap-simple"
import { simpleParser } from "mailparser"
import { validateImapTarget } from "./imap-validation"
import { ImapClient, ImapConnectConfig, ImapMessage, ImapSearchCriteria } from "./types"

function buildImapConfig(config: ImapConnectConfig) {
Expand All @@ -10,16 +11,32 @@ function buildImapConfig(config: ImapConnectConfig) {
host: config.host,
port: config.port,
tls: config.tls,
authTimeout: 10000,
connTimeout: 10000,
authTimeout: 5000,
connTimeout: 5000,
tlsOptions: { servername: config.host },
},
}
}

async function connectWithImap(config: ImapConnectConfig) {
const maxAttempts = 1
let lastError: unknown

for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await imaps.connect(buildImapConfig(config))
} catch (error) {
lastError = error
}
}

throw lastError instanceof Error ? lastError : new Error("IMAP connection failed")
}

export const realImapClient: ImapClient = {
async fetchMessages(config: ImapConnectConfig, criteria: ImapSearchCriteria[]): Promise<ImapMessage[]> {
const connection = await imaps.connect(buildImapConfig(config))
await validateImapTarget(config.host, config.port)
const connection = await connectWithImap(config)

try {
await connection.openBox("INBOX")
Expand Down Expand Up @@ -58,7 +75,8 @@ export const realImapClient: ImapClient = {
}

export async function testImapConnection(config: ImapConnectConfig): Promise<void> {
const connection = await imaps.connect(buildImapConfig(config))
await validateImapTarget(config.host, config.port)
const connection = await connectWithImap(config)
try {
await connection.openBox("INBOX")
} finally {
Expand Down
29 changes: 29 additions & 0 deletions lib/email-sync/imap-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest"
import { validateImapTarget } from "./imap-validation"

describe("validateImapTarget", () => {
it("rejects localhost names", async () => {
await expect(validateImapTarget("localhost", 993)).rejects.toThrow(/localhost/i)
})

it("rejects disallowed ports", async () => {
await expect(validateImapTarget("imap.example.com", 587)).rejects.toThrow(/port/i)
})

it("rejects private IP addresses", async () => {
await expect(validateImapTarget("192.168.1.10", 993)).rejects.toThrow(/private/i)
})

it("rejects direct IP literals by default", async () => {
await expect(validateImapTarget("8.8.8.8", 993)).rejects.toThrow(/direct ip/i)
})

it("accepts public hostnames that resolve to public IPs", async () => {
const result = await validateImapTarget("imap.example.com", 993, {
resolveHost: async () => ["1.1.1.1"],
})

expect(result.host).toBe("imap.example.com")
expect(result.resolvedIps).toEqual(["1.1.1.1"])
})
})
117 changes: 117 additions & 0 deletions lib/email-sync/imap-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import dns from "node:dns"
import { isIP } from "node:net"

// Security notes:
// - Only allow IMAP on the standard public ports 143/993.
// - Reject localhost and local/private targets before any connection is attempted.
// - Resolve hostnames and verify every resolved IP to reduce DNS rebinding risk.
// - Log rejected hosts so suspicious attempts can be monitored.
const ALLOWED_PORTS = new Set([143, 993])

function isIpLiteral(value: string): boolean {
return isIP(value) > 0
}

function isPrivateIpv4(address: string): boolean {
const parts = address.split(".").map((part) => Number.parseInt(part, 10))
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
return false
}

const [a, b] = parts
if (a === 10) return true
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 192 && b === 168) return true
return false
}

function isPrivateIpv6(address: string): boolean {
const normalized = address.toLowerCase()
if (normalized === "::1") return true
if (normalized.startsWith("fe80:")) return true
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true
return false
}

function isReservedOrLocal(address: string): boolean {
if (address === "127.0.0.1" || address === "::1") return true
if (address.startsWith("127.")) return true
if (address.startsWith("169.254.")) return true
return false
}

function isPrivateAddress(address: string): boolean {
return isPrivateIpv4(address) || isPrivateIpv6(address) || isReservedOrLocal(address)
}

function isIgnoredHostname(hostname: string): boolean {
return hostname === "localhost" || hostname === "::1"
}

function rejectWithLog(host: string, port: number, reason: string): never {
console.warn("[imap-validation] Rejected IMAP connection attempt", { host, port, reason })
throw new Error(reason)
}

export type ImapTargetValidationOptions = {
resolveHost?: (host: string) => Promise<string[]>
rejectDirectIp?: boolean
}

export type ImapTargetValidationResult = {
host: string
port: number
resolvedIps: string[]
}

export async function validateImapTarget(
host: string,
port: number,
options: ImapTargetValidationOptions = {}
): Promise<ImapTargetValidationResult> {
const rejectDirectIp = options.rejectDirectIp ?? true
const hostname = host.trim()

if (!hostname) {
rejectWithLog(hostname, port, "IMAP host is required")
}

if (!ALLOWED_PORTS.has(port)) {
rejectWithLog(hostname, port, `IMAP port must be one of: ${Array.from(ALLOWED_PORTS).join(", ")}`)
}

if (hostname === "localhost" || hostname === "::1" || hostname === "127.0.0.1") {
rejectWithLog(hostname, port, "IMAP host must not be localhost")
}

if (isIpLiteral(hostname)) {
// Check for private address BEFORE rejecting direct IPs
if (isPrivateAddress(hostname)) {
rejectWithLog(hostname, port, "IMAP host resolves to a private or local address")
}

if (rejectDirectIp) {
rejectWithLog(hostname, port, "Direct IP hostnames are not allowed")
}

return { host: hostname, port, resolvedIps: [hostname] }
}

if (isIgnoredHostname(hostname)) {
rejectWithLog(hostname, port, "IMAP host must not be localhost")
}

const resolveHost = options.resolveHost ?? ((value: string) => dns.promises.lookup(value, { all: true }).then((entries) => entries.map((entry) => entry.address)))
const resolvedIps = await resolveHost(hostname)

if (!resolvedIps.length) {
rejectWithLog(hostname, port, "IMAP host did not resolve to any IP addresses")
}

const publicIps = resolvedIps.filter((ip) => !isPrivateAddress(ip))
if (!publicIps.length) {
rejectWithLog(hostname, port, "IMAP host resolves to a private or local address")
}

return { host: hostname, port, resolvedIps }
}
Loading