mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-10-19 07:33:16 +00:00
security fixes - details in changelog
This commit is contained in:
parent
b310417514
commit
0f29d58fd1
20 changed files with 832 additions and 182 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -817,6 +817,8 @@
|
|||
const noRevokedKeysMessage = document.getElementById('no-revoked-keys');
|
||||
|
||||
|
||||
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (ch) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
114
install.sh
114
install.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
)
|
||||
|
|
|
|||
65
mail-manager/app/core/secret_store.py
Normal file
65
mail-manager/app/core/secret_store.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue