Skip to content

Commit 46acab9

Browse files
authored
Merge pull request #320 from spoo-me/feat/anonymous-metadata
feat: anonymous metadata, expand, and domain-intel endpoints
2 parents 62c3814 + d68c39e commit 46acab9

24 files changed

Lines changed: 1687 additions & 93 deletions

config.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,29 @@ class MetaTagsSettings(BaseSettings):
401401
fetch_user_agent: str = "spoo.me-og-validator/1.0 (+https://spoo.me)"
402402

403403

404+
class WebRiskSettings(BaseSettings):
405+
"""Google Web Risk lookups for the URL expander's safety verdict.
406+
407+
Env vars are prefixed ``WEB_RISK_`` so a generic ``API_KEY`` set
408+
elsewhere in the deploy environment can't configure this. Without a
409+
key the check silently doesn't run and the verdict is absent — the
410+
tool never shows a fabricated one.
411+
"""
412+
413+
model_config = SettingsConfigDict(
414+
env_file=".env",
415+
extra="ignore",
416+
env_prefix="WEB_RISK_",
417+
)
418+
419+
api_key: str = ""
420+
timeout_seconds: float = Field(default=4.0, gt=0)
421+
422+
@property
423+
def enabled(self) -> bool:
424+
return bool(self.api_key)
425+
426+
404427
class WebhookSettings(BaseSettings):
405428
"""Webhooks system — real-time event deliveries to subscriber URLs.
406429
@@ -603,6 +626,7 @@ def _password_max_length_sane(cls, v: int) -> int:
603626
edge_cache: EdgeCacheSettings | None = None
604627
r2: R2StorageSettings | None = None
605628
meta_tags: MetaTagsSettings | None = None
629+
web_risk: WebRiskSettings | None = None
606630
webhooks: WebhookSettings | None = None
607631

608632
@model_validator(mode="after")
@@ -643,6 +667,8 @@ def _populate_sub_configs_and_secret(self) -> AppSettings:
643667
self.r2 = R2StorageSettings()
644668
if self.meta_tags is None:
645669
self.meta_tags = MetaTagsSettings()
670+
if self.web_risk is None:
671+
self.web_risk = WebRiskSettings()
646672
if self.webhooks is None:
647673
self.webhooks = WebhookSettings()
648674
if self.webhooks.enabled and not self.secret_key:

dependencies/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
CredentialSvc,
6363
CustomDomainSvc,
6464
DeviceAuthSvc,
65+
DomainIntelSvc,
6566
ExportSvc,
6667
FeatureFlagSvc,
6768
OAuthSvc,
@@ -70,6 +71,7 @@
7071
ProfilePictureSvc,
7172
ReportIntakeSvc,
7273
StatsSvc,
74+
UrlExpandSvc,
7375
UrlSvc,
7476
UserRepo,
7577
VerificationSvc,
@@ -122,6 +124,7 @@
122124
"CurrentUser",
123125
"CustomDomainSvc",
124126
"DeviceAuthSvc",
127+
"DomainIntelSvc",
125128
"ExportSvc",
126129
"FeatureFlagSvc",
127130
"GeoIP",
@@ -139,6 +142,7 @@
139142
"ReportIntakeSvc",
140143
"Settings",
141144
"StatsSvc",
145+
"UrlExpandSvc",
142146
"UrlSvc",
143147
"UserRepo",
144148
"VerificationSvc",

dependencies/services.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from services.click.sinks import ClickEventSink
2828
from services.contact_service import ContactService
2929
from services.custom_domain_service import CustomDomainService
30+
from services.domain_intel_service import DomainIntelService
3031
from services.export.service import ExportService
3132
from services.feature_flag_service import FeatureFlagService
3233
from services.oauth_service import OAuthService
@@ -36,6 +37,7 @@
3637
from services.public_stats_service import PublicStatsService
3738
from services.report_intake_service import ReportIntakeService
3839
from services.stats_service import StatsService
40+
from services.url_expand_service import UrlExpandService
3941
from services.url_service import UrlService
4042
from services.webhooks.service import WebhookService
4143

@@ -136,6 +138,14 @@ def get_public_preview_service(request: Request) -> PublicPreviewService:
136138
return request.app.state.public_preview_service
137139

138140

141+
def get_url_expand_service(request: Request) -> UrlExpandService:
142+
return request.app.state.url_expand_service
143+
144+
145+
def get_domain_intel_service(request: Request) -> DomainIntelService:
146+
return request.app.state.domain_intel_service
147+
148+
139149
def get_report_intake_service(request: Request) -> ReportIntakeService:
140150
return request.app.state.report_intake_service
141151

@@ -169,5 +179,7 @@ def get_webhook_service(request: Request) -> WebhookService:
169179
FeatureFlagSvc = Annotated[FeatureFlagService, Depends(get_feature_flag_service)]
170180
CustomDomainSvc = Annotated[CustomDomainService, Depends(get_custom_domain_service)]
171181
PublicPreviewSvc = Annotated[PublicPreviewService, Depends(get_public_preview_service)]
182+
UrlExpandSvc = Annotated[UrlExpandService, Depends(get_url_expand_service)]
183+
DomainIntelSvc = Annotated[DomainIntelService, Depends(get_domain_intel_service)]
172184
ReportIntakeSvc = Annotated[ReportIntakeService, Depends(get_report_intake_service)]
173185
WebhookSvc = Annotated[WebhookService, Depends(get_webhook_service)]

dependencies/wiring.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
from services.click.sinks import InlineSink, RedisStreamSink
5656
from services.contact_service import ContactService
5757
from services.custom_domain_service import CustomDomainService
58+
from services.domain_intel_service import DomainIntelService
5859
from services.edge_cache.og_writethrough import OgEdgeWritethrough
5960
from services.events.sinks import (
6061
InlineDomainEventSink,
@@ -76,6 +77,7 @@
7677
from services.stats_service import StatsService
7778
from services.tenant_resolver import CachedMongoTenantResolver
7879
from services.token_factory import TokenFactory
80+
from services.url_expand_service import UrlExpandService
7981
from services.url_service import UrlService
8082
from services.webhooks import (
8183
DeliveryExecutor,
@@ -329,6 +331,18 @@ def wire_services(app: FastAPI, settings: AppSettings, redis_client) -> None:
329331
system_default_domain=settings.system_default_domain,
330332
)
331333
app.state.public_preview_service = PublicPreviewService(public_link_resolver)
334+
app.state.url_expand_service = UrlExpandService(
335+
blocked_url_repo,
336+
MetaFetchCache(redis_client, prefix="url_expand"),
337+
regex_timeout=settings.blocked_url_regex_timeout,
338+
user_agent=settings.meta_tags.fetch_user_agent,
339+
http_client=http_client,
340+
web_risk_api_key=settings.web_risk.api_key,
341+
)
342+
app.state.domain_intel_service = DomainIntelService(
343+
MetaFetchCache(redis_client, prefix="domain_intel", ttl_seconds=86_400),
344+
http_client,
345+
)
332346
app.state.public_stats_service = PublicStatsService(
333347
public_link_resolver,
334348
app.state.stats_service,

infrastructure/cache/meta_fetch_cache.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@ def __init__(
2525
*,
2626
ttl_seconds: int = 3600,
2727
negative_ttl_seconds: int = 300,
28+
prefix: str = "meta_fetch",
2829
) -> None:
2930
self._redis = redis_client
3031
self._ttl = ttl_seconds
3132
self._negative_ttl = negative_ttl_seconds
33+
self._prefix = prefix
3234

33-
@staticmethod
34-
def _key(url: str) -> str:
35-
return f"meta_fetch:{hashlib.sha256(url.encode()).hexdigest()}"
35+
def _key(self, url: str) -> str:
36+
return f"{self._prefix}:{hashlib.sha256(url.encode()).hexdigest()}"
3637

3738
async def get(self, url: str) -> dict | None:
3839
if self._redis is None:

infrastructure/safe_fetch.py

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def _is_public(ip: str) -> bool:
7878
return addr.is_global and not addr.is_multicast
7979

8080

81-
async def _resolve_public_ip(host: str) -> str:
81+
async def resolve_public_ip(host: str) -> str:
8282
"""Resolve *host* and return one address, rejecting any private result."""
8383
# A literal IP address skips DNS entirely.
8484
try:
@@ -153,7 +153,7 @@ async def fetch_public(
153153
parsed = httpx.URL(url)
154154
if parsed.scheme != "https":
155155
raise FetchHardError("non-https URL")
156-
ip = await _resolve_public_ip(parsed.host)
156+
ip = await resolve_public_ip(parsed.host)
157157

158158
# Pin the connection to the validated IP; keep name-based TLS via
159159
# sni_hostname and the Host header.
@@ -282,7 +282,7 @@ async def post_public(
282282
parsed = httpx.URL(url)
283283
if parsed.scheme != "https":
284284
return PostResult(None, "non-https URL", None)
285-
ip = await _resolve_public_ip(parsed.host)
285+
ip = await resolve_public_ip(parsed.host)
286286
except (FetchHardError, FetchTransientError, httpx.InvalidURL) as exc:
287287
# Transient DNS failures included: delivery outcomes are DATA for
288288
# the retry ladder, never exceptions that skip attempt recording.
@@ -328,4 +328,82 @@ async def validate_public_https_url(url: str) -> None:
328328
parsed = httpx.URL(url)
329329
if parsed.scheme != "https":
330330
raise FetchHardError("URL must be https")
331-
await _resolve_public_ip(parsed.host)
331+
await resolve_public_ip(parsed.host)
332+
333+
334+
@dataclass(frozen=True)
335+
class ChainHop:
336+
url: str
337+
status: int | None # None: this hop never answered
338+
339+
340+
@dataclass(frozen=True)
341+
class ExpandedChain:
342+
hops: list[ChainHop]
343+
final_url: str
344+
final_status: int | None
345+
truncated: bool
346+
347+
348+
async def expand_public(
349+
url: str,
350+
*,
351+
timeout: float = 5.0,
352+
max_redirects: int = 10,
353+
user_agent: str = DEFAULT_USER_AGENT,
354+
) -> ExpandedChain:
355+
"""Follow *url*'s redirect chain hop by hop and report every stop.
356+
357+
Unlike fetch_public this never reads bodies and allows plain-http
358+
hops — real shortener chains bounce through http trackers, and only
359+
headers ever ride the wire here. Every hop still gets the full SSRF
360+
guard: public-DNS resolution, IP pinning, no auto-redirects.
361+
"""
362+
hops: list[ChainHop] = []
363+
for _hop in range(max_redirects + 1):
364+
parsed = httpx.URL(url)
365+
if parsed.scheme not in ("http", "https"):
366+
raise FetchHardError("unsupported scheme")
367+
try:
368+
ip = await resolve_public_ip(parsed.host)
369+
except FetchHardError:
370+
if not hops:
371+
raise
372+
# Mid-chain dead end (NXDOMAIN, private space): report the
373+
# chain up to it rather than erasing what we learned.
374+
hops.append(ChainHop(url, None))
375+
return ExpandedChain(hops, url, None, False)
376+
pinned = parsed.copy_with(host=_bracket(ip))
377+
async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
378+
request = client.build_request(
379+
"GET",
380+
pinned,
381+
headers={
382+
"Host": parsed.host,
383+
"User-Agent": user_agent,
384+
"Accept-Encoding": "identity",
385+
},
386+
extensions=(
387+
{"sni_hostname": parsed.host} if parsed.scheme == "https" else {}
388+
),
389+
)
390+
try:
391+
resp = await client.send(request, stream=True)
392+
except (httpx.TimeoutException, httpx.TransportError) as exc:
393+
if not hops:
394+
raise FetchTransientError(str(exc)) from exc
395+
hops.append(ChainHop(url, None))
396+
return ExpandedChain(hops, url, None, False)
397+
try:
398+
status = resp.status_code
399+
hops.append(ChainHop(str(parsed), status))
400+
if status in _REDIRECT_STATUSES:
401+
location = resp.headers.get("location")
402+
if not location:
403+
return ExpandedChain(hops, str(parsed), status, False)
404+
url = str(parsed.join(location))
405+
continue
406+
return ExpandedChain(hops, str(parsed), status, False)
407+
finally:
408+
await resp.aclose()
409+
return ExpandedChain(hops, url, None, True)

middleware/rate_limiter.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,14 @@ class Limits:
105105
# guessing is theater at 256 bits — this is belt-and-suspenders.
106106
URL_CLAIM = "30 per minute; 500 per day"
107107

108-
# Destination metadata fetch — outbound fetches on our dime; tight.
109-
METADATA_FETCH = "20 per minute; 500 per day"
108+
# Destination metadata fetch — outbound fetches on our dime, but the
109+
# ~1h result cache means only novel URLs actually fetch.
110+
METADATA_FETCH = "60 per minute; 2000 per day"
111+
112+
# Anonymous callers on GET /metadata (the link preview checker tool).
113+
# Generous on purpose — it's a free tool — but still per-IP bounded:
114+
# results cache ~1h, so only novel URLs cost an outbound fetch.
115+
METADATA_ANON = "15 per minute; 300 per day"
110116

111117
# Custom domains. Create counts FAILED attempts too (slowapi increments
112118
# at route entry), so the budget must absorb typos, blocked TLDs, and

0 commit comments

Comments
 (0)