Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 3 additions & 11 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def register_static_mime_types() -> None:
import bcrypt as _bcrypt

from src.app_helpers import abs_join, serve_html_with_nonce
from src.constants import PROXY_TUNNEL_HEADERS
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
from starlette.responses import RedirectResponse

Expand Down Expand Up @@ -328,15 +329,6 @@ def _refresh_token_cache():
_token_cache.update(new_map)
app.state._token_cache_dirty = False

# Headers that prove a request was forwarded by a proxy/tunnel (cloudflared,
# nginx, Caddy, Tailscale Funnel, …). cloudflared connects to the app FROM
# 127.0.0.1, so without this check every tunneled request would look like
# loopback and could bypass auth.
_PROXY_FWD_HEADERS = (
"cf-connecting-ip", "cf-ray", "cf-visitor",
"x-forwarded-for", "x-forwarded-host", "x-real-ip", "forwarded",
)

def _is_trusted_loopback(request: Request) -> bool:
"""True ONLY for a DIRECT loopback connection with no proxy/tunnel
forwarding headers. A bare ``client.host in ('127.0.0.1','::1')`` check is
Expand All @@ -348,8 +340,8 @@ def _is_trusted_loopback(request: Request) -> bool:
host = request.client.host if request.client else None
if host not in ("127.0.0.1", "::1"):
return False
for _h in _PROXY_FWD_HEADERS:
if request.headers.get(_h):
for _h in PROXY_TUNNEL_HEADERS:
if _h in request.headers:
return False
return True

Expand Down
4 changes: 4 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,10 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
4. Keep raw service and model ports internal-only.

Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.

Create the first administrator before enabling the proxy by opening `http://127.0.0.1:7000/setup` directly on the Odysseus host. First-run setup is rejected through reverse proxies and other network-facing hostnames.

For a headless remote host, establish an SSH tunnel before enabling the proxy: `ssh -L 7000:127.0.0.1:7000 user@host`, then open `http://127.0.0.1:7000/setup` in the local browser. Platforms that cannot provide loopback access must complete bootstrap before exposing the service; Odysseus intentionally provides no remote setup bypass.
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.

Common internal-only ports from the default docs/compose setup:
Expand Down
48 changes: 47 additions & 1 deletion routes/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@

from core.atomic_io import atomic_write_json, atomic_write_text
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
from src.constants import (
DEEP_RESEARCH_DIR,
MEMORY_FILE,
PASSWORD_MIN_LENGTH,
PROXY_TUNNEL_HEADERS,
SKILLS_DIR,
)
from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings
from src.settings import (
Expand Down Expand Up @@ -84,6 +90,41 @@ class SetOpenRegistrationRequest(BaseModel):
SESSION_COOKIE = "odysseus_session"


_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"}
def _request_from_loopback(request: Request) -> bool:
"""Return true only for a direct localhost request.

A reverse proxy on the same host also connects from loopback, so the TCP
peer alone is not sufficient. Require both the peer and requested host to
be localhost and reject requests carrying proxy forwarding headers.

A misconfigured same-host proxy that rewrites Host to loopback and strips
every forwarding header is indistinguishable from a direct request. The
deployment contract therefore also requires completing setup before the
proxy is enabled, as documented in docs/setup.md.
"""
client_host = (
str(request.client.host if request.client else "")
.strip()
.rstrip(".")
.lower()
)
request_host = (
str(getattr(getattr(request, "url", None), "hostname", "") or "")
.strip()
.rstrip(".")
.lower()
)

if any(name in request.headers for name in PROXY_TUNNEL_HEADERS):
return False

return (
client_host in _LOOPBACK_HOSTS
and request_host in _LOOPBACK_HOSTS
)


def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
router = APIRouter(prefix="/api/auth", tags=["auth"])

Expand All @@ -102,6 +143,11 @@ async def first_run_setup(body: SetupRequest, request: Request):
raise HTTPException(429, "Too many requests — try again later")
if auth_manager.is_configured:
raise HTTPException(400, "Already configured")
if not _request_from_loopback(request):
raise HTTPException(
403,
"First-run setup must be completed directly from localhost",
)
if len(body.password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.username.strip()) < 1:
Expand Down
51 changes: 33 additions & 18 deletions routes/email_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -890,12 +890,14 @@ def _get_email_config(account_id: str | None = None, owner: str = "") -> dict:
"""Return IMAP/SMTP config as a dict.

Resolution order:
1. If account_id given → that specific EmailAccount row.
1. If account_id given → that specific enabled EmailAccount row only.
2. Else → the row with is_default=True (scoped to `owner` when given).
3. Else → the first enabled row (scoped to `owner` when given).
4. Else → legacy flat keys in data/settings.json (kept for envs
where the migration hasn't run yet or accounts table is empty).
5. Else → env vars (SMTP_HOST / IMAP_HOST / ...).
4. For unscoped single-user callers only, legacy flat keys in
data/settings.json (kept for envs where the migration hasn't run yet
or accounts table is empty).
5. For unscoped single-user callers only, env vars
(SMTP_HOST / IMAP_HOST / ...).

Returned dict always has the same shape as before; an `account_id` key is
added so callers can stamp derivative records (email_ai_replies etc.).
Expand Down Expand Up @@ -933,11 +935,13 @@ def _owner_or_matching_legacy_account(query):
row = None
# Fallback path — restrict to this owner's accounts so we don't
# leak another user's default mailbox to an unconfigured user.
if row is None:
# An explicit selection is authoritative: a missing, disabled, or
# invisible row must not silently turn into a different account.
if row is None and not account_id:
q = db.query(_EA).filter(_EA.is_default == True, _EA.enabled == True) # noqa: E712
q = _owner_or_matching_legacy_account(q)
row = q.first()
if row is None:
if row is None and not account_id:
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
q = _owner_or_matching_legacy_account(q)
row = q.order_by(_EA.created_at.asc()).first()
Expand Down Expand Up @@ -972,27 +976,38 @@ def _owner_or_matching_legacy_account(query):
finally:
db.close()
except Exception as e:
# Owner-scoped and explicit-account calls must fail closed. Falling
# through here would replace a transient database error with shared
# settings/environment credentials, or replace an explicit selection
# with an unrelated legacy account.
if owner or account_id:
raise
logger.debug(f"email_accounts lookup failed, falling back to settings.json: {e}")

# Legacy fallback — flat keys in settings.json / env vars
settings = _load_settings()
# These credentials predate owner scoping and carry no safe tenant
# attribution. Preserve them only for trusted single-user callers that did
# not explicitly select an account.
legacy_allowed = not owner and not account_id
settings = _load_settings() if legacy_allowed else {}
environ = os.environ if legacy_allowed else {}
cfg = {
"account_id": resolved_id,
"account_name": "legacy",
"smtp_host": settings.get("smtp_host", os.environ.get("SMTP_HOST", "")),
"smtp_port": int(settings.get("smtp_port", os.environ.get("SMTP_PORT", "465")) or 465),
"smtp_host": settings.get("smtp_host", environ.get("SMTP_HOST", "")),
"smtp_port": int(settings.get("smtp_port", environ.get("SMTP_PORT", "465")) or 465),
"smtp_security": _smtp_security_mode({
"smtp_security": settings.get("smtp_security", os.environ.get("SMTP_SECURITY", "")),
"smtp_port": settings.get("smtp_port", os.environ.get("SMTP_PORT", "465")),
"smtp_security": settings.get("smtp_security", environ.get("SMTP_SECURITY", "")),
"smtp_port": settings.get("smtp_port", environ.get("SMTP_PORT", "465")),
}),
"smtp_user": settings.get("smtp_user", os.environ.get("SMTP_USER", "")),
"smtp_password": settings.get("smtp_password", os.environ.get("SMTP_PASSWORD", "")),
"imap_host": settings.get("imap_host", os.environ.get("IMAP_HOST", "")),
"imap_port": int(settings.get("imap_port", os.environ.get("IMAP_PORT", "993")) or 993),
"imap_user": settings.get("imap_user", os.environ.get("IMAP_USER", "")),
"imap_password": settings.get("imap_password", os.environ.get("IMAP_PASSWORD", "")),
"smtp_user": settings.get("smtp_user", environ.get("SMTP_USER", "")),
"smtp_password": settings.get("smtp_password", environ.get("SMTP_PASSWORD", "")),
"imap_host": settings.get("imap_host", environ.get("IMAP_HOST", "")),
"imap_port": int(settings.get("imap_port", environ.get("IMAP_PORT", "993")) or 993),
"imap_user": settings.get("imap_user", environ.get("IMAP_USER", "")),
"imap_password": settings.get("imap_password", environ.get("IMAP_PASSWORD", "")),
"imap_starttls": settings.get("imap_starttls", True),
"from_address": settings.get("email_from", os.environ.get("EMAIL_FROM", "")),
"from_address": settings.get("email_from", environ.get("EMAIL_FROM", "")),
}
if not (cfg["smtp_host"] and cfg["smtp_user"] and cfg["smtp_password"]):
logger.warning("SMTP not configured — add an Email Account in Settings or set env vars")
Expand Down
53 changes: 47 additions & 6 deletions routes/email_pollers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@

from routes.email_helpers import (
_strip_think, _extract_reply, _apply_email_style_mechanics, _load_settings, _save_settings, _get_email_config,
_account_visible_to_owner,
EmailNotConfiguredError,
_send_smtp_message,
_imap_connect, _imap, _decode_header,
_detect_sent_folder, _detect_spam_folder, _imap_move,
Expand Down Expand Up @@ -100,6 +102,24 @@ def _owner_for_email_account(account_id: str | None) -> str:
return ""


def _account_visible_to_task_owner(account_id: str | None, owner: str) -> bool:
"""Check an explicit task account against the canonical visibility policy."""
if not account_id or not owner:
return True
from core.database import SessionLocal as _SL, EmailAccount as _EA

db = _SL()
try:
row = db.query(_EA).filter(_EA.id == account_id).first()
return (
row is not None
and bool(row.enabled)
and _account_visible_to_owner(row, owner)
)
finally:
db.close()


# ── Routes ──

async def _emit_progress(progress_cb, message: str):
Expand All @@ -119,6 +139,7 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
days_back: int = 1,
account_id: str | None = None,
max_process: int | None = None,
owner: str = "",
progress_cb=None) -> str:
"""One iteration of the email scan. Temporarily flips settings flags
so the existing background-loop logic runs exactly once for the requested ops."""
Expand All @@ -137,6 +158,7 @@ async def _run_auto_summarize_once(do_summary: bool = True, do_reply: bool = Tru
days_back=days_back,
account_id=account_id,
max_process=max_process,
owner=owner,
progress_cb=progress_cb,
)
finally:
Expand Down Expand Up @@ -176,7 +198,7 @@ def _latest_inbox_fallback_uids(conn, reconnect):
return [], reconnect()


async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str:
async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, owner: str = "", progress_cb=None) -> str:
"""Single pass of the auto-summarize/reply scan.

When account_id is None, iterates over every enabled account in
Expand All @@ -194,19 +216,29 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
.order_by(_EA.is_default.desc(), _EA.created_at.asc())
.all()
)
if owner:
rows = [r for r in rows if _account_visible_to_owner(r, owner)]
ids = [r.id for r in rows]
names = {r.id: r.name for r in rows}
finally:
db.close()
except Exception:
if owner:
raise
ids = []
names = {}
if not ids and owner:
raise EmailNotConfiguredError(
"No email accounts are available to this task owner"
)
if len(ids) <= 1:
# Single-account (or zero rows — fallback to legacy settings.json lookup)
# Unscoped zero-account callers retain the single-user legacy
# settings fallback. Owner-scoped callers fail closed above.
return await _auto_summarize_pass_single(
days_back=days_back,
account_id=(ids[0] if ids else None),
max_process=max_process,
owner=owner,
progress_cb=progress_cb,
)
outs = []
Expand All @@ -217,6 +249,7 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
days_back=days_back,
account_id=aid,
max_process=max_process,
owner=owner,
progress_cb=progress_cb,
)
outs.append(f"[{names.get(aid, aid[:8])}] {result}")
Expand All @@ -228,11 +261,12 @@ async def _auto_summarize_pass(days_back: int = 1, account_id: str | None = None
days_back=days_back,
account_id=account_id,
max_process=max_process,
owner=owner,
progress_cb=progress_cb,
)


async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, progress_cb=None) -> str:
async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None = None, max_process: int | None = None, owner: str = "", progress_cb=None) -> str:
"""Single pass of the auto-summarize/reply scan for ONE account.
Reads current settings flags."""
import asyncio
Expand All @@ -249,11 +283,18 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
return "Nothing to do"

# Owner of the account being processed. All calendar + mailbox reads/writes
# below are scoped to this user: the multi-account fan-out runs every user's
# mailbox, so an unscoped pass would disclose/mutate other tenants' data.
# below are scoped to this user. Owner-filtered fan-out must never process
# another tenant's mailbox, or it could disclose/mutate that tenant's data.
# One resolution feeds both the mailbox path (account_owner) and upstream's
# calendar path (_acct_owner, which expects None rather than "").
account_owner = _owner_for_email_account(account_id)
if owner and account_id and not _account_visible_to_task_owner(account_id, owner):
raise PermissionError(
"Email account is not available to this task owner"
)

# A scheduled task owner is authoritative. Only trusted legacy/system
# callers without an owner may derive authority from the account row.
account_owner = owner or _owner_for_email_account(account_id)
_acct_owner = account_owner or None

conn = None
Expand Down
5 changes: 4 additions & 1 deletion src/builtin_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ async def action_summarize_emails(owner: str, **kwargs) -> Tuple[str, bool]:
do_summary=True,
do_reply=False,
account_id=_email_task_account_id(kwargs),
owner=owner,
)
if _result_is_config_error(result):
return result, False
Expand All @@ -559,6 +560,7 @@ async def action_draft_email_replies(owner: str, **kwargs) -> Tuple[str, bool]:
do_reply=True,
account_id=_email_task_account_id(kwargs),
days_back=7,
owner=owner,
progress_cb=kwargs.get("progress_cb"),
)
if _result_is_config_error(result):
Expand Down Expand Up @@ -1067,6 +1069,7 @@ async def action_extract_email_events(owner: str, **kwargs) -> Tuple[str, bool]:
days_back=days_back,
account_id=account_id,
max_process=max_process,
owner=owner,
),
timeout=timeout,
)
Expand Down Expand Up @@ -1353,7 +1356,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
recent_subjects: list[tuple[str, str]] = []
try:
import email as _email
conn = _imap_connect(None)
conn = _imap_connect(None, owner=owner)
try:
conn.select("INBOX", readonly=True)
status, data = conn.search(None, "UNSEEN")
Expand Down
15 changes: 15 additions & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@
# Auth policy
PASSWORD_MIN_LENGTH = 8

# Headers added by reverse proxies and tunnels. A loopback TCP peer carrying
# any of these headers is not a direct localhost request.
PROXY_TUNNEL_HEADERS = (
"cf-connecting-ip",
"cf-connecting-ipv6",
"cf-pseudo-ipv4",
"cf-ray",
"cf-visitor",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-real-ip",
)

# Default parameters
DEFAULT_TEMPERATURE = 1.0
DEFAULT_MAX_TOKENS = 0
Expand Down
Loading