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 -

- +

Reset Password

Or copy and paste this link into your browser:

- - +

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.

- - +
@@ -115,20 +133,10 @@ def send_password_reset_email(to: str, token: str, base_url: str) -> bool: text_body = f"""Reset your CmdForge password -Hello, - -You requested to reset your password for your CmdForge account. - -Reset your password by visiting this link: +Visit this link to set a new password: {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. - ---- -CmdForge +This link will expire in 1 hour. If you did not request this reset, ignore this email. """ return send_email(to, subject, html_body, text_body) diff --git a/tests/test_email.py b/tests/test_email.py new file mode 100644 index 0000000..6d4377f --- /dev/null +++ b/tests/test_email.py @@ -0,0 +1,173 @@ +"""Tests for transactional email and password-reset delivery safety.""" + +from __future__ import annotations + +import hashlib +import logging +from unittest.mock import patch + +import requests +from flask import Flask + +from cmdforge.registry.app import create_app, password_hasher +from cmdforge.registry.db import connect_db +from cmdforge.web.email import send_email + + +def _email_app(**config) -> Flask: + app = Flask(__name__) + defaults = { + "CMDFORGE_ENV": "development", + "MAIL_TRANSPORT": "disabled", + "MAIL_FROM": "CmdForge ", + "MAIL_TIMEOUT": 10, + "RESEND_API_KEY": "", + } + defaults.update(config) + app.config.update(defaults) + return app + + +def test_disabled_delivery_does_not_log_message_or_recipient(caplog): + app = _email_app() + with app.app_context(), caplog.at_level(logging.INFO): + assert not send_email( + "private@example.com", "Reset subject", "secret reset body", "secret text" + ) + assert "private@example.com" not in caplog.text + assert "secret reset body" not in caplog.text + assert "secret text" not in caplog.text + + +def test_console_transport_is_rejected_in_production(caplog): + app = _email_app(CMDFORGE_ENV="production", MAIL_TRANSPORT="console") + with app.app_context(), caplog.at_level(logging.INFO): + assert not send_email("private@example.com", "subject", "body") + assert "private@example.com" not in caplog.text + assert "body" not in caplog.text + + +def test_resend_transport_sends_expected_request(monkeypatch): + app = _email_app(MAIL_TRANSPORT="resend", RESEND_API_KEY="test-api-key") + captured = {} + + class Response: + def raise_for_status(self): + return None + + def fake_post(url, **kwargs): + captured["url"] = url + captured.update(kwargs) + return Response() + + monkeypatch.setattr(requests, "post", fake_post) + with app.app_context(): + assert send_email("person@example.com", "subject", "

body

", "body") + + assert captured["url"] == "https://api.resend.com/emails" + assert captured["headers"]["Authorization"] == "Bearer test-api-key" + assert captured["json"]["to"] == ["person@example.com"] + assert captured["timeout"] == 10.0 + + +def test_resend_failure_does_not_log_sensitive_data(monkeypatch, caplog): + app = _email_app(MAIL_TRANSPORT="resend", RESEND_API_KEY="test-api-key") + + def fail_post(*args, **kwargs): + raise requests.ConnectionError("provider unavailable") + + monkeypatch.setattr(requests, "post", fail_post) + with app.app_context(), caplog.at_level(logging.INFO): + assert not send_email("private@example.com", "subject", "reset-secret") + assert "private@example.com" not in caplog.text + assert "reset-secret" not in caplog.text + assert "test-api-key" not in caplog.text + + +def _registry_client(tmp_path, monkeypatch): + db_path = tmp_path / "registry.db" + monkeypatch.setenv("CMDFORGE_REGISTRY_DB", str(db_path)) + monkeypatch.setenv("CMDFORGE_ENV", "production") + monkeypatch.setenv("CMDFORGE_PUBLIC_URL", "https://cmdforge.example") + app = create_app() + app.config["TESTING"] = True + with connect_db() as db: + db.execute( + """ + INSERT INTO publishers (email, password_hash, slug, display_name) + VALUES (?, ?, ?, ?) + """, + ["owner@example.com", password_hasher.hash("initial-password"), "owner", "Owner"], + ) + db.commit() + return app.test_client(), db_path + + +def test_reset_uses_canonical_url_and_leaves_delivered_token_valid(tmp_path, monkeypatch): + client, db_path = _registry_client(tmp_path, monkeypatch) + captured = {} + + def delivered(to, token, base_url): + captured.update(to=to, token=token, base_url=base_url) + return True + + with patch("cmdforge.web.email.send_password_reset_email", delivered): + response = client.post( + "/api/v1/password-reset/request", + json={"email": "owner@example.com"}, + headers={"Host": "attacker.example"}, + ) + + assert response.status_code == 200 + assert captured["base_url"] == "https://cmdforge.example" + with connect_db() as db: + row = db.execute( + "SELECT token_hash, used_at FROM password_reset_tokens" + ).fetchone() + assert row["token_hash"] == hashlib.sha256(captured["token"].encode()).hexdigest() + assert row["used_at"] is None + + +def test_reset_invalidates_token_when_delivery_fails(tmp_path, monkeypatch): + client, db_path = _registry_client(tmp_path, monkeypatch) + + with patch("cmdforge.web.email.send_password_reset_email", return_value=False): + response = client.post( + "/api/v1/password-reset/request", json={"email": "owner@example.com"} + ) + + assert response.status_code == 200 + with connect_db() as db: + row = db.execute("SELECT used_at FROM password_reset_tokens").fetchone() + assert row["used_at"] is not None + + +def test_reset_invalidates_token_without_safe_production_base_url(tmp_path, monkeypatch): + client, db_path = _registry_client(tmp_path, monkeypatch) + client.application.config["PUBLIC_BASE_URL"] = "http://insecure.example" + + with patch("cmdforge.web.email.send_password_reset_email") as sender: + response = client.post( + "/api/v1/password-reset/request", json={"email": "owner@example.com"} + ) + + assert response.status_code == 200 + sender.assert_not_called() + with connect_db() as db: + row = db.execute("SELECT used_at FROM password_reset_tokens").fetchone() + assert row["used_at"] is not None + + +def test_unknown_email_creates_no_token_and_sends_nothing(tmp_path, monkeypatch): + client, db_path = _registry_client(tmp_path, monkeypatch) + + with patch("cmdforge.web.email.send_password_reset_email") as sender: + response = client.post( + "/api/v1/password-reset/request", json={"email": "missing@example.com"} + ) + + assert response.status_code == 200 + sender.assert_not_called() + with connect_db() as db: + count = db.execute("SELECT COUNT(*) FROM password_reset_tokens").fetchone()[0] + assert count == 0