security fixes - details in changelog

This commit is contained in:
ChrispyBacon-dev 2026-09-19 18:23:47 +02:00
parent b310417514
commit 0f29d58fd1
20 changed files with 832 additions and 182 deletions

View file

@ -12,6 +12,16 @@ All notable changes to this project will be documented in this file.
- **Email domain lifecycle:** Tearing down a domain now removes its webhook bypass Access app, creating a mailbox that already has a routing rule updates it instead of failing with a 409, and quota KV namespace creation no longer logs "already exists" on every start.
- **Access Group country picker:** With a lot of countries selected the chip list wouldn't scroll and the dropdown was clipped by the modal. It now scrolls, and the dropdown renders outside the modal.
### Security
- **Outbound email worker hardening:** The worker no longer relays for arbitrary senders - it pins the sender to the configured domain (plus an optional allowlist), validates recipients, strips CR/LF from all headers to block header/MIME injection, stops leaking the `Bcc:` header, encodes attachment filenames, compares the auth secret in constant time, and enforces per-sender hourly/daily rate limits. Mail-manager sanitizes header fields before dispatch too.
- **Agent API Access app no longer bypasses everyone:** The `/api/v2/agents/` Cloudflare Access app no longer gets a `bypass` policy for `everyone`, and existing installs are cleaned up on startup. Admin browser bypass, when wanted, is scoped to the configured authorized emails.
- **Agent secrets removed from `state.json`:** Agent API keys and tunnel tokens now live only in the encrypted key store, legacy plaintext values are migrated on first load, backups no longer contain them, and rolling a key revokes all previous active keys.
- **Mail-manager secrets encrypted at rest:** R2 secret keys, webhook secrets and outbound auth secrets are AES-GCM encrypted in the SQLite DB (`MAIL_SECRET_KEY`, falling back to the bootstrap secret), existing rows are migrated on startup, and DB/directory permissions are tightened.
- **Rate limits can't be spoofed:** The client IP used for login/mailbox rate limiting now only trusts forwarded headers from known proxies (Cloudflare ranges, loopback and private ranges by default, overridable via `TRUSTED_PROXY_IPS`).
- **Email status no longer leaks secrets:** `/email/status` and the Email page now return a non-secret summary instead of the full config (no private JWT/VAPID keys, R2 secrets or webhook secrets).
- **Cross-mailbox folder protection:** Moving a message now requires the target folder to belong to that mailbox and folder listings are mailbox-scoped; any legacy mis-filed messages are repaired on startup.
- **Agent UI XSS and CORS:** Agent-supplied fields are HTML-escaped before rendering, and the blanket `Access-Control-Allow-Origin: *` on the master UI was removed.
## [v3.1.5] - 2026-09-01

View file

@ -93,6 +93,7 @@ services:
- DOCKFLARE_MASTER_URL=http://dockflare:5000
- MAIL_DATA_PATH=/data
- INTERNAL_BOOTSTRAP_SECRET=${INTERNAL_BOOTSTRAP_SECRET:?set INTERNAL_BOOTSTRAP_SECRET}
- MAIL_SECRET_KEY=${MAIL_SECRET_KEY:-}
volumes:
- mail_data:/data
depends_on:

View file

@ -21,6 +21,7 @@ import sys
import os
import json
import hashlib
import ipaddress
from flask import Flask
from flask_wtf.csrf import CSRFProtect
@ -46,11 +47,60 @@ log_formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', dat
oauth = None
DEFAULT_TRUSTED_PROXY_NETS = (
"127.0.0.0/8", "::1/128",
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7",
"173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22",
"141.101.64.0/18", "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20",
"197.234.240.0/22", "198.41.128.0/17", "162.158.0.0/15", "104.16.0.0/13",
"104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
"2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32",
"2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32",
)
_trusted_proxy_networks_cache = None
def _trusted_proxy_networks():
global _trusted_proxy_networks_cache
if _trusted_proxy_networks_cache is not None:
return _trusted_proxy_networks_cache
raw = os.environ.get("TRUSTED_PROXY_IPS", "").strip()
entries = [value.strip() for value in raw.split(",") if value.strip()] if raw else list(DEFAULT_TRUSTED_PROXY_NETS)
networks = []
for entry in entries:
try:
networks.append(ipaddress.ip_network(entry, strict=False))
except ValueError:
logging.warning(f"TRUSTED_PROXY_IPS: ignoring invalid entry '{entry}'")
_trusted_proxy_networks_cache = tuple(networks)
return _trusted_proxy_networks_cache
def _is_trusted_proxy(ip_text):
if not ip_text:
return False
try:
address = ipaddress.ip_address(ip_text)
except ValueError:
return False
return any(address in network for network in _trusted_proxy_networks())
def get_client_ip():
peer = flask_request.remote_addr or ""
if _is_trusted_proxy(peer):
forwarded = flask_request.headers.get('CF-Connecting-IP')
if not forwarded:
xff = flask_request.headers.get('X-Forwarded-For', '')
forwarded = xff.split(',')[0].strip() if xff else None
if forwarded:
return forwarded
return peer
def _get_real_ip():
return (
flask_request.headers.get('CF-Connecting-IP') or
get_remote_address()
)
return get_client_ip()
limiter = Limiter(
key_func=_get_real_ip,

View file

@ -213,3 +213,36 @@ def clear_service_token_secret() -> None:
if _CF_SERVICE_TOKEN_KEY in _cached_keys:
del _cached_keys[_CF_SERVICE_TOKEN_KEY]
_persist_locked()
_AGENT_TUNNEL_PREFIX = "__agent_tunnel__"
def store_agent_tunnel_token(agent_id, token) -> None:
if not agent_id or not token:
return
_ensure_loaded()
with _store_lock:
_cached_keys[f"{_AGENT_TUNNEL_PREFIX}{agent_id}"] = {"token": token}
_persist_locked()
def get_agent_tunnel_token(agent_id):
if not agent_id:
return None
_ensure_loaded()
with _store_lock:
entry = _cached_keys.get(f"{_AGENT_TUNNEL_PREFIX}{agent_id}")
if isinstance(entry, dict):
return entry.get("token")
return None
def clear_agent_tunnel_token(agent_id) -> None:
if not agent_id:
return
_ensure_loaded()
with _store_lock:
if f"{_AGENT_TUNNEL_PREFIX}{agent_id}" in _cached_keys:
del _cached_keys[f"{_AGENT_TUNNEL_PREFIX}{agent_id}"]
_persist_locked()

View file

@ -34,6 +34,83 @@ def _parse_hostname(public_url):
return parsed.hostname
def _admin_bypass_emails():
emails = []
try:
from flask import current_app
emails = list(current_app.config.get('OAUTH_AUTHORIZED_USERS') or [])
except Exception:
emails = []
if not emails:
emails = list(getattr(config, 'OAUTH_AUTHORIZED_USERS', []) or [])
return sorted({email.strip() for email in emails if isinstance(email, str) and '@' in email})
def _create_scoped_bypass(account_id, app_uuid, emails):
resp = cf_api_request(
"POST",
f"/accounts/{account_id}/access/apps/{app_uuid}/policies",
json_data={
"name": "DockFlare Admin Bypass",
"decision": "bypass",
"precedence": 2,
"include": [{"email": {"email": email}} for email in emails]
}
)
return resp.get("result", {}).get("id")
def _policy_includes_everyone(policy):
for rule in policy.get("include") or []:
if isinstance(rule, dict) and "everyone" in rule:
return True
return False
def ensure_agent_access_hardening():
data = get_agent_cf_token()
account_id = _account_id()
if not data or not account_id:
return
app_uuid = data.get("app_uuid")
if not app_uuid:
return
try:
resp = cf_api_request("GET", f"/accounts/{account_id}/access/apps/{app_uuid}/policies")
policies = resp.get("result") or []
except Exception as e:
logging.warning(f"Could not list Cloudflare Access policies for the Agent API app: {e}")
return
admin_emails = _admin_bypass_emails()
scoped_bypass = None
broad_bypass_ids = []
for policy in policies:
if policy.get("decision") != "bypass":
continue
policy_id = policy.get("id") or policy.get("uid")
if _policy_includes_everyone(policy):
broad_bypass_ids.append(policy_id)
else:
scoped_bypass = policy_id
for policy_id in broad_bypass_ids:
try:
cf_api_request("DELETE", f"/accounts/{account_id}/access/apps/{app_uuid}/policies/{policy_id}")
logging.warning("Removed an everyone-bypass policy from the Cloudflare Access Agent API application")
except Exception as e:
logging.warning(f"Could not remove broad Access bypass policy {policy_id}: {e}")
if not scoped_bypass and admin_emails:
try:
new_id = _create_scoped_bypass(account_id, app_uuid, admin_emails)
if new_id:
logging.info("Created a scoped admin bypass policy for the Cloudflare Access Agent API application")
except Exception as e:
logging.warning(f"Could not create scoped admin bypass policy: {e}")
def ensure_agent_service_token(public_url):
existing = get_agent_cf_token()
existing_secret = agent_key_store.get_service_token_secret()
@ -91,17 +168,10 @@ def ensure_agent_service_token(public_url):
policy_result = policy_resp.get("result", {})
policy_id = policy_result.get("id")
bypass_resp = cf_api_request(
"POST",
f"/accounts/{account_id}/access/apps/{app_uuid}/policies",
json_data={
"name": "DockFlare Admin Bypass",
"decision": "bypass",
"precedence": 2,
"include": [{"everyone": {}}]
}
)
bypass_policy_id = bypass_resp.get("result", {}).get("id")
admin_emails = _admin_bypass_emails()
bypass_policy_id = None
if admin_emails:
bypass_policy_id = _create_scoped_bypass(account_id, app_uuid, admin_emails)
agent_key_store.store_service_token_secret(client_secret)
@ -110,8 +180,9 @@ def ensure_agent_service_token(public_url):
"token_id": token_id,
"app_uuid": app_uuid,
"policy_id": policy_id,
"bypass_policy_id": bypass_policy_id,
}
if bypass_policy_id:
token_data["bypass_policy_id"] = bypass_policy_id
set_agent_cf_token(token_data)
return {**token_data, "client_secret": client_secret}

View file

@ -91,6 +91,80 @@ def _resolve_remote_policy(reusable_policies, policy_name, decision, include_rul
return None
_AGENT_STATE_SECRET_FIELDS = ("api_key", "assigned_tunnel_token")
def _sanitize_agent_for_state(agent):
clean = dict(agent)
for field in _AGENT_STATE_SECRET_FIELDS:
clean.pop(field, None)
try:
token = agent_key_store.get_agent_tunnel_token(agent.get("id"))
except Exception:
token = None
if token:
clean["has_tunnel_token"] = True
commands = []
for command in clean.get("commands") or []:
if isinstance(command, dict):
command = {key: value for key, value in command.items() if key not in ("token", "tunnel_token")}
commands.append(command)
clean["commands"] = commands
return clean
def _scrub_secret_fields(value):
if isinstance(value, dict):
return {
key: _scrub_secret_fields(item)
for key, item in value.items()
if key not in ("token", "tunnel_token", "api_key", "assigned_tunnel_token")
}
if isinstance(value, list):
return [_scrub_secret_fields(item) for item in value]
return value
def _migrate_agent_secrets():
moved_keys = 0
moved_tokens = 0
for agent_id, agent_data in list(agents.items()):
if not isinstance(agent_data, dict):
continue
legacy_key = agent_data.get("api_key")
if isinstance(legacy_key, str) and legacy_key:
existing = agent_key_store.get_key(legacy_key)
metadata = dict(existing) if existing else {}
metadata.setdefault("bound_agent_id", agent_id)
metadata.setdefault("status", "active")
agent_key_store.upsert_key(legacy_key, metadata)
moved_keys += 1
legacy_token = agent_data.get("assigned_tunnel_token")
if isinstance(legacy_token, str) and legacy_token:
agent_key_store.store_agent_tunnel_token(agent_id, legacy_token)
moved_tokens += 1
for command in agent_data.get("commands") or []:
if not isinstance(command, dict):
continue
if command.get("action") == "start_tunnel" and not command.get("token"):
token = agent_key_store.get_agent_tunnel_token(agent_id)
if token:
command["token"] = token
elif command.get("action") == "restart_tunnel" and not command.get("tunnel_token"):
token = agent_key_store.get_agent_tunnel_token(agent_id)
if token:
command["tunnel_token"] = token
agent_data.pop("api_key", None)
agent_data.pop("assigned_tunnel_token", None)
if moved_keys or moved_tokens:
logging.warning(
"STATE_SECURITY: moved %s plaintext agent key(s) and %s tunnel token(s) out of state.json into the encrypted store",
moved_keys,
moved_tokens,
)
return bool(moved_keys or moved_tokens)
STATE_SCHEMA_VERSION = 3
RULE_LIFECYCLE_DEFAULTS = {
"source_rule_key": None,
@ -331,6 +405,8 @@ def load_state():
)
identity_providers.update(idps_to_load)
agent_cf_token.update(cf_token_to_load)
if _migrate_agent_secrets():
save_state()
key_count = len(agent_key_store.list_keys())
logging.info(
"LOAD_STATE: Loaded %s access groups, %s agents and %s agent keys (encrypted backing store).",
@ -698,8 +774,15 @@ def save_state():
serializable_rules = {}
rules_to_iterate = list(managed_rules.items())
groups_to_iterate = dict(access_groups)
agents_to_iterate = dict(agents)
decommissions_to_iterate = dict(agent_decommissions)
agents_to_iterate = {
agent_id: _sanitize_agent_for_state(agent_data)
for agent_id, agent_data in agents.items()
if isinstance(agent_data, dict)
}
decommissions_to_iterate = {
operation_id: _scrub_secret_fields(operation)
for operation_id, operation in agent_decommissions.items()
}
idps_to_iterate = dict(identity_providers)
cf_token_to_iterate = dict(agent_cf_token)

View file

@ -1,94 +1,244 @@
import { EmailMessage } from "cloudflare:email";
const MAX_ATTACHMENTS = 25;
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
const MAX_SUBJECT_LENGTH = 998;
function json(payload, status) {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json" },
});
}
function stripHeader(value) {
return String(value ?? "").replace(/[\r\n\u0000]+/g, " ").trim();
}
function hasInjection(value) {
return /[\r\n\u0000]/.test(String(value ?? ""));
}
async function safeEqual(a, b) {
const encoder = new TextEncoder();
const [da, db] = await Promise.all([
crypto.subtle.digest("SHA-256", encoder.encode(String(a ?? ""))),
crypto.subtle.digest("SHA-256", encoder.encode(String(b ?? ""))),
]);
const va = new Uint8Array(da);
const vb = new Uint8Array(db);
let diff = va.length ^ vb.length;
for (let i = 0; i < va.length; i++) diff |= va[i] ^ (vb[i] || 0);
return diff === 0;
}
function parseAddress(value) {
if (typeof value !== "string") return null;
const text = value.trim();
if (hasInjection(text)) return null;
const match = text.match(/<([^<>]+)>/);
const address = (match ? match[1] : text).trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) return null;
return address;
}
function addressList(value) {
const items = Array.isArray(value) ? value : value ? [value] : [];
const seen = [];
for (const item of items) {
const address = parseAddress(item);
if (address && !seen.includes(address)) seen.push(address);
}
return seen;
}
function sanitizeFilename(name) {
return String(name ?? "").replace(/[\r\n\u0000"\\/]/g, "_").slice(0, 255) || "attachment";
}
function sanitizeContentType(value) {
const text = stripHeader(value);
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(\s*;.*)?$/i.test(text)) {
return "application/octet-stream";
}
return text.replace(/[\r\n\u0000]/g, "");
}
async function isRateLimited(env, sender) {
if (!env.RATE_LIMIT_KV) return false;
const now = new Date();
const hourKey = `outbound:${sender}:h:${now.toISOString().slice(0, 13)}`;
const dayKey = `outbound:${sender}:d:${now.toISOString().slice(0, 10)}`;
const hourLimit = parseInt(env.RATE_LIMIT_PER_HOUR || "50", 10);
const dayLimit = parseInt(env.RATE_LIMIT_PER_DAY || "200", 10);
const [hourRaw, dayRaw] = await Promise.all([
env.RATE_LIMIT_KV.get(hourKey),
env.RATE_LIMIT_KV.get(dayKey),
]);
const hourCount = parseInt(hourRaw || "0", 10);
const dayCount = parseInt(dayRaw || "0", 10);
if (hourCount >= hourLimit || dayCount >= dayLimit) return true;
await Promise.all([
env.RATE_LIMIT_KV.put(hourKey, String(hourCount + 1), { expirationTtl: 7200 }),
env.RATE_LIMIT_KV.put(dayKey, String(dayCount + 1), { expirationTtl: 172800 }),
]);
return false;
}
export default {
async fetch(request, env, ctx) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
return json({ error: "method not allowed" }, 405);
}
const authHeader = request.headers.get("Authorization") || "";
const provided = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
if (!env.AUTH_SECRET || !(await safeEqual(provided, env.AUTH_SECRET))) {
return json({ error: "unauthorized" }, 401);
}
let body;
try {
body = await request.json();
} catch (e) {
return json({ error: "invalid json" }, 400);
}
const domain = String(env.DOMAIN_NAME || "").toLowerCase();
const fromAddress = parseAddress(body.from);
if (!fromAddress) {
return json({ error: "invalid sender" }, 400);
}
if (domain) {
const fromDomain = fromAddress.slice(fromAddress.lastIndexOf("@") + 1).toLowerCase();
if (fromDomain !== domain) {
return json({ error: "sender domain not allowed" }, 403);
}
}
if (env.ALLOWED_SENDERS) {
let allowedSenders = [];
try {
allowedSenders = JSON.parse(env.ALLOWED_SENDERS);
} catch (e) {
allowedSenders = [];
}
if (Array.isArray(allowedSenders) && allowedSenders.length > 0) {
const normalized = allowedSenders.map((a) => String(a).toLowerCase());
if (!normalized.includes(fromAddress.toLowerCase())) {
return json({ error: "sender not allowed" }, 403);
}
}
}
const toList = addressList(body.to);
const ccList = addressList(body.cc);
const bccList = addressList(body.bcc);
if (toList.length === 0) {
return json({ error: "invalid recipients" }, 400);
}
if (env.RECIPIENT_ALLOWLIST) {
let allowedRecipients = [];
try {
allowedRecipients = JSON.parse(env.RECIPIENT_ALLOWLIST);
} catch (e) {
allowedRecipients = [];
}
if (Array.isArray(allowedRecipients) && allowedRecipients.length > 0) {
const normalized = allowedRecipients.map((a) => String(a).toLowerCase());
const all = toList.concat(ccList, bccList).map((a) => a.toLowerCase());
if (!all.every((a) => normalized.includes(a))) {
return json({ error: "recipient not allowed" }, 403);
}
}
}
if (await isRateLimited(env, fromAddress.toLowerCase())) {
return json({ error: "rate limit exceeded" }, 429);
}
const envelopeRecipients = toList.concat(ccList, bccList).filter(
(address, index, array) => array.indexOf(address) === index
);
const subject = stripHeader(body.subject).slice(0, MAX_SUBJECT_LENGTH);
const replyTo = addressList(body.replyTo);
const inReplyTo = stripHeader(body.inReplyTo);
const references = stripHeader(body.references);
const messageId = stripHeader(body.messageId);
const attachments = (Array.isArray(body.attachments) ? body.attachments : []).filter(
(a) => a && typeof a.data_b64 === "string" && a.data_b64.length > 0
).slice(0, MAX_ATTACHMENTS);
let totalAttachmentBytes = 0;
for (const att of attachments) {
totalAttachmentBytes += att.data_b64.length;
}
if (totalAttachmentBytes > MAX_TOTAL_BYTES) {
return json({ error: "attachments too large" }, 413);
}
const authHeader = request.headers.get("Authorization");
if (authHeader !== `Bearer ${env.AUTH_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
const body = await request.json();
const toList = Array.isArray(body.to) ? body.to : [body.to];
const toHeader = toList.join(", ");
const attachments = Array.isArray(body.attachments) ? body.attachments.filter(a => a && a.data_b64) : [];
const hasAttachments = attachments.length > 0;
const innerBoundary = "b" + crypto.randomUUID().replace(/-/g, "");
const outerBoundary = hasAttachments ? "b" + crypto.randomUUID().replace(/-/g, "") : null;
let mimeMessage = `From: ${body.from}\r\nTo: ${toHeader}\r\n`;
if (body.cc) mimeMessage += `Cc: ${Array.isArray(body.cc) ? body.cc.join(", ") : body.cc}\r\n`;
if (body.bcc) mimeMessage += `Bcc: ${Array.isArray(body.bcc) ? body.bcc.join(", ") : body.bcc}\r\n`;
mimeMessage += `Subject: ${body.subject}\r\n`;
let mimeMessage = `From: ${fromAddress}\r\nTo: ${toList.join(", ")}\r\n`;
if (ccList.length > 0) mimeMessage += `Cc: ${ccList.join(", ")}\r\n`;
mimeMessage += `Subject: ${subject}\r\n`;
mimeMessage += `Date: ${new Date().toUTCString()}\r\n`;
if (body.replyTo) mimeMessage += `Reply-To: ${body.replyTo}\r\n`;
if (body.inReplyTo) mimeMessage += `In-Reply-To: ${body.inReplyTo}\r\n`;
if (body.references) mimeMessage += `References: ${body.references}\r\n`;
if (body.messageId) mimeMessage += `Message-ID: ${body.messageId}\r\n`;
if (replyTo.length > 0) mimeMessage += `Reply-To: ${replyTo[0]}\r\n`;
if (inReplyTo) mimeMessage += `In-Reply-To: ${inReplyTo}\r\n`;
if (references) mimeMessage += `References: ${references}\r\n`;
if (messageId) mimeMessage += `Message-ID: ${messageId}\r\n`;
mimeMessage += `MIME-Version: 1.0\r\n`;
const textBody = body.text || (body.html ? "" : "(no content)");
const textBody = String(body.text || (body.html ? "" : "(no content)"));
const htmlBody = typeof body.html === "string" ? body.html : "";
if (hasAttachments) {
mimeMessage += `Content-Type: multipart/mixed; boundary="${outerBoundary}"\r\n\r\n`;
mimeMessage += `--${outerBoundary}\r\n`;
mimeMessage += `Content-Type: multipart/alternative; boundary="${innerBoundary}"\r\n\r\n`;
if (textBody) {
mimeMessage += `--${innerBoundary}\r\nContent-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${textBody}\r\n`;
}
if (body.html) {
mimeMessage += `--${innerBoundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${body.html}\r\n`;
if (htmlBody) {
mimeMessage += `--${innerBoundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${htmlBody}\r\n`;
}
mimeMessage += `--${innerBoundary}--\r\n`;
for (const att of attachments) {
const ct = att.content_type || 'application/octet-stream';
const fn = att.filename || 'attachment';
const contentType = sanitizeContentType(att.content_type);
const filename = sanitizeFilename(att.filename);
const encodedName = encodeURIComponent(filename);
mimeMessage += `\r\n--${outerBoundary}\r\n`;
mimeMessage += `Content-Type: ${ct}; name="${fn}"\r\n`;
mimeMessage += `Content-Type: ${contentType}; name="=?UTF-8?Q?${encodedName.replace(/[?=]/g, "")}?="\r\n`;
mimeMessage += `Content-Transfer-Encoding: base64\r\n`;
mimeMessage += `Content-Disposition: attachment; filename="${fn}"\r\n\r\n`;
// Chunk base64 at 76 chars per line (RFC 2045)
const b64 = att.data_b64.replace(/(.{76})/g, '$1\r\n');
mimeMessage += `Content-Disposition: attachment; filename="=?UTF-8?Q?${encodedName.replace(/[?=]/g, "")}?="; filename*=UTF-8''${encodedName}\r\n\r\n`;
const b64 = att.data_b64.replace(/[^A-Za-z0-9+/=]/g, "").replace(/(.{76})/g, "$1\r\n");
mimeMessage += `${b64}\r\n`;
}
mimeMessage += `\r\n--${outerBoundary}--\r\n`;
} else {
mimeMessage += `Content-Type: multipart/alternative; boundary="${innerBoundary}"\r\n\r\n`;
if (textBody) {
mimeMessage += `--${innerBoundary}\r\nContent-Type: text/plain; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${textBody}\r\n`;
}
if (body.html) {
mimeMessage += `--${innerBoundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${body.html}\r\n`;
if (htmlBody) {
mimeMessage += `--${innerBoundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 8bit\r\n\r\n${htmlBody}\r\n`;
}
mimeMessage += `--${innerBoundary}--\r\n`;
}
try {
const fromMatch = typeof body.from === 'string' ? body.from.match(/<([^>]+)>/) : null;
const fromEnvelope = fromMatch ? fromMatch[1] : (body.from || '').trim();
for (const recipient of toList) {
const addrMatch = typeof recipient === 'string' ? recipient.match(/<([^>]+)>/) : null;
const toAddress = addrMatch ? addrMatch[1] : (typeof recipient === 'string' ? recipient.trim() : recipient);
const message = new EmailMessage(fromEnvelope, toAddress, mimeMessage);
for (const recipient of envelopeRecipients) {
const message = new EmailMessage(fromAddress, recipient, mimeMessage);
await env.SEND_EMAIL.send(message);
}
return new Response(JSON.stringify({ success: true, message_id: body.messageId }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
return json({ success: true, message_id: messageId || undefined }, 200);
} catch (e) {
return new Response(JSON.stringify({ success: false, error: e.message }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
return json({ success: false, error: e.message }, 500);
}
}
};

View file

@ -316,6 +316,13 @@ def start_core_services():
except Exception as e:
logging.debug(f"Could not refresh email sending status on startup: {e}")
try:
from app.core.service_token_manager import ensure_agent_access_hardening
with app.app_context():
ensure_agent_access_hardening()
except Exception as e:
logging.warning(f"Agent Access hardening check failed: {e}")
notification_manager.end_bootstrap()
run_all_background_tasks()

View file

@ -817,6 +817,8 @@
const noRevokedKeysMessage = document.getElementById('no-revoked-keys');
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
const renderAgentsTable = (agents) => {
agentsTableBody.innerHTML = '';
if (!agents || Object.keys(agents).length === 0) {
@ -838,10 +840,10 @@
const connectorVersion = agent.connector_version || null;
const connectorPlatform = agent.connector_platform || null;
const cloudflaredVersion = connectorVersion
? `<span class="text-sm font-medium">${connectorVersion}</span>${connectorPlatform ? `<br><span class="text-xs text-base-content/40 font-normal">${connectorPlatform}</span>` : ''}`
? `<span class="text-sm font-medium">${escapeHtml(connectorVersion)}</span>${connectorPlatform ? `<br><span class="text-xs text-base-content/40 font-normal">${escapeHtml(connectorPlatform)}</span>` : ''}`
: na;
const originIp = agent.connector_origin_ip
? `<span class="font-mono text-sm">${agent.connector_origin_ip}</span>`
? `<span class="font-mono text-sm">${escapeHtml(agent.connector_origin_ip)}</span>`
: na;
const migrationStatus = agent.migration_status;
@ -861,20 +863,20 @@
tr.innerHTML = `
<td class="px-4 py-3 whitespace-nowrap" data-label="${t('agents.agent_id')}">
<span class="font-mono text-xs text-base-content/50 cursor-help" title="${agentId}">${agentId.substring(0, 8)}…</span>
<span class="font-mono text-xs text-base-content/50 cursor-help" title="${escapeHtml(agentId)}">${escapeHtml(agentId.substring(0, 8))}…</span>
</td>
<td class="px-4 py-3 whitespace-nowrap" data-label="${t('agents.display_name')}">
<span class="text-sm font-medium">${agent.display_name || na}</span>
<span class="text-sm font-medium">${agent.display_name ? escapeHtml(agent.display_name) : na}</span>
</td>
<td class="px-4 py-3 whitespace-nowrap" data-label="${t('agents.version')}">
<span class="text-sm text-base-content/60">${agent.version || na}</span>
<span class="text-sm text-base-content/60">${agent.version ? escapeHtml(agent.version) : na}</span>
</td>
<td class="px-4 py-3" data-label="${t('agents.status')}">
<div class="flex flex-wrap gap-1">${statusHtml}</div>
</td>
<td class="px-4 py-3 text-center" data-label="${t('agents.heart_beat')}">${ledStripHtml}</td>
<td class="px-4 py-3 whitespace-nowrap" data-label="${t('agents.assigned_tunnel')}">
<span class="text-sm">${agent.assigned_tunnel_name || na}</span>
<span class="text-sm">${agent.assigned_tunnel_name ? escapeHtml(agent.assigned_tunnel_name) : na}</span>
</td>
<td class="px-4 py-3" data-label="${t('agents.migration')}">${migrationHtml}${migrationStatus && (migrationStatus.has_conflicts || migrationStatus.has_orphaned) ? `<button class="btn btn-xs btn-info ml-2 btn-migration-assistant" data-agent-id="${agentId}" title="Open Migration Assistant">Resolve</button>` : ''}</td>
<td class="px-4 py-3 whitespace-nowrap" data-label="${t('agents.cloudflared_version')}">${cloudflaredVersion}</td>

View file

@ -1425,17 +1425,20 @@ def agents_revoke_key():
key = _resolve_agent_key_identifier(key_identifier)
if not key:
return jsonify({"status": "error", "message": "Key not found."}), 404
key_info = get_agent_key_info(key) or {}
bound_agent_id = key_info.get("bound_agent_id")
ok = revoke_agent_key(key)
if ok:
affected_agents = []
agents_snapshot = list_agents()
now_iso = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
for agent_id, agent_data in agents_snapshot.items():
if agent_data.get("api_key") == key:
if bound_agent_id:
agents_snapshot = list_agents()
agent_data = agents_snapshot.get(bound_agent_id)
if agent_data:
now_iso = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
agent_meta = dict(agent_data.get("meta") or {})
agent_meta["last_key_revoked_at"] = now_iso
update_agent(agent_id, {"api_key": None, "status": "pending", "meta": agent_meta})
affected_agents.append(agent_id)
update_agent(bound_agent_id, {"status": "pending", "meta": agent_meta})
affected_agents.append(bound_agent_id)
return jsonify({"status": "success", "message": "Key revoked.", "affected_agents": affected_agents}), 200
else:
return jsonify({"status": "error", "message": "Key not found."}), 404
@ -2278,12 +2281,16 @@ def agents_enroll(agent_id):
current_agent.update({
"assigned_tunnel_name": tunnel_name,
"assigned_tunnel_id": tunnel_id,
"assigned_tunnel_token": token,
"assigned_tunnel_ownership": tunnel_ownership,
"status": "enrolled",
"commands": existing_cmds,
"last_enrolled_at": datetime.now(timezone.utc).isoformat(),
})
try:
from app.core import agent_key_store as _agent_key_store
_agent_key_store.store_agent_tunnel_token(agent_id, token)
except Exception as _token_err:
logging.warning(f"Could not persist tunnel token for agent {agent_id}: {_token_err}")
rules_updated = False
previous_rules = {}
if not persistence_failed:
@ -2469,7 +2476,8 @@ def redeploy_agent_tunnel(agent_id):
tunnel_name = agent_record.get("assigned_tunnel_name")
tunnel_id = agent_record.get("assigned_tunnel_id")
tunnel_token = agent_record.get("assigned_tunnel_token")
from app.core import agent_key_store as _agent_key_store
tunnel_token = _agent_key_store.get_agent_tunnel_token(agent_id) or agent_record.get("assigned_tunnel_token")
if not all([tunnel_name, tunnel_id, tunnel_token]):
return jsonify({"status": "error", "message": "Agent missing tunnel configuration."}), 400
@ -2505,23 +2513,23 @@ def roll_agent_api_key(agent_id):
if _agent_decommission_blocks_action(agent_record):
return jsonify({"status": "error", "message": "Agent decommission is in progress."}), 409
old_api_key = agent_record.get("api_key")
from app.core import agent_key_store as _agent_key_store
new_api_key = secrets.token_urlsafe(32)
success = update_agent(agent_id, {"api_key": new_api_key})
if not success:
return jsonify({"status": "error", "message": "Failed to update agent with new API key."}), 500
if old_api_key:
revoke_agent_key(old_api_key)
now_iso = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
revoked = 0
for existing_key, existing_meta in _agent_key_store.list_keys().items():
if (existing_meta or {}).get("bound_agent_id") == agent_id and (existing_meta or {}).get("status", "active") == "active":
if revoke_agent_key(existing_key):
revoked += 1
add_agent_key(new_api_key, {
"bound_agent_id": agent_id,
"created_at": now_iso,
"last_used_at": None,
"rolled_from": old_api_key[:8] + "..." if old_api_key else None
"status": "active",
"rolled_from_previous": bool(revoked),
})
return jsonify({

View file

@ -7,6 +7,7 @@ import os
import secrets
import time
import jwt
from urllib.parse import urlparse
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, current_app
from flask_login import login_required, current_user
from cryptography.hazmat.primitives import serialization
@ -23,21 +24,73 @@ def _read_worker_template(filename):
with open(os.path.join(_WORKER_TEMPLATE_DIR, filename), 'r') as f:
return f.read()
def _outbound_bindings(domain_name, auth_secret, quota_kv_ns_id=None):
bindings = [
{"type": "send_email", "name": "SEND_EMAIL"},
{"type": "secret_text", "name": "AUTH_SECRET", "text": auth_secret},
{"type": "plain_text", "name": "DOMAIN_NAME", "text": domain_name},
{"type": "plain_text", "name": "RATE_LIMIT_PER_HOUR", "text": str(getattr(config, 'EMAIL_OUTBOUND_RATE_LIMIT_PER_HOUR', 50))},
{"type": "plain_text", "name": "RATE_LIMIT_PER_DAY", "text": str(getattr(config, 'EMAIL_OUTBOUND_RATE_LIMIT_PER_DAY', 200))},
]
if quota_kv_ns_id:
bindings.append({"type": "kv_namespace", "name": "RATE_LIMIT_KV", "namespace_id": quota_kv_ns_id})
return bindings
email_bp = Blueprint('email', __name__, url_prefix='/email')
def _public_email_config(email_cfg):
public = {
"enabled": email_cfg.get("enabled", False),
"domains": {},
}
for domain, domain_cfg in (email_cfg.get("domains") or {}).items():
public["domains"][domain] = {
"zone_id": domain_cfg.get("zone_id"),
"zone_name": domain_cfg.get("zone_name"),
"email_routing_enabled": domain_cfg.get("email_routing_enabled"),
"email_sending_status": domain_cfg.get("email_sending_status"),
"r2_bucket": domain_cfg.get("r2_bucket"),
"inbound_worker_name": domain_cfg.get("inbound_worker_name"),
"outbound_worker_name": domain_cfg.get("outbound_worker_name"),
"catch_all_address": domain_cfg.get("catch_all_address"),
"mailboxes": {
address: {
"display_name": mailbox.get("display_name"),
"quota_bytes": mailbox.get("quota_bytes"),
"created_at": mailbox.get("created_at"),
}
for address, mailbox in (domain_cfg.get("mailboxes") or {}).items()
},
}
return public
def _allowed_webmail_origins():
allowed = set()
webmail_hostname = _get_webmail_hostname()
if webmail_hostname:
allowed.add(webmail_hostname.lower())
for domain in (config.EMAIL_CONFIG.get('domains') or {}).keys():
allowed.add(f"mail.{domain}".lower())
return allowed
def _webmail_origin():
request_origin = request.headers.get('Origin', '')
allowed_hosts = _allowed_webmail_origins()
fallback = f"https://{sorted(allowed_hosts)[0]}" if allowed_hosts else 'null'
if not request_origin:
return '*'
# Allow any origin that looks like our mail subdomains
if '.dockflare.app' in request_origin or 'localhost' in request_origin or '127.0.0.1' in request_origin:
return request_origin
# Fallback to the first domain or *
domains = config.EMAIL_CONFIG.get('domains', {})
first_domain = next(iter(domains), '')
return f"https://mail.{first_domain}" if first_domain else '*'
return fallback
try:
parsed = urlparse(request_origin)
except Exception:
return fallback
if parsed.scheme in ('http', 'https') and parsed.hostname:
host = parsed.hostname.lower()
if host in allowed_hosts or host in ('localhost', '127.0.0.1', '::1'):
return request_origin
return fallback
def save_email_config(email_config_data):
cfg, fernet = load_encrypted_config_with_cipher()
@ -70,7 +123,7 @@ def _get_mail_manager_state():
def email_page():
zones = list_account_zones() or []
mail_manager_state = _get_mail_manager_state()
return render_template('email.html', zones=zones, email_config=config.EMAIL_CONFIG, email_enabled=config.EMAIL_ENABLED, mail_manager_state=mail_manager_state, cf_account_id=config.CF_ACCOUNT_ID or '')
return render_template('email.html', zones=zones, email_config=_public_email_config(config.EMAIL_CONFIG), email_enabled=config.EMAIL_ENABLED, mail_manager_state=mail_manager_state, cf_account_id=config.CF_ACCOUNT_ID or '')
@email_bp.route('/setup-domain', methods=['POST'])
@login_required
@ -172,10 +225,7 @@ def setup_email_domain():
email_sending_status = email_manager.get_email_sending_status(zone_id, zone_name)
outbound_bindings = [
{"type": "send_email", "name": "SEND_EMAIL"},
{"type": "secret_text", "name": "AUTH_SECRET", "text": outbound_auth_secret}
]
outbound_bindings = _outbound_bindings(zone_name, outbound_auth_secret, quota_kv_ns_id)
email_manager.deploy_worker(outbound_worker_name, _read_worker_template('outbound_worker.js'), outbound_bindings)
@ -413,10 +463,7 @@ def local_domains():
def _redeploy_outbound_worker(email_cfg, domain):
d = email_cfg['domains'][domain]
outbound_bindings = [
{"type": "send_email", "name": "SEND_EMAIL"},
{"type": "secret_text", "name": "AUTH_SECRET", "text": d['outbound_auth_secret']}
]
outbound_bindings = _outbound_bindings(domain, d['outbound_auth_secret'], d.get('quota_kv_namespace_id'))
email_manager.deploy_worker(d['outbound_worker_name'], _read_worker_template('outbound_worker.js'), outbound_bindings)
def _redeploy_inbound_worker(email_cfg, domain):
@ -618,7 +665,7 @@ def update_r2_credentials():
@email_bp.route('/status', methods=['GET'])
@login_required
def email_status_api():
return jsonify({'success': True, 'config': config.EMAIL_CONFIG})
return jsonify({'success': True, 'config': _public_email_config(config.EMAIL_CONFIG)})
@email_bp.route('/verify-dns', methods=['POST'])
@login_required

View file

@ -124,24 +124,22 @@ def roll_agent_key(agent_id):
cloudflared_agent_state["last_action_status"] = f"Error: Agent '{agent_id}' not found."
return redirect(url_for('web.agents_page'))
old_api_key = agent.get("api_key")
new_api_key = secrets.token_urlsafe(32)
success = update_agent(agent_id, {"api_key": new_api_key})
if not success:
cloudflared_agent_state["last_action_status"] = f"Error: Failed to update agent '{agent_id}' with new API key."
return redirect(url_for('web.agents_page'))
if old_api_key:
revoke_agent_key(old_api_key)
from app.core import agent_key_store as _agent_key_store
revoked = 0
for existing_key, existing_meta in _agent_key_store.list_keys().items():
if (existing_meta or {}).get("bound_agent_id") == agent_id and (existing_meta or {}).get("status", "active") == "active":
if revoke_agent_key(existing_key):
revoked += 1
now_iso = datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
add_agent_key(new_api_key, {
"bound_agent_id": agent_id,
"created_at": now_iso,
"last_used_at": None,
"rolled_from": old_api_key[:8] + "..." if old_api_key else None
"status": "active",
"rolled_from_previous": bool(revoked),
})
cloudflared_agent_state["last_action_status"] = f"Success: API key rolled for agent '{agent.get('display_name', agent_id)}'. Agent must be restarted with new key: {new_api_key}"
@ -263,9 +261,6 @@ def add_security_headers_bp(response):
if is_https:
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Requested-With, Authorization'
return response
@bp.context_processor

View file

@ -18,6 +18,7 @@ set -euo pipefail
# DOCKFLARE_TLD — base domain e.g. example.com (required if EMAIL=true)
# DOCKFLARE_DOMAIN — DockFlare master domain (default: dockflare.$DOCKFLARE_TLD)
# INTERNAL_BOOTSTRAP_SECRET — existing service-to-service secret (generated if unset)
# MAIL_SECRET_KEY — key for encrypting mail secrets at rest (generated if unset)
#
# Examples:
# bash <(curl -fsSL https://dockflare.app/install.sh)
@ -33,6 +34,7 @@ DOCKFLARE_EMAIL="${DOCKFLARE_EMAIL:-false}"
DOCKFLARE_TLD="${DOCKFLARE_TLD:-}"
DOCKFLARE_DOMAIN="${DOCKFLARE_DOMAIN:-}"
INTERNAL_BOOTSTRAP_SECRET="${INTERNAL_BOOTSTRAP_SECRET:-}"
MAIL_SECRET_KEY="${MAIL_SECRET_KEY:-}"
BOLD="\033[1m"
GREEN="\033[0;32m"
@ -297,73 +299,107 @@ section "Configuring internal service authentication"
ENV_FILE="${DOCKFLARE_DIR}/.env"
# Preserve the existing secret when the installer is rerun. Do not source the
# file: it is configuration data and may contain values that are unsafe to
# evaluate as shell code.
if [ -z "$INTERNAL_BOOTSTRAP_SECRET" ] && [ -f "$ENV_FILE" ]; then
INTERNAL_BOOTSTRAP_SECRET="$(
awk '
/^INTERNAL_BOOTSTRAP_SECRET=/ {
sub(/^INTERNAL_BOOTSTRAP_SECRET=/, "")
print
exit
}
' "$ENV_FILE"
)"
fi
# Preserve existing secrets when the installer is rerun. Do not source the file:
# it is configuration data and may contain values that are unsafe to evaluate as
# shell code.
read_env_value() {
[ -f "$ENV_FILE" ] || return 0
awk -v key="$1" '
$0 ~ "^" key "=" {
sub("^" key "=", "")
print
exit
}
' "$ENV_FILE"
}
if [ -z "$INTERNAL_BOOTSTRAP_SECRET" ]; then
INTERNAL_BOOTSTRAP_SECRET="$(read_env_value INTERNAL_BOOTSTRAP_SECRET)"
fi
if [ -z "$MAIL_SECRET_KEY" ]; then
MAIL_SECRET_KEY="$(read_env_value MAIL_SECRET_KEY)"
fi
generate_secret() {
if command -v openssl &>/dev/null; then
INTERNAL_BOOTSTRAP_SECRET="$(openssl rand -hex 32)"
openssl rand -hex 32
elif command -v od &>/dev/null && command -v tr &>/dev/null && [ -r /dev/urandom ]; then
INTERNAL_BOOTSTRAP_SECRET="$(od -An -N32 -tx1 /dev/urandom | tr -d '[:space:]')"
od -An -N32 -tx1 /dev/urandom | tr -d '[:space:]'
else
return 1
fi
}
if [ -z "$INTERNAL_BOOTSTRAP_SECRET" ]; then
INTERNAL_BOOTSTRAP_SECRET="$(generate_secret)" || {
error "Could not generate INTERNAL_BOOTSTRAP_SECRET (openssl or od with /dev/urandom is required)."
exit 1
fi
}
fi
if [ "${#INTERNAL_BOOTSTRAP_SECRET}" -lt 32 ]; then
error "INTERNAL_BOOTSTRAP_SECRET must contain at least 32 characters."
exit 1
fi
case "$INTERNAL_BOOTSTRAP_SECRET" in
*[!A-Za-z0-9._-]*)
error "INTERNAL_BOOTSTRAP_SECRET may contain only letters, numbers, dots, underscores, and hyphens."
if [ -z "$MAIL_SECRET_KEY" ]; then
MAIL_SECRET_KEY="$(generate_secret)" || {
error "Could not generate MAIL_SECRET_KEY (openssl or od with /dev/urandom is required)."
exit 1
;;
esac
}
fi
# Write or replace only the bootstrap-secret entry while preserving any other
# installer settings already present in .env. The temporary file and final file
# are restricted to the installing user.
validate_secret() {
local name="$1"
local value="$2"
if [ "${#value}" -lt 32 ]; then
error "${name} must contain at least 32 characters."
exit 1
fi
case "$value" in
*[!A-Za-z0-9._-]*)
error "${name} may contain only letters, numbers, dots, underscores, and hyphens."
exit 1
;;
esac
}
validate_secret INTERNAL_BOOTSTRAP_SECRET "$INTERNAL_BOOTSTRAP_SECRET"
validate_secret MAIL_SECRET_KEY "$MAIL_SECRET_KEY"
# Write or replace only these entries while preserving any other installer
# settings already present in .env. The temporary file and final file are
# restricted to the installing user.
umask 077
ENV_TMP="$(mktemp "${DOCKFLARE_DIR}/.env.tmp.XXXXXX")"
trap 'rm -f "$ENV_TMP"' EXIT
if [ -f "$ENV_FILE" ]; then
awk -v secret="$INTERNAL_BOOTSTRAP_SECRET" '
BEGIN { written = 0 }
awk -v bootstrap="$INTERNAL_BOOTSTRAP_SECRET" -v mailkey="$MAIL_SECRET_KEY" '
BEGIN { wrote_bootstrap = 0; wrote_mailkey = 0 }
/^INTERNAL_BOOTSTRAP_SECRET=/ {
if (!written) {
print "INTERNAL_BOOTSTRAP_SECRET=" secret
written = 1
if (!wrote_bootstrap) {
print "INTERNAL_BOOTSTRAP_SECRET=" bootstrap
wrote_bootstrap = 1
}
next
}
/^MAIL_SECRET_KEY=/ {
if (!wrote_mailkey) {
print "MAIL_SECRET_KEY=" mailkey
wrote_mailkey = 1
}
next
}
{ print }
END {
if (!written) {
print "INTERNAL_BOOTSTRAP_SECRET=" secret
if (!wrote_bootstrap) {
print "INTERNAL_BOOTSTRAP_SECRET=" bootstrap
}
if (!wrote_mailkey) {
print "MAIL_SECRET_KEY=" mailkey
}
}
' "$ENV_FILE" > "$ENV_TMP"
else
printf 'INTERNAL_BOOTSTRAP_SECRET=%s\n' "$INTERNAL_BOOTSTRAP_SECRET" > "$ENV_TMP"
printf 'INTERNAL_BOOTSTRAP_SECRET=%s\nMAIL_SECRET_KEY=%s\n' "$INTERNAL_BOOTSTRAP_SECRET" "$MAIL_SECRET_KEY" > "$ENV_TMP"
fi
chmod 600 "$ENV_TMP"
mv "$ENV_TMP" "$ENV_FILE"
trap - EXIT
info "Internal bootstrap secret configured in ${ENV_FILE}"
info "Internal bootstrap secret and mail encryption key configured in ${ENV_FILE}"
# -----------------------------------------------------------------------------
# Docker network

View file

@ -56,6 +56,23 @@ def _check_mailbox_access(address):
return address in request.user.get('mailboxes', [])
def _folder_belongs_to_mailbox(db, folder_id, address):
try:
folder_id = int(folder_id)
except (TypeError, ValueError):
return False
row = db.execute(
"SELECT 1 FROM folders WHERE id=? AND mailbox_address=?",
(folder_id, address),
).fetchone()
return row is not None
def _strip_header_value(value, max_length=998):
text = str(value or '').replace('\r', ' ').replace('\n', ' ').replace('\x00', ' ').strip()
return text[:max_length]
@api_bp.route('/stats', methods=['GET'])
@admin_required
def stats():
@ -358,13 +375,13 @@ def get_messages(address):
folder_id = folder_row['id']
cur = db.execute(
"SELECT COUNT(*) FROM messages WHERE folder_id=?", (folder_id,)
"SELECT COUNT(*) FROM messages WHERE folder_id=? AND mailbox_address=?", (folder_id, address)
)
total = cur.fetchone()[0]
cur = db.execute(
f"SELECT * FROM messages WHERE folder_id=? ORDER BY {sort_expr} {order} LIMIT ? OFFSET ?",
(folder_id, per_page, offset),
f"SELECT * FROM messages WHERE folder_id=? AND mailbox_address=? ORDER BY {sort_expr} {order} LIMIT ? OFFSET ?",
(folder_id, address, per_page, offset),
)
msgs = [dict(row) for row in cur.fetchall()]
return _paginated(msgs, total, page, per_page)
@ -455,6 +472,8 @@ def patch_message(address, msg_id):
(int(bool(data['is_starred'])), msg_id, address),
)
if 'folder_id' in data:
if not _folder_belongs_to_mailbox(db, data['folder_id'], address):
return jsonify({"error": "invalid folder"}), 400
db.execute(
"UPDATE messages SET folder_id=? WHERE id=? AND mailbox_address=?",
(data['folder_id'], msg_id, address),
@ -476,6 +495,8 @@ def bulk_move(address):
return jsonify({"error": "message_ids and folder_id are required"}), 400
db = get_db()
if not _folder_belongs_to_mailbox(db, folder_id, address):
return jsonify({"error": "invalid folder"}), 400
for mid in msg_ids:
db.execute(
"UPDATE messages SET folder_id=? WHERE id=? AND mailbox_address=?",
@ -521,14 +542,14 @@ def get_folders(address):
folders = [dict(row) for row in cur.fetchall()]
for f in folders:
cur = db.execute(
"SELECT COUNT(*) FROM messages WHERE folder_id=? AND is_read=0",
(f['id'],),
"SELECT COUNT(*) FROM messages WHERE folder_id=? AND mailbox_address=? AND is_read=0",
(f['id'], address),
)
f['unread_count'] = cur.fetchone()[0]
cur = db.execute(
"SELECT COUNT(*) FROM messages WHERE folder_id=?",
(f['id'],),
"SELECT COUNT(*) FROM messages WHERE folder_id=? AND mailbox_address=?",
(f['id'], address),
)
f['total_count'] = cur.fetchone()[0]
@ -820,23 +841,32 @@ def _dispatch_send(address, data, effective_from=None, via_alias=None):
(sender_domain,)
).fetchone()
if db_cfg and db_cfg['outbound_worker_url']:
from app.core.secret_store import decrypt_value
outbound_url = db_cfg['outbound_worker_url']
outbound_auth = db_cfg['outbound_auth_secret']
outbound_auth = decrypt_value(db_cfg['outbound_auth_secret'])
if external_recipients:
safe_attachments = [
{
**att,
"filename": _strip_header_value(att.get("filename") or "attachment", 255) or "attachment",
"content_type": _strip_header_value(att.get("content_type") or "application/octet-stream", 255) or "application/octet-stream",
}
for att in attachments
]
worker_payload = {
"from": from_formatted,
"from": _strip_header_value(from_formatted),
"to": external_recipients,
"cc": data.get('cc'),
"bcc": data.get('bcc'),
"subject": subject,
"subject": _strip_header_value(subject),
"text": text,
"html": html,
"replyTo": data.get('reply_to') or data.get('replyTo'),
"inReplyTo": data.get('in_reply_to') or data.get('inReplyTo'),
"references": data.get('references'),
"messageId": msg_id,
"attachments": attachments,
"replyTo": _strip_header_value(data.get('reply_to') or data.get('replyTo') or '') or None,
"inReplyTo": _strip_header_value(data.get('in_reply_to') or data.get('inReplyTo') or '') or None,
"references": _strip_header_value(data.get('references') or '') or None,
"messageId": _strip_header_value(msg_id),
"attachments": safe_attachments,
}
if outbound_url:
try:

View file

@ -156,9 +156,10 @@ def _check_and_send_auto_reply(db, mailbox_address, parsed, domain_cfg):
def _get_domain_config(domain):
from app.core.secret_store import decrypt_domain_config
db = get_db()
cur = db.execute("SELECT * FROM domain_configs WHERE domain_name=?", (domain,))
return cur.fetchone()
return decrypt_domain_config(cur.fetchone())
def _verify_signature(req, secret):
@ -190,7 +191,8 @@ def inbound():
log.warning("Inbound webhook: X-DockFlare-Domain header missing with %d domains configured", count)
return jsonify({"error": "domain header required"}), 400
row = db.execute("SELECT * FROM domain_configs LIMIT 1").fetchone()
domain_cfg = row if row else None
from app.core.secret_store import decrypt_domain_config
domain_cfg = decrypt_domain_config(row)
secret = domain_cfg['webhook_secret'] if domain_cfg else config.WEBHOOK_SECRET
if not _verify_signature(request, secret):

View file

@ -54,6 +54,10 @@ class _Config:
def OUTBOUND_AUTH_SECRET(self):
return _env('OUTBOUND_AUTH_SECRET')
@property
def MAIL_SECRET_KEY(self):
return _env('MAIL_SECRET_KEY') or _env('INTERNAL_BOOTSTRAP_SECRET')
@property
def DB_PATH(self):
return os.path.join(self.MAIL_DATA_PATH, 'db', 'mail.db')

View file

@ -1,5 +1,6 @@
import sqlite3
import os
import logging
from flask import g
from app.config import config
@ -192,6 +193,27 @@ def _migrate(conn):
except Exception:
pass
try:
cursor = conn.execute("""
UPDATE messages
SET folder_id = (
SELECT f2.id FROM folders f2
WHERE f2.mailbox_address = messages.mailbox_address AND f2.name = 'Inbox'
)
WHERE EXISTS (
SELECT 1 FROM folders f_inbox
WHERE f_inbox.mailbox_address = messages.mailbox_address AND f_inbox.name = 'Inbox'
)
AND folder_id IN (
SELECT f3.id FROM folders f3 WHERE f3.mailbox_address != messages.mailbox_address
)
""")
if cursor.rowcount:
logging.warning(f"Repaired {cursor.rowcount} message(s) filed into another mailbox's folder")
conn.commit()
except Exception:
pass
try:
row = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='push_subscriptions'"
@ -292,6 +314,31 @@ def _migrate(conn):
except Exception:
pass
try:
from app.core.secret_store import encrypt_value, is_encrypted
rows = conn.execute(
"SELECT domain_name, webhook_secret, r2_secret_access_key, outbound_auth_secret FROM domain_configs"
).fetchall()
encrypted_count = 0
for row in rows:
updates = {}
for index, column in enumerate(("webhook_secret", "r2_secret_access_key", "outbound_auth_secret"), start=1):
value = row[index]
if value and not is_encrypted(value):
updates[column] = encrypt_value(value)
if updates:
set_clause = ", ".join(f"{column}=?" for column in updates)
conn.execute(
f"UPDATE domain_configs SET {set_clause} WHERE domain_name=?",
(*updates.values(), row[0]),
)
encrypted_count += 1
if encrypted_count:
logging.warning(f"Encrypted secrets for {encrypted_count} domain config row(s) at rest")
conn.commit()
except Exception as e:
logging.warning(f"Could not encrypt mail-manager secrets at rest: {e}")
def init_db():
import logging
@ -299,6 +346,13 @@ def init_db():
conn = _connect()
_migrate(conn)
conn.executescript(_SCHEMA)
try:
conn.execute("PRAGMA secure_delete=ON")
conn.commit()
os.chmod(os.path.dirname(config.DB_PATH), 0o700)
os.chmod(config.DB_PATH, 0o600)
except Exception as e:
logging.getLogger('mail-manager').warning("Could not tighten mail DB permissions: %s", e)
result = conn.execute("PRAGMA quick_check").fetchone()
if result and result[0] != 'ok':
logging.getLogger('mail-manager').critical("SQLite integrity check failed: %s", result[0])

View file

@ -24,9 +24,10 @@ def get_r2_client(access_key_id, secret_access_key, endpoint_url):
def _resolve(domain_cfg):
if domain_cfg:
from app.core.secret_store import decrypt_value
return (
domain_cfg['r2_access_key_id'],
domain_cfg['r2_secret_access_key'],
decrypt_value(domain_cfg['r2_secret_access_key']),
domain_cfg['r2_endpoint_url'],
domain_cfg['r2_bucket'],
)

View file

@ -0,0 +1,65 @@
import base64
import hashlib
import logging
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.config import config
_PREFIX = "enc:v1:"
SECRET_COLUMNS = ("webhook_secret", "r2_secret_access_key", "outbound_auth_secret")
def _encryption_key():
raw = (config.MAIL_SECRET_KEY or '').strip()
if not raw:
return None
try:
candidate = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
if len(candidate) == 32:
return candidate
except Exception:
pass
return hashlib.sha256(raw.encode('utf-8')).digest()
def is_encrypted(value):
return isinstance(value, str) and value.startswith(_PREFIX)
def encrypt_value(value):
if not value or not isinstance(value, str) or is_encrypted(value):
return value
key = _encryption_key()
if key is None:
logging.warning("MAIL_SECRET_KEY is not configured; a mail secret is being stored unencrypted")
return value
nonce = os.urandom(12)
ciphertext = AESGCM(key).encrypt(nonce, value.encode('utf-8'), None)
return _PREFIX + base64.urlsafe_b64encode(nonce + ciphertext).decode('ascii')
def decrypt_value(value):
if not is_encrypted(value):
return value
key = _encryption_key()
if key is None:
logging.error("Cannot decrypt a stored mail secret because MAIL_SECRET_KEY is missing")
return value
try:
blob = base64.urlsafe_b64decode(value[len(_PREFIX):])
return AESGCM(key).decrypt(blob[:12], blob[12:], None).decode('utf-8')
except Exception as e:
logging.error(f"Failed to decrypt a stored mail secret: {e}")
return value
def decrypt_domain_config(row):
if row is None:
return None
data = dict(row)
for column in SECRET_COLUMNS:
if column in data:
data[column] = decrypt_value(data[column])
return data

View file

@ -110,6 +110,7 @@ def _sync_domains(bootstrap_data):
conn = sqlite3.connect(db_path)
now = datetime.now(timezone.utc).isoformat()
try:
from app.core.secret_store import encrypt_value
for zone_name, d in bootstrap_data.get('domains', {}).items():
conn.execute("""
INSERT INTO domain_configs (
@ -128,13 +129,13 @@ def _sync_domains(bootstrap_data):
updated_at=excluded.updated_at
""", (
zone_name,
d.get('webhook_secret', ''),
encrypt_value(d.get('webhook_secret', '')),
d.get('r2_bucket', ''),
d.get('r2_access_key_id', ''),
d.get('r2_secret_access_key', ''),
encrypt_value(d.get('r2_secret_access_key', '')),
d.get('r2_endpoint_url', ''),
d.get('outbound_worker_url', ''),
d.get('outbound_auth_secret', ''),
encrypt_value(d.get('outbound_auth_secret', '')),
now,
))
conn.commit()