11"""Resolve Host header → TenantInfo on every request.
22
33Lands `request.state.tenant` for downstream handlers. Redirect route
4- reads it to scope the URL lookup to the right tenant. Unknown public
5- hosts get an HTML 404; internal/loopback hosts pass through with
6- tenant=None so /health doesn't break.
4+ reads it to scope the URL lookup to the right tenant.
5+
6+ Routing policy for custom tenants is a strict allowlist:
7+ - `GET /<alias>` and `POST /<alias>/password` → redirect router
8+ - `GET /favicon.ico` → static router (generic favicon)
9+ - `GET /robots.txt` → inline `Disallow: /`
10+ - Everything else → 404
11+
12+ Operator surface (`/api/*`, `/dashboard/*`, `/auth/*`, `/oauth/*`, `/health`)
13+ and brand pages (`/about`, `/contact`, `/api-docs`, `/<alias>+`, `/report`)
14+ all 404 on custom tenants. Per-domain routing config (`root_redirect`,
15+ `not_found_redirect`, `custom_robots_txt`) lands in PR4.5.
16+
17+ System-default tenant behaves exactly as before — full app surface.
18+
19+ Every custom-tenant response carries `X-Robots-Tag: noindex, nofollow,
20+ noarchive` (post-handler). Combined with the disallow-all robots.txt, this
21+ keeps short links out of search indexes. Preview crawlers (Twitter/Slack/
22+ Discord/etc.) ignore these signals and continue to unfurl correctly.
723"""
824
925from __future__ import annotations
1026
27+ import re
1128from urllib .parse import urlsplit
1229
1330from fastapi import Request
1431from starlette .middleware .base import BaseHTTPMiddleware
15- from starlette .responses import HTMLResponse , Response
32+ from starlette .responses import HTMLResponse , PlainTextResponse , Response
1633
1734from infrastructure .logging import get_logger
1835from services .tenant_resolver .protocol import TenantInfo , TenantResolver
2138
2239_LOOPBACK_HOSTS = frozenset ({"localhost" , "127.0.0.1" , "::1" , "app" })
2340
41+ _NOT_FOUND_BODY = (
42+ "<!doctype html><html><head><title>404 — Not Found</title></head>"
43+ "<body><h1>404</h1><p>URL not found.</p></body></html>"
44+ )
45+
46+ _CUSTOM_TENANT_ROBOTS_BODY = "User-agent: *\n Disallow: /\n "
47+ _NOINDEX_HEADER = "noindex, nofollow, noarchive"
48+
49+ # Allowed exact paths on custom tenants (besides the alias pattern).
50+ _ALLOWED_EXACT_PATHS = frozenset ({"/favicon.ico" })
51+
52+ # Reserved path prefixes — match these *before* the alias allowlist so
53+ # operator surface (`/dashboard/*`, `/api/*`, …) and brand pages
54+ # (`/about`, `/contact`, …) cannot be exposed through the alias namespace.
55+ # A path is reserved if it equals one of these strings exactly or starts
56+ # with one followed by `/`. Bare alias collisions (e.g. a customer creating
57+ # an alias literally named `dashboard`) are sacrificed for tenant isolation.
58+ _RESERVED_PREFIXES = (
59+ "/api" ,
60+ "/dashboard" ,
61+ "/auth" ,
62+ "/oauth" ,
63+ "/health" ,
64+ "/report" ,
65+ "/about" ,
66+ "/contact" ,
67+ "/privacy" ,
68+ "/api-docs" ,
69+ "/api-reference" ,
70+ )
71+
72+ # Alias paths allowed on custom tenants. Match `/<alias>` and
73+ # `/<alias>/password` only. Alias body is `[A-Za-z0-9_-]{3,16}` per
74+ # `shared.validators.validate_alias` plus the URL-safe slice of the emoji
75+ # range used in v2. Stats suffix (`+`) is intentionally NOT matched so
76+ # `/<alias>+` falls through to 404 — analytics surface stays on spoo.me.
77+ #
78+ # Emoji ranges: Misc Symbols & Pictographs (1F300-1F5FF), Emoticons
79+ # (1F600-1F64F), Transport & Map (1F680-1F6FF), Supplemental Symbols
80+ # (1F900-1F9FF), Extended-A (1FA70-1FAFF), and percent-encoded forms.
81+ _ALIAS_PATTERN = re .compile (
82+ r"^/"
83+ r"(?:[A-Za-z0-9_\-]"
84+ r"|[\U0001F300-\U0001F5FF\U0001F600-\U0001F64F"
85+ r"\U0001F680-\U0001F6FF\U0001F900-\U0001F9FF\U0001FA70-\U0001FAFF]"
86+ r"|%[0-9A-Fa-f]{2})+"
87+ r"(?:/password)?$"
88+ )
89+
2490
2591def _normalise_host (raw : str ) -> str :
2692 """Lowercased, dot-stripped, port-stripped host. RFC 3986-safe for
@@ -34,8 +100,40 @@ def _normalise_host(raw: str) -> str:
34100 return (parsed or "" ).rstrip ("." ).lower ()
35101
36102
103+ def _is_reserved_path (path : str ) -> bool :
104+ for prefix in _RESERVED_PREFIXES :
105+ if path == prefix or path .startswith (prefix + "/" ):
106+ return True
107+ return False
108+
109+
110+ def _is_allowed_on_custom_tenant (path : str , method : str ) -> bool :
111+ """Custom-tenant allowlist gate. Path-and-method check so disallowed
112+ methods on allowed paths (e.g. ``DELETE /<alias>``) return our 404
113+ instead of Starlette's 405, preserving the strict deny policy."""
114+ if path == "/" :
115+ return False
116+ if _is_reserved_path (path ):
117+ return False
118+ if path in _ALLOWED_EXACT_PATHS :
119+ # Static assets (favicon) are read-only.
120+ return method in {"GET" , "HEAD" }
121+ if _ALIAS_PATTERN .match (path ):
122+ # `/<alias>/password` is a form POST; everything else under the alias
123+ # namespace (the redirect) is GET/HEAD only.
124+ if path .endswith ("/password" ):
125+ return method == "POST"
126+ return method in {"GET" , "HEAD" }
127+ return False
128+
129+
37130class TenantMiddleware (BaseHTTPMiddleware ):
38- """Populates request.state.tenant from the request Host header."""
131+ """Populates request.state.tenant from the request Host header.
132+
133+ On custom tenants additionally enforces the allowlist routing policy
134+ documented at the top of this module and stamps the noindex header on
135+ every response.
136+ """
39137
40138 async def dispatch (self , request : Request , call_next ) -> Response :
41139 resolver : TenantResolver | None = getattr (
@@ -51,15 +149,41 @@ async def dispatch(self, request: Request, call_next) -> Response:
51149
52150 tenant : TenantInfo | None = await resolver .resolve (host )
53151 request .state .tenant = tenant
152+
54153 if tenant is None :
55154 log .info ("tenant_unknown_host" , host = host )
56- # Static HTML 404 — browser-friendly, no template deps, no
57- # tenancy details leaked.
58155 return HTMLResponse (_NOT_FOUND_BODY , status_code = 404 )
59- return await call_next (request )
60156
157+ if tenant .is_system_default :
158+ return await call_next (request )
61159
62- _NOT_FOUND_BODY = (
63- "<!doctype html><html><head><title>404 — Not Found</title></head>"
64- "<body><h1>404</h1><p>URL not found.</p></body></html>"
65- )
160+ path = request .url .path
161+
162+ if path == "/robots.txt" :
163+ if request .method not in {"GET" , "HEAD" }:
164+ return HTMLResponse (
165+ _NOT_FOUND_BODY ,
166+ status_code = 404 ,
167+ headers = {"X-Robots-Tag" : _NOINDEX_HEADER },
168+ )
169+ return PlainTextResponse (
170+ _CUSTOM_TENANT_ROBOTS_BODY ,
171+ headers = {"X-Robots-Tag" : _NOINDEX_HEADER },
172+ )
173+
174+ if not _is_allowed_on_custom_tenant (path , request .method ):
175+ log .info (
176+ "tenant_path_denied" ,
177+ host = host ,
178+ path = path ,
179+ method = request .method ,
180+ )
181+ return HTMLResponse (
182+ _NOT_FOUND_BODY ,
183+ status_code = 404 ,
184+ headers = {"X-Robots-Tag" : _NOINDEX_HEADER },
185+ )
186+
187+ response = await call_next (request )
188+ response .headers ["X-Robots-Tag" ] = _NOINDEX_HEADER
189+ return response
0 commit comments