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
6 changes: 6 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ ENV PYTHONUNBUFFERED=1
# Enable web auth mode for Docker (binds OAuth to 0.0.0.0)
ENV WEB_AUTH=true

# Bind on all interfaces inside the container so the published port is
# reachable. Because this is a non-loopback bind, the app requires an API
# token: set API_TOKEN to pin one, otherwise a random token is generated at
# startup and printed to the container logs.
ENV HOST=0.0.0.0

# Install uv for faster package management
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

Expand Down
95 changes: 95 additions & 0 deletions app/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""
API Authentication
-------------------
Guards the API behind a shared secret whenever the server is reachable over
the network.

Threat model: after a user has OAuth'd their Gmail account, the action and
status endpoints can read and destroy mail using the stored credentials. They
must therefore not be callable by anyone who can merely reach the port.

Policy:
- A loopback-only bind (the default) is treated as trusted single-user use and
needs no token.
- Any non-loopback bind requires a token. If ``API_TOKEN`` is not set, one is
generated at startup so the API is never left open by accident.

The same-origin web UI authenticates automatically: the root page sets the
token as a cookie, which the browser then sends with every ``/api`` request.
"""

import logging
import secrets

from fastapi import HTTPException, Request, status

from app.core import settings

logger = logging.getLogger(__name__)

# Token enforced for this process. Resolved once at startup; ``None`` means the
# server is loopback-only and auth is disabled.
_effective_token: str | None = None
# True when the active token was generated rather than supplied via API_TOKEN.
_token_was_generated = False


def resolve_effective_token() -> str | None:
"""Decide whether API auth is required and which token enforces it.

Returns the active token (or ``None`` when auth is disabled).
"""
global _effective_token, _token_was_generated

if settings.api_token:
_effective_token = settings.api_token
_token_was_generated = False
elif not settings.is_loopback_host():
# Reachable over the network but no token configured: refuse to run
# open. Mint a random one so the operator gets a working, secured app.
_effective_token = secrets.token_urlsafe(32)
_token_was_generated = True
else:
_effective_token = None
_token_was_generated = False

return _effective_token


def get_effective_token() -> str | None:
"""Return the token currently enforced (``None`` if auth is disabled)."""
return _effective_token


def token_was_generated() -> bool:
"""Return True if the active token was auto-generated at startup."""
return _token_was_generated


def _extract_token(request: Request) -> str | None:
"""Pull a presented token from the Authorization header or a cookie."""
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
return auth_header[len("Bearer "):].strip()

explicit = request.headers.get("X-API-Token")
if explicit:
return explicit.strip()

return request.cookies.get("api_token")


async def require_api_auth(request: Request) -> None:
"""FastAPI dependency enforcing the API token when one is in effect."""
token = _effective_token
if not token:
# Loopback-only deployment: not network-reachable, no token required.
return

presented = _extract_token(request)
if not presented or not secrets.compare_digest(presented, token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid API token",
headers={"WWW-Authenticate": "Bearer"},
)
16 changes: 16 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ class Settings(BaseSettings):
app_name: str = "Gmail Cleaner"
app_version: str = "1.0.0"
debug: bool = False
host: str = Field(
default="127.0.0.1",
description="Network interface the web server binds to. Defaults to "
"loopback so the API is not reachable from the local network. Set to "
"0.0.0.0 to listen on all interfaces (required inside Docker).",
)
port: int = 8766
oauth_port: int = 8767
oauth_external_port: int | None = Field(
Expand All @@ -28,6 +34,12 @@ class Settings(BaseSettings):
default=False,
description="Enable web-based authentication mode",
)
api_token: str = Field(
default="",
description="Shared secret required to call the API. When the server "
"binds a non-loopback interface and this is left empty, a random token "
"is generated at startup so the API is never exposed without auth.",
)
oauth_host: str = Field(
default="localhost",
description="Custom host for OAuth redirect (e.g., your domain or IP)",
Expand All @@ -44,6 +56,10 @@ def validate_web_auth(cls, v) -> bool:
return normalized in ("true", "1", "yes", "on")
return bool(v)

def is_loopback_host(self) -> bool:
"""Return True when the server only listens on the local machine."""
return self.host.strip().lower() in ("127.0.0.1", "::1", "localhost", "")
Comment on lines +59 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ls -la
rg -n "class .*Settings|is_loopback_host|host\s*:" app/core/config.py || true
sed -n '1,120p' app/core/config.py || true

rg -n "uvicorn\.run|settings\.host|host=" -S . || true
# show likely main entrypoints without dumping huge files
for f in $(fd -t f -a -e py "main.py" .); do
  echo "---- $f ----"
  sed -n '1,200p' "$f" || true
done

# show other places host is referenced (keep it small)
rg -n "Settings\.host|settings\.host|is_loopback_host\(" -S . || true

Repository: Gururagavendra/gmail-cleaner

Length of output: 16494


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' app/api/auth.py
rg -n "is_loopback_host\(" app/api/auth.py
sed -n '1,220p' app/api/auth.py

Repository: Gururagavendra/gmail-cleaner

Length of output: 6591


🌐 Web query:

How does Uvicorn interpret host="" (empty string) passed to uvicorn.run(host=...) bind address?

💡 Result:

Uvicorn does not special-case an empty string host. If you pass host="" to uvicorn.run(host=...), Uvicorn will pass that value down to its socket-binding logic, which ultimately binds the listening socket(s) according to Python/asyncio semantics for host="". Concretely, Uvicorn’s config stores the host value and its bind_socket calls sock.bind((self.host, self.port)) (for the normal TCP case), without checking whether self.host is empty. Therefore, host="" is treated as “bind to all interfaces” by the underlying Python networking/asyncio behavior rather than being converted to 0.0.0.0/:: inside Uvicorn.[1][2] Notes/implications: 1) The “all interfaces” behavior comes from host="" meaning “bind to all interfaces” in the underlying socket/create_server implementation (and is consistent with asyncio’s documented behavior); some asyncio edge cases exist when host is provided as a sequence containing only "" (e.g., binding issues), but that’s about passing [''] to asyncio rather than passing "" directly to Uvicorn.[3] 2) Uvicorn’s own documented default host is 127.0.0.1, not empty string; use host="0.0.0.0" explicitly if you want an unambiguous IPv4-only bind, or host="::" for IPv6.[4][5] If your goal is “listen on all IPv4 interfaces” reliably, prefer uvicorn.run(..., host="0.0.0.0",...); if you want “all interfaces” (v4+v6), host="" (or None) will generally bind accordingly, but the exact address family behavior can depend on the underlying platform/asyncio behavior.[4][1][3]

Citations:


Don’t treat blank HOST ("") as loopback—this can disable API auth while binding to all interfaces.
app/core/config.py’s is_loopback_host() returns True for "", which makes resolve_effective_token() disable auth; then main.py passes settings.host directly into uvicorn.run(host=host). Uvicorn doesn’t special-case host="", so it binds as “all interfaces” at the socket layer—re-exposing the API without a token.

🛡️ Suggested hardening
+import ipaddress
 import os
 from pydantic import Field, field_validator
 from pydantic_settings import BaseSettings, SettingsConfigDict
@@
     def is_loopback_host(self) -> bool:
         """Return True when the server only listens on the local machine."""
-        return self.host.strip().lower() in ("127.0.0.1", "::1", "localhost", "")
+        host = self.host.strip()
+        if not host:
+            return False
+        if host.lower() == "localhost":
+            return True
+        try:
+            return ipaddress.ip_address(host).is_loopback
+        except ValueError:
+            return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/core/config.py` around lines 59 - 61, The is_loopback_host() helper
currently treats an empty host string as loopback which incorrectly disables
auth; update is_loopback_host (in app/core/config.py) to only consider explicit
loopback addresses ("127.0.0.1", "::1", "localhost") and remove "" from that set
so that an empty settings.host is treated as binding to all interfaces (not
loopback). Ensure any logic in resolve_effective_token() that calls
is_loopback_host() continues to rely on that corrected interpretation (so auth
is not disabled when settings.host is empty) and leave main.py which passes
settings.host into uvicorn.run(host=host) unchanged.


credentials_file: str = "credentials.json"
token_file: str = "token.json"

Expand Down
34 changes: 29 additions & 5 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@
import time
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from app.core import settings
from app.api import status_router, actions_router
from app.api.auth import (
get_effective_token,
require_api_auth,
resolve_effective_token,
)

templates = Jinja2Templates(directory="templates")

Expand Down Expand Up @@ -125,6 +130,10 @@ async def lifespan(app: FastAPI):
def create_app() -> FastAPI:
"""Application factory - creates and configures the FastAPI app."""

# Decide whether the API must require a token (non-loopback bind) and, if
# so, which token enforces it. Done before routers are mounted.
resolve_effective_token()

app = FastAPI(
title=settings.app_name,
description="Bulk unsubscribe and email management tool for Gmail",
Expand All @@ -137,19 +146,34 @@ def create_app() -> FastAPI:
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")

# Include API routers
app.include_router(status_router)
app.include_router(actions_router)
# Include API routers — every /api endpoint is guarded by the token check.
# When the server is loopback-only the dependency is a no-op, preserving the
# local single-user experience.
app.include_router(status_router, dependencies=[Depends(require_api_auth)])
app.include_router(actions_router, dependencies=[Depends(require_api_auth)])

# HTML routes
@app.get("/", include_in_schema=False)
async def root(request: Request):
"""Serve the main HTML page."""
return templates.TemplateResponse(
response = templates.TemplateResponse(
request,
"index.html",
{"cache_bust": STARTUP_CACHE_BUST, "version": settings.app_version},
)
# Hand the same-origin browser its API token via a cookie so the UI
# keeps working without any client-side changes. Sent only when auth is
# actually in effect.
token = get_effective_token()
if token:
response.set_cookie(
"api_token",
token,
httponly=True,
samesite="strict",
secure=request.url.scheme == "https",
)
Comment on lines +159 to +175

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Anonymous / requests can mint a valid API session.

This hands the shared token to anyone who can load the root page. Because require_api_auth() in app/api/auth.py accepts api_token cookies, a network-adjacent caller can just GET /, receive the cookie, and then use the protected /api/* routes. That means the new guard is still bypassable from the LAN; it only adds one extra request.

Only set this cookie after a real UI authentication step, or gate / behind the same token when the app is bound non-loopback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/main.py` around lines 159 - 175, The root handler currently sets an
"api_token" cookie for any caller (TemplateResponse block using
get_effective_token), which lets anonymous requests mint sessions; change the
logic so the cookie is only set after a proven UI authentication step or when
serving loopback-only clients: update the code around get_effective_token() and
response.set_cookie(...) to (a) verify the token is tied to an authenticated UI
session (e.g., check session/user state or the same auth flag your UI login
routine sets) before calling response.set_cookie, or (b) if you need a shortcut
for local-only deployments, only set the cookie when request.client.host is
loopback (127.0.0.1 or ::1); do not rely on presence of any token alone and do
not set the cookie for anonymous callers so require_api_auth() cannot be
bypassed.

return response

return app

Expand Down
12 changes: 10 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ services:
# Option 2: Build locally (uncomment below, comment out image above)
# build: .

# Published ports are bound to host loopback only, so the API is not exposed
# to the local network by default. To reach it from other machines, drop the
# "127.0.0.1:" prefix AND set API_TOKEN below (see environment).
ports:
- "8766:8766" # Web UI
- "8767:8767" # OAuth callback
- "127.0.0.1:8766:8766" # Web UI
- "127.0.0.1:8767:8767" # OAuth callback
volumes:
- type: bind
source: ./credentials.json
Expand All @@ -28,6 +31,11 @@ services:
environment:
- WEB_AUTH=true

# API authentication: the container binds 0.0.0.0 internally, so the API
# requires a token. Pin a stable secret here; if left unset a random token
# is generated on each start and printed to the container logs.
# - API_TOKEN=change-me-to-a-long-random-secret

# Uncomment and set OAUTH_HOST if accessing via custom domain or server IP
# - OAUTH_HOST=gmail.example.com

Expand Down
22 changes: 20 additions & 2 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from app.core import settings
from app.main import app
from app.api.auth import get_effective_token, token_was_generated


def main():
Expand All @@ -44,6 +45,22 @@ def main():
print(f"\n{settings.credentials_file} found!")

port = int(os.environ.get("PORT", settings.port))
host = settings.host

# Report the API authentication posture so the operator is never surprised.
token = get_effective_token()
if token:
if token_was_generated():
print("\nAPI authentication: ENABLED (auto-generated token)")
print(f" API token: {token}")
print(" The web UI authenticates automatically in the browser.")
print(" Use this token for direct API/CLI calls:")
print(' Authorization: Bearer <token> (or cookie api_token=<token>)')
print(" Set API_TOKEN in the environment to pin a stable token.")
else:
print("\nAPI authentication: ENABLED (API_TOKEN from environment)")
else:
print(f"\nAPI authentication: disabled (loopback-only bind on {host})")

print(f"\nOpening browser at: http://localhost:{port}")
print(" (Keep this terminal open)")
Expand All @@ -55,8 +72,9 @@ def main():
1.0, lambda: webbrowser.open(f"http://localhost:{port}")
).start()

# Start FastAPI with Uvicorn
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
# Start FastAPI with Uvicorn. Host defaults to loopback (see Settings.host);
# set HOST=0.0.0.0 to listen on all interfaces (required inside Docker).
uvicorn.run(app, host=host, port=port, log_level="warning")


if __name__ == "__main__":
Expand Down