diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index 94df6cb..2669932 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -11,8 +11,9 @@ import secrets from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from typing import Any, Dict, Iterable, List, Optional, Tuple +from urllib.parse import urlsplit -from flask import Flask, Response, g, jsonify, request +from flask import Flask, Response, current_app, g, jsonify, request import yaml from functools import wraps from argon2 import PasswordHasher @@ -67,6 +68,11 @@ rate_limiter = RateLimiter() password_hasher = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4) +def _naive_utc_now() -> datetime: + """Return UTC without tzinfo for compatibility with existing DB values.""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + @dataclass(frozen=True) class Semver: major: int @@ -275,6 +281,14 @@ def run_ai_scrutiny_review(scrutiny_report: dict, config: dict, tool_name: str, def create_app() -> Flask: app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = MAX_BODY_BYTES + app.config.update( + CMDFORGE_ENV=os.environ.get("CMDFORGE_ENV", "development"), + PUBLIC_BASE_URL=os.environ.get("CMDFORGE_PUBLIC_URL", "").rstrip("/"), + MAIL_TRANSPORT=os.environ.get("MAIL_TRANSPORT", "disabled"), + MAIL_FROM=os.environ.get("MAIL_FROM", ""), + MAIL_TIMEOUT=os.environ.get("MAIL_TIMEOUT", "10"), + RESEND_API_KEY=os.environ.get("RESEND_API_KEY", ""), + ) # Initialize database schema once at startup with connect_db() as init_conn: @@ -2300,7 +2314,7 @@ def create_app() -> Flask: # Generate a secure token token, token_hash = generate_token() - expires_at = datetime.utcnow() + timedelta(hours=1) + expires_at = _naive_utc_now() + timedelta(hours=1) g.db.execute( """ @@ -2311,10 +2325,44 @@ def create_app() -> Flask: ) g.db.commit() - # Send email (logs to console in dev mode) + # Use a configured canonical URL in production. Falling back to the + # request URL is development-only because Host headers are untrusted. from cmdforge.web.email import send_password_reset_email - base_url = request.url_root.rstrip("/") - send_password_reset_email(publisher["email"], token, base_url) + base_url = str(current_app.config.get("PUBLIC_BASE_URL", "")).rstrip("/") + environment = str(current_app.config.get("CMDFORGE_ENV", "development")).lower() + if not base_url and environment != "production": + base_url = request.url_root.rstrip("/") + + parsed_base = urlsplit(base_url) + base_url_is_safe = bool( + base_url + and parsed_base.scheme in {"http", "https"} + and parsed_base.netloc + and parsed_base.username is None + and parsed_base.password is None + and not parsed_base.query + and not parsed_base.fragment + and (environment != "production" or parsed_base.scheme == "https") + ) + + try: + delivered = base_url_is_safe and send_password_reset_email( + publisher["email"], token, base_url + ) + except Exception: + # Preserve the non-enumerating response while ensuring the token is + # invalidated below. Flask logs the exception without email/token + # values because they are not part of this message. + current_app.logger.exception("Password reset delivery raised unexpectedly") + delivered = False + if not delivered: + # Never leave a usable reset token in the database when its only + # intended recipient could not receive it. + g.db.execute( + "UPDATE password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?", + [token_hash], + ) + g.db.commit() return success_response @@ -2346,7 +2394,7 @@ def create_app() -> Flask: try: expires_at = datetime.fromisoformat(row["expires_at"]) - if datetime.utcnow() > expires_at: + if _naive_utc_now() > expires_at: return error_response("TOKEN_EXPIRED", "This reset token has expired", 400) except ValueError: return error_response("INVALID_TOKEN", "Invalid token data", 400) @@ -2388,7 +2436,7 @@ def create_app() -> Flask: try: expires_at = datetime.fromisoformat(row["expires_at"]) - if datetime.utcnow() > expires_at: + if _naive_utc_now() > expires_at: return error_response("TOKEN_EXPIRED", "This reset token has expired", 400) except ValueError: return error_response("INVALID_TOKEN", "Invalid token data", 400) diff --git a/src/cmdforge/web/email.py b/src/cmdforge/web/email.py index 4779703..accec80 100644 --- a/src/cmdforge/web/email.py +++ b/src/cmdforge/web/email.py @@ -1,72 +1,103 @@ -"""Email sending utilities for CmdForge web. +"""Transactional email utilities for the CmdForge web application. -In development mode, emails are logged to the console instead of being sent. -To enable real email sending, set MAIL_ENABLED=true and configure SMTP settings. +Production delivery uses Resend's HTTPS API. Email bodies and reset URLs are +never written to logs. Console delivery is an explicit development-only mode. """ from __future__ import annotations import logging +from html import escape from typing import Optional +import requests from flask import current_app logger = logging.getLogger(__name__) +RESEND_ENDPOINT = "https://api.resend.com/emails" +SUPPORTED_TRANSPORTS = {"disabled", "console", "resend"} + + +def _transport() -> str: + transport = str(current_app.config.get("MAIL_TRANSPORT", "disabled")).strip().lower() + if transport not in SUPPORTED_TRANSPORTS: + logger.error("Unsupported email transport configured; delivery disabled") + return "disabled" + return transport + def send_email(to: str, subject: str, html_body: str, text_body: Optional[str] = None) -> bool: - """Send an email. + """Deliver an email without logging its recipient or contents. - In dev mode (MAIL_ENABLED=false or unset), logs the email to console. - In production (MAIL_ENABLED=true), sends via SMTP. - - Args: - to: Recipient email address - subject: Email subject - html_body: HTML content of the email - text_body: Plain text content (optional) - - Returns: - True if email was sent/logged successfully, False otherwise + ``MAIL_TRANSPORT`` must be one of ``disabled``, ``console``, or ``resend``. + Console mode records only that a message was suppressed and reports no + delivery, so reset tokens are invalidated. It is also rejected in + production. Resend mode requires ``RESEND_API_KEY`` and ``MAIL_FROM``. """ - mail_enabled = current_app.config.get("MAIL_ENABLED", False) + transport = _transport() + environment = str(current_app.config.get("CMDFORGE_ENV", "development")).lower() - if mail_enabled: - # Future: implement real SMTP sending - # smtp_host = current_app.config.get("MAIL_SERVER", "localhost") - # smtp_port = current_app.config.get("MAIL_PORT", 587) - # smtp_user = current_app.config.get("MAIL_USERNAME") - # smtp_pass = current_app.config.get("MAIL_PASSWORD") - # smtp_tls = current_app.config.get("MAIL_USE_TLS", True) - logger.warning("MAIL_ENABLED is true but SMTP is not implemented yet. Falling back to console logging.") + if transport == "disabled": + logger.warning("Email delivery is disabled") + return False - # Dev mode: log to console - logger.info("=" * 60) - logger.info("[EMAIL] To: %s", to) - logger.info("[EMAIL] Subject: %s", subject) - logger.info("[EMAIL] Body:") + if transport == "console": + if environment == "production": + logger.error("Console email transport is not permitted in production") + return False + logger.info("Development email suppressed (subject length=%d)", len(subject)) + return False + + api_key = str(current_app.config.get("RESEND_API_KEY") or "").strip() + sender = str(current_app.config.get("MAIL_FROM") or "").strip() + if not api_key or not sender: + logger.error("Resend email transport is missing required configuration") + return False + + try: + timeout = float(current_app.config.get("MAIL_TIMEOUT", 10.0)) + except (TypeError, ValueError): + logger.error("Invalid email timeout configured") + return False + if timeout <= 0 or timeout > 60: + logger.error("Email timeout must be greater than zero and at most 60 seconds") + return False + + payload = { + "from": sender, + "to": [to], + "subject": subject, + "html": html_body, + } if text_body: - logger.info(text_body) - else: - logger.info(html_body) - logger.info("=" * 60) + payload["text"] = text_body + try: + response = requests.post( + RESEND_ENDPOINT, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=timeout, + ) + response.raise_for_status() + except requests.RequestException as exc: + # Do not log the response body, recipient, request headers, or message; + # providers can echo sensitive request data in error responses. + logger.error("Transactional email delivery failed (%s)", type(exc).__name__) + return False + + logger.info("Transactional email accepted by provider") return True def send_password_reset_email(to: str, token: str, base_url: str) -> bool: - """Send a password reset email. - - Args: - to: Recipient email address - token: The password reset token - base_url: Base URL of the application (e.g., https://cmdforge.brrd.tech) - - Returns: - True if email was sent/logged successfully - """ + """Send a one-hour password reset link using the configured transport.""" reset_url = f"{base_url.rstrip('/')}/reset-password?token={token}" - + html_reset_url = escape(reset_url, quote=True) subject = "Reset your CmdForge password" html_body = f""" @@ -86,28 +117,15 @@ def send_password_reset_email(to: str, token: str, base_url: str) -> bool:
Hello,
-You requested to reset your password for your CmdForge account. Click the button below to set a new password:
- -- Reset Password -
- +Or copy and paste this link into your browser:
-{reset_url}
- +{html_reset_url}
This link will expire in 1 hour.
-If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.
- - +