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
4 changes: 4 additions & 0 deletions backend/migrations/0013_account_allow_private_host.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Server-level policy flags for self-hosted mail server support
INSERT INTO system_settings (key, value) VALUES ('allow_private_hosts', 'false') ON CONFLICT (key) DO NOTHING;
INSERT INTO system_settings (key, value) VALUES ('allow_insecure_tls', 'false') ON CONFLICT (key) DO NOTHING;
INSERT INTO system_settings (key, value) VALUES ('allow_nonstandard_ports', 'false') ON CONFLICT (key) DO NOTHING;
19 changes: 14 additions & 5 deletions backend/src/routes/accounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { imapManager } from '../index.js';
import { encrypt } from '../services/encryption.js';
import { sanitizeSignature } from '../services/emailSanitizer.js';
import { validateHost } from '../services/hostValidation.js';
import { getConnectionPolicy } from '../services/connectionPolicy.js';

const ALLOWED_IMAP_PORTS = new Set([143, 993]);
const ALLOWED_SMTP_PORTS = new Set([465, 587]);
Expand Down Expand Up @@ -92,12 +93,16 @@ router.post('/', async (req, res) => {
return res.status(400).json({ error: 'Sender name cannot contain control characters' });
}

const policy = await getConnectionPolicy();

if (imap_host) {
const err = (await validateHost(imap_host)) || validatePort(imap_port, ALLOWED_IMAP_PORTS);
const err = (await validateHost(imap_host, { allowPrivate: policy.allowPrivateHosts }))
|| (!policy.allowNonstandardPorts && validatePort(imap_port, ALLOWED_IMAP_PORTS));
if (err) return res.status(400).json({ error: `IMAP: ${err}` });
}
if (smtp_host) {
const err = (await validateHost(smtp_host)) || validatePort(smtp_port, ALLOWED_SMTP_PORTS);
const err = (await validateHost(smtp_host, { allowPrivate: policy.allowPrivateHosts }))
|| (!policy.allowNonstandardPorts && validatePort(smtp_port, ALLOWED_SMTP_PORTS));
if (err) return res.status(400).json({ error: `SMTP: ${err}` });
}

Expand Down Expand Up @@ -145,13 +150,17 @@ router.put('/:id', async (req, res) => {
if ('sender_name' in updates && updates.sender_name && hasHeaderInjectionChars(updates.sender_name)) {
return res.status(400).json({ error: 'Sender name cannot contain control characters' });
}
const policy = await getConnectionPolicy();

if ('smtp_host' in updates && updates.smtp_host) {
const err = await validateHost(updates.smtp_host);
const err = await validateHost(updates.smtp_host, { allowPrivate: policy.allowPrivateHosts });
if (err) return res.status(400).json({ error: `SMTP: ${err}` });
}
if ('smtp_port' in updates && updates.smtp_port !== undefined && updates.smtp_port !== null) {
const err = validatePort(updates.smtp_port, ALLOWED_SMTP_PORTS);
if (err) return res.status(400).json({ error: `SMTP: ${err}` });
if (!policy.allowNonstandardPorts) {
const err = validatePort(updates.smtp_port, ALLOWED_SMTP_PORTS);
if (err) return res.status(400).json({ error: `SMTP: ${err}` });
}
}

const allowed = ['name', 'sender_name', 'color', 'enabled', 'auth_user', 'auth_pass', 'sort_order', 'smtp_host', 'smtp_port', 'folder_mappings', 'imap_skip_tls_verify', 'signature'];
Expand Down
19 changes: 17 additions & 2 deletions backend/src/routes/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ router.get('/auth-events', async (req, res) => {
});

router.patch('/settings', async (req, res) => {
const { registration_open, internal_auth_disabled, auth_max_attempts, auth_window_minutes } = req.body;
const { registration_open, internal_auth_disabled, auth_max_attempts, auth_window_minutes,
allow_private_hosts, allow_insecure_tls, allow_nonstandard_ports } = req.body;
if (typeof registration_open === 'boolean') {
await query(
`INSERT INTO system_settings (key, value, updated_at)
Expand Down Expand Up @@ -159,6 +160,20 @@ router.patch('/settings', async (req, res) => {
if (auth_max_attempts != null || auth_window_minutes != null) {
await reloadAuthSettings();
}
for (const [key, val] of [
['allow_private_hosts', allow_private_hosts],
['allow_insecure_tls', allow_insecure_tls],
['allow_nonstandard_ports', allow_nonstandard_ports],
]) {
if (typeof val === 'boolean') {
await query(
`INSERT INTO system_settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[key, val ? 'true' : 'false']
);
console.log(`[admin] ${req.session.username} set ${key}=${val}`);
}
}
res.json({ ok: true });
});

Expand Down Expand Up @@ -256,7 +271,7 @@ router.post('/invites', async (req, res) => {
} else {
smtpAuth = { user: account.auth_user, pass: decrypt(account.auth_pass) };
}
const acctResolved = await resolveForConnection(account.smtp_host);
const acctResolved = await resolveForConnection(account.smtp_host, { allowPrivate: !!account.allow_private_host });
const acctTls = { rejectUnauthorized: !account.imap_skip_tls_verify };
if (acctResolved.servername) acctTls.servername = acctResolved.servername;
transport = nodemailer.createTransport({
Expand Down
10 changes: 8 additions & 2 deletions backend/src/routes/send.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { decrypt } from '../services/encryption.js';
import sanitizeHtml from 'sanitize-html';
import { redactEmail } from '../utils/redact.js';
import { resolveForConnection } from '../services/hostValidation.js';
import { getConnectionPolicy } from '../services/connectionPolicy.js';
import { imapManager } from '../index.js';

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
Expand Down Expand Up @@ -226,8 +227,13 @@ router.post('/send', async (req, res) => {
smtpAuth = { user: account.auth_user, pass };
}

const smtpResolved = await resolveForConnection(account.smtp_host);
const smtpTls = { rejectUnauthorized: !account.imap_skip_tls_verify };
const policy = await getConnectionPolicy();
const smtpResolved = await resolveForConnection(account.smtp_host, { allowPrivate: policy.allowPrivateHosts });
const smtpPlain = account.smtp_tls !== 'STARTTLS' && account.smtp_tls !== 'SSL';
if (!policy.allowInsecureTls && smtpPlain) {
return res.status(403).json({ error: 'Plain-text SMTP is not allowed: admin must enable "Allow insecure TLS"' });
}
const smtpTls = { rejectUnauthorized: !(policy.allowInsecureTls && account.imap_skip_tls_verify) };
if (smtpResolved.servername) smtpTls.servername = smtpResolved.servername;
const transport = nodemailer.createTransport({
host: smtpResolved.host,
Expand Down
18 changes: 18 additions & 0 deletions backend/src/services/connectionPolicy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { query } from './db.js';

// Reads the three server-level connection policy flags from system_settings.
// Called once per connection attempt — keeps validation and connection code
// free of direct DB coupling.
export async function getConnectionPolicy() {
const result = await query(
`SELECT key, value FROM system_settings
WHERE key IN ('allow_private_hosts', 'allow_insecure_tls', 'allow_nonstandard_ports')`
);
const map = {};
for (const row of result.rows) map[row.key] = row.value === 'true';
return {
allowPrivateHosts: !!map.allow_private_hosts,
allowInsecureTls: !!map.allow_insecure_tls,
allowNonstandardPorts: !!map.allow_nonstandard_ports,
};
}
30 changes: 18 additions & 12 deletions backend/src/services/hostValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ function isPrivateIPv6(ip) {
}

// Synchronous check: literal IPs and reserved hostnames.
export function validateHostLiteral(host) {
export function validateHostLiteral(host, { allowPrivate = false } = {}) {
if (!host || typeof host !== 'string') return null;
if (allowPrivate) return null;
const h = host.trim().toLowerCase();
if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.localhost') || h.endsWith('.internal')) {
return 'Host cannot be a local address';
Expand All @@ -54,8 +55,8 @@ export function validateHostLiteral(host) {

// Async check: resolve A/AAAA records and reject any that are private/reserved.
// Prevents SSRF via controlled hostnames that resolve to internal addresses.
export async function validateHost(host) {
const literalErr = validateHostLiteral(host);
export async function validateHost(host, { allowPrivate = false } = {}) {
const literalErr = validateHostLiteral(host, { allowPrivate });
if (literalErr) return literalErr;

const h = host.trim().toLowerCase();
Expand All @@ -72,9 +73,11 @@ export async function validateHost(host) {
dnsPromises.resolve6(bare).catch(() => []),
]);

for (const addr of [...v4, ...v6]) {
if (isIPv4(addr) && isPrivateIPv4(addr)) return 'Host resolves to a private or reserved IP address';
if (isIPv6(addr) && isPrivateIPv6(addr)) return 'Host resolves to a private or reserved IP address';
if (!allowPrivate) {
for (const addr of [...v4, ...v6]) {
if (isIPv4(addr) && isPrivateIPv4(addr)) return 'Host resolves to a private or reserved IP address';
if (isIPv6(addr) && isPrivateIPv6(addr)) return 'Host resolves to a private or reserved IP address';
}
}

return null;
Expand All @@ -89,8 +92,9 @@ export async function validateHost(host) {
// already a literal IP, since SNI override is not needed in that case)
//
// Throws if the host is a reserved/private literal or if DNS resolves to a private range.
export async function resolveForConnection(hostname) {
const literalErr = validateHostLiteral(hostname);
// Pass { allowPrivate: true } to skip all private/local checks (for self-hosted servers).
export async function resolveForConnection(hostname, { allowPrivate = false } = {}) {
const literalErr = validateHostLiteral(hostname, { allowPrivate });
if (literalErr) throw new Error(literalErr);

const h = hostname.trim();
Expand All @@ -104,16 +108,18 @@ export async function resolveForConnection(hostname) {
dnsPromises.resolve6(bare).catch(() => []),
]);

for (const addr of [...v4, ...v6]) {
if (isIPv4(addr) && isPrivateIPv4(addr)) throw new Error('Host resolves to a private or reserved IP address');
if (isIPv6(addr) && isPrivateIPv6(addr)) throw new Error('Host resolves to a private or reserved IP address');
if (!allowPrivate) {
for (const addr of [...v4, ...v6]) {
if (isIPv4(addr) && isPrivateIPv4(addr)) throw new Error('Host resolves to a private or reserved IP address');
if (isIPv6(addr) && isPrivateIPv6(addr)) throw new Error('Host resolves to a private or reserved IP address');
}
}

const all = [...v4, ...v6];
// DNS failed — let the connection attempt fail naturally (NXDOMAIN etc.).
if (!all.length) return { host: h, servername: null };

// Pin to first validated public IP. Pass servername so TLS SNI and cert verification
// Pin to first validated IP. Pass servername so TLS SNI and cert verification
// still use the hostname even though the socket connects directly to the IP.
return { host: all[0], servername: h };
}
45 changes: 45 additions & 0 deletions backend/src/services/hostValidation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,48 @@ describe('resolveForConnection', () => {
expect(result.servername).toBe('imap.gmail.com');
});
});

// ── allowPrivate option ────────────────────────────────────────────────────

describe('allowPrivate option', () => {
it('validateHostLiteral passes private IPv4 when allowPrivate is true', () => {
expect(validateHostLiteral('127.0.0.1', { allowPrivate: true })).toBeNull();
expect(validateHostLiteral('192.168.1.1', { allowPrivate: true })).toBeNull();
expect(validateHostLiteral('10.0.0.1', { allowPrivate: true })).toBeNull();
});

it('validateHostLiteral passes localhost when allowPrivate is true', () => {
expect(validateHostLiteral('localhost', { allowPrivate: true })).toBeNull();
expect(validateHostLiteral('mail.local', { allowPrivate: true })).toBeNull();
});

it('validateHostLiteral still blocks private hosts when allowPrivate is false (default)', () => {
expect(validateHostLiteral('127.0.0.1')).not.toBeNull();
expect(validateHostLiteral('localhost')).not.toBeNull();
});

it('validateHost passes private IPv4 when allowPrivate is true', async () => {
expect(await validateHost('192.168.1.1', { allowPrivate: true })).toBeNull();
});

it('validateHost passes a hostname resolving to a private IP when allowPrivate is true', async () => {
dns.resolve4.mockResolvedValue(['192.168.1.100']);
expect(await validateHost('protonmail.local', { allowPrivate: true })).toBeNull();
});

it('resolveForConnection does not throw for localhost when allowPrivate is true', async () => {
await expect(resolveForConnection('localhost', { allowPrivate: true })).resolves.toBeDefined();
});

it('resolveForConnection does not throw for a private IPv4 when allowPrivate is true', async () => {
const result = await resolveForConnection('127.0.0.1', { allowPrivate: true });
expect(result.host).toBe('127.0.0.1');
expect(result.servername).toBeNull();
});

it('resolveForConnection does not throw for a hostname resolving to private IP when allowPrivate is true', async () => {
dns.resolve4.mockResolvedValue(['192.168.1.100']);
const result = await resolveForConnection('bridge.local', { allowPrivate: true });
expect(result.host).toBe('192.168.1.100');
});
});
43 changes: 29 additions & 14 deletions backend/src/services/imapManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,21 @@ import { decrypt } from './encryption.js';
import { sendPushToUser } from './pushNotifications.js';
import { redactEmail } from '../utils/redact.js';
import { resolveForConnection } from './hostValidation.js';
import { getConnectionPolicy } from './connectionPolicy.js';
import { applyInboxRules } from './inboxRules.js';


// Shorthand for log lines — keeps domain visible while masking the local part.
const logAccount = (account) => redactEmail(account?.email_address || '');

// Resolves the IMAP host for an account, applying server-level connection policy.
// Returns { resolved, policy } so callers can pass policy to makeClientCfg.
const resolveAccountHost = async (account) => {
const policy = await getConnectionPolicy();
const resolved = await resolveForConnection(account.imap_host, { allowPrivate: policy.allowPrivateHosts });
return { resolved, policy };
};

// Body parts that cover ~99% of real-world email structures (used for full body caching)
const BODY_PREFETCH_PARTS = ['1', '1.1', '1.2', '2', '2.1', '2.2', '1.1.1', '1.2.1'];

Expand Down Expand Up @@ -393,8 +402,13 @@ async function ensureFreshToken(account) {

// resolved: { host, servername } from resolveForConnection() — pins the IP so the
// actual TCP connection uses the address we validated, not a later DNS lookup.
function makeClientCfg(account, resolved, { enableIdle = false } = {}) {
const tlsOpts = { rejectUnauthorized: !account.imap_skip_tls_verify };
// policy: result of getConnectionPolicy() — gates TLS verification override.
export function makeClientCfg(account, resolved, { enableIdle = false, policy = {} } = {}) {
if (!policy.allowInsecureTls && !account.imap_tls) {
throw new Error('Plain-text IMAP is not allowed: admin must enable "Allow insecure TLS"');
}
const skipTls = policy.allowInsecureTls && !!account.imap_skip_tls_verify;
const tlsOpts = { rejectUnauthorized: !skipTls };
// Set servername so TLS SNI and cert verification use the original hostname even
// though the socket connects directly to the pinned IP address.
if (resolved.servername) tlsOpts.servername = resolved.servername;
Expand Down Expand Up @@ -454,8 +468,8 @@ async function acquirePooledClient(account) {
// Grow pool if under limit — refresh token before creating a new connection
if (pool.clients.length < POOL_SIZE) {
const freshAccount = await ensureFreshToken(account);
const resolved = await resolveForConnection(freshAccount.imap_host);
const client = new ImapFlow(makeClientCfg(freshAccount, resolved));
const { resolved, policy } = await resolveAccountHost(freshAccount);
const client = new ImapFlow(makeClientCfg(freshAccount, resolved, { policy }));
await Promise.race([
client.connect(),
new Promise((_, reject) =>
Expand Down Expand Up @@ -487,8 +501,8 @@ async function acquirePooledClient(account) {
pool.waiters = pool.waiters.filter(w => w !== entry);
try {
const freshAccount = await ensureFreshToken(account);
const resolved = await resolveForConnection(freshAccount.imap_host);
const tmp = new ImapFlow(makeClientCfg(freshAccount, resolved));
const { resolved, policy } = await resolveAccountHost(freshAccount);
const tmp = new ImapFlow(makeClientCfg(freshAccount, resolved, { policy }));
tmp.on('error', (err) => {
console.error(`IMAP temp client error for account ${account.id}:`, err.message);
});
Expand Down Expand Up @@ -665,9 +679,10 @@ export class ImapManager {

// Refresh OAuth token if needed before connecting
account = await ensureFreshToken(account);
const resolved = await resolveForConnection(account.imap_host);
const client = new ImapFlow(makeClientCfg(account, resolved, { enableIdle: true }));
const { resolved, policy } = await resolveAccountHost(account);
let client;
try {
client = new ImapFlow(makeClientCfg(account, resolved, { enableIdle: true, policy }));
// Race the connect against a 30-second timeout.
// client.connect() has no built-in connection timeout — on slow or unresponsive
// IMAP servers (e.g. purelymail.com during cold starts) it can hang indefinitely,
Expand Down Expand Up @@ -795,8 +810,8 @@ export class ImapManager {
if (!accountResult.rows.length) return;
const freshAccount = await ensureFreshToken(accountResult.rows[0]);
syncAccount = freshAccount;
const resolved = await resolveForConnection(freshAccount.imap_host);
activeClient = new ImapFlow(makeClientCfg(freshAccount, resolved, { enableIdle: true }));
const { resolved, policy } = await resolveAccountHost(freshAccount);
activeClient = new ImapFlow(makeClientCfg(freshAccount, resolved, { enableIdle: true, policy }));
await Promise.race([
activeClient.connect(),
new Promise((_, reject) =>
Expand Down Expand Up @@ -1300,8 +1315,8 @@ export class ImapManager {
const row = (await query('SELECT * FROM email_accounts WHERE id = $1', [account.id])).rows[0];
if (!row) throw new Error('Account deleted');
const fresh = await ensureFreshToken(row);
const resolved = await resolveForConnection(fresh.imap_host);
const newClient = new ImapFlow(makeClientCfg(fresh, resolved));
const { resolved, policy } = await resolveAccountHost(fresh);
const newClient = new ImapFlow(makeClientCfg(fresh, resolved, { policy }));
newClient.on('error', (err) => {
console.error(`Backfill IMAP error for ${logAccount(account)}:`, err.message);
});
Expand Down Expand Up @@ -1717,8 +1732,8 @@ export class ImapManager {
const row = (await query('SELECT * FROM email_accounts WHERE id = $1', [account.id])).rows[0];
if (!row) throw new Error('Account deleted');
const fresh = await ensureFreshToken(row);
const resolved = await resolveForConnection(fresh.imap_host);
const c = new ImapFlow(makeClientCfg(fresh, resolved));
const { resolved, policy } = await resolveAccountHost(fresh);
const c = new ImapFlow(makeClientCfg(fresh, resolved, { policy }));
c.on('error', err => console.error(`Snippet indexer IMAP error ${logAccount(account)}:`, err.message));
await Promise.race([
c.connect(),
Expand Down
Loading