All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m22s
Critical: Add ownership checks to all order routes (IDOR fix). High: Redis rate limiting on auth endpoints, HMAC-signed internal service calls replacing header-presence-only checks, nh3 HTML sanitization on ghost_sync and product import, internal auth on market API endpoints, SHA-256 hashed OAuth grant/code tokens. Medium: SECRET_KEY production guard, AP signature enforcement, is_admin param removal, cart_sid validation, SSRF protection on remote actor fetch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
601 lines
20 KiB
Python
601 lines
20 KiB
Python
"""Reusable AP inbox handlers for all apps.
|
|
|
|
Extracted from federation/bp/actors/routes.py so that every app's
|
|
shared AP blueprint can process Follow, Undo, Accept, Create, etc.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from shared.models.federation import (
|
|
ActorProfile, APInboxItem, APInteraction, APNotification,
|
|
APRemotePost, APActivity, RemoteActor,
|
|
)
|
|
from shared.services.registry import services
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
AP_CONTENT_TYPE = "application/activity+json"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _is_safe_url(url: str) -> bool:
|
|
"""Reject URLs pointing to private/internal IPs to prevent SSRF."""
|
|
from urllib.parse import urlparse
|
|
import ipaddress
|
|
|
|
parsed = urlparse(url)
|
|
|
|
# Require HTTPS
|
|
if parsed.scheme != "https":
|
|
return False
|
|
|
|
hostname = parsed.hostname
|
|
if not hostname:
|
|
return False
|
|
|
|
# Block obvious internal hostnames
|
|
if hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0"):
|
|
return False
|
|
|
|
try:
|
|
addr = ipaddress.ip_address(hostname)
|
|
if addr.is_private or addr.is_loopback or addr.is_reserved or addr.is_link_local:
|
|
return False
|
|
except ValueError:
|
|
# Not an IP literal — hostname is fine (DNS resolution handled by httpx)
|
|
# Block common internal DNS patterns
|
|
if hostname.endswith(".internal") or hostname.endswith(".local"):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
async def fetch_remote_actor(actor_url: str) -> dict | None:
|
|
"""Fetch a remote actor's JSON-LD profile."""
|
|
if not _is_safe_url(actor_url):
|
|
log.warning("Blocked SSRF attempt: %s", actor_url)
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(
|
|
actor_url,
|
|
headers={"Accept": AP_CONTENT_TYPE},
|
|
)
|
|
if resp.status_code == 200:
|
|
return resp.json()
|
|
except Exception:
|
|
log.exception("Failed to fetch remote actor: %s", actor_url)
|
|
return None
|
|
|
|
|
|
async def send_accept(
|
|
actor: ActorProfile,
|
|
follow_activity: dict,
|
|
follower_inbox: str,
|
|
domain: str,
|
|
) -> None:
|
|
"""Send an Accept activity back to the follower."""
|
|
from shared.utils.http_signatures import sign_request
|
|
from urllib.parse import urlparse
|
|
|
|
username = actor.preferred_username
|
|
actor_url = f"https://{domain}/users/{username}"
|
|
|
|
accept_id = f"{actor_url}/activities/{uuid.uuid4()}"
|
|
accept = {
|
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
"id": accept_id,
|
|
"type": "Accept",
|
|
"actor": actor_url,
|
|
"object": follow_activity,
|
|
}
|
|
|
|
body_bytes = json.dumps(accept).encode()
|
|
key_id = f"{actor_url}#main-key"
|
|
|
|
parsed = urlparse(follower_inbox)
|
|
headers = sign_request(
|
|
private_key_pem=actor.private_key_pem,
|
|
key_id=key_id,
|
|
method="POST",
|
|
path=parsed.path,
|
|
host=parsed.netloc,
|
|
body=body_bytes,
|
|
)
|
|
headers["Content-Type"] = AP_CONTENT_TYPE
|
|
|
|
log.info("Accept payload → %s: %s", follower_inbox, json.dumps(accept)[:500])
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.post(
|
|
follower_inbox,
|
|
content=body_bytes,
|
|
headers=headers,
|
|
)
|
|
log.info("Accept → %s: %d %s", follower_inbox, resp.status_code, resp.text[:200])
|
|
except Exception:
|
|
log.exception("Failed to send Accept to %s", follower_inbox)
|
|
|
|
|
|
async def backfill_follower(
|
|
session: AsyncSession,
|
|
actor: ActorProfile,
|
|
follower_inbox: str,
|
|
domain: str,
|
|
origin_app: str | None = None,
|
|
) -> None:
|
|
"""Deliver recent *current* Create activities to a new follower's inbox.
|
|
|
|
Skips Creates whose source was later Deleted, and uses the latest
|
|
Update data when available (so the follower sees the current version).
|
|
"""
|
|
from shared.events.handlers.ap_delivery_handler import (
|
|
_build_activity_json, _deliver_to_inbox,
|
|
)
|
|
|
|
filters = [
|
|
APActivity.actor_profile_id == actor.id,
|
|
APActivity.is_local == True, # noqa: E712
|
|
APActivity.activity_type == "Create",
|
|
APActivity.source_type.isnot(None),
|
|
APActivity.source_id.isnot(None),
|
|
]
|
|
if origin_app is not None:
|
|
filters.append(APActivity.origin_app == origin_app)
|
|
|
|
creates = (
|
|
await session.execute(
|
|
select(APActivity).where(*filters)
|
|
.order_by(APActivity.published.desc())
|
|
.limit(40)
|
|
)
|
|
).scalars().all()
|
|
|
|
if not creates:
|
|
return
|
|
|
|
# Collect source keys that have been Deleted
|
|
source_keys = {(c.source_type, c.source_id) for c in creates}
|
|
deleted_keys: set[tuple[str | None, int | None]] = set()
|
|
if source_keys:
|
|
deletes = (
|
|
await session.execute(
|
|
select(APActivity.source_type, APActivity.source_id).where(
|
|
APActivity.actor_profile_id == actor.id,
|
|
APActivity.activity_type == "Delete",
|
|
APActivity.is_local == True, # noqa: E712
|
|
)
|
|
)
|
|
).all()
|
|
deleted_keys = {(d[0], d[1]) for d in deletes}
|
|
|
|
# For sources with Updates, grab the latest Update's object_data
|
|
updated_data: dict[tuple[str | None, int | None], dict] = {}
|
|
if source_keys:
|
|
updates = (
|
|
await session.execute(
|
|
select(APActivity).where(
|
|
APActivity.actor_profile_id == actor.id,
|
|
APActivity.activity_type == "Update",
|
|
APActivity.is_local == True, # noqa: E712
|
|
).order_by(APActivity.published.desc())
|
|
)
|
|
).scalars().all()
|
|
for u in updates:
|
|
key = (u.source_type, u.source_id)
|
|
if key not in updated_data and key in source_keys:
|
|
updated_data[key] = u.object_data or {}
|
|
|
|
# Filter to current, non-deleted Creates (limit 20)
|
|
activities = []
|
|
for c in creates:
|
|
key = (c.source_type, c.source_id)
|
|
if key in deleted_keys:
|
|
continue
|
|
# Apply latest Update data if available
|
|
if key in updated_data:
|
|
c.object_data = updated_data[key]
|
|
activities.append(c)
|
|
if len(activities) >= 20:
|
|
break
|
|
|
|
if not activities:
|
|
return
|
|
|
|
log.info(
|
|
"Backfilling %d posts to %s for @%s",
|
|
len(activities), follower_inbox, actor.preferred_username,
|
|
)
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
for activity in reversed(activities): # oldest first
|
|
activity_json = _build_activity_json(activity, actor, domain)
|
|
await _deliver_to_inbox(client, follower_inbox, activity_json, actor, domain)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inbox activity handlers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def handle_follow(
|
|
session: AsyncSession,
|
|
actor_row: ActorProfile,
|
|
body: dict,
|
|
from_actor_url: str,
|
|
domain: str,
|
|
app_domain: str = "federation",
|
|
) -> None:
|
|
"""Process a Follow activity: add follower, send Accept, backfill."""
|
|
remote_actor = await fetch_remote_actor(from_actor_url)
|
|
if not remote_actor:
|
|
log.warning("Could not fetch remote actor for Follow: %s", from_actor_url)
|
|
return
|
|
|
|
follower_inbox = remote_actor.get("inbox")
|
|
if not follower_inbox:
|
|
log.warning("Remote actor has no inbox: %s", from_actor_url)
|
|
return
|
|
|
|
remote_username = remote_actor.get("preferredUsername", "")
|
|
from urllib.parse import urlparse
|
|
remote_domain = urlparse(from_actor_url).netloc
|
|
follower_acct = f"{remote_username}@{remote_domain}" if remote_username else from_actor_url
|
|
|
|
pub_key = (remote_actor.get("publicKey") or {}).get("publicKeyPem")
|
|
|
|
await services.federation.add_follower(
|
|
session,
|
|
actor_row.preferred_username,
|
|
follower_acct=follower_acct,
|
|
follower_inbox=follower_inbox,
|
|
follower_actor_url=from_actor_url,
|
|
follower_public_key=pub_key,
|
|
app_domain=app_domain,
|
|
)
|
|
|
|
log.info(
|
|
"New follower: %s → @%s (app_domain=%s)",
|
|
follower_acct, actor_row.preferred_username, app_domain,
|
|
)
|
|
|
|
# Notification
|
|
ra = (
|
|
await session.execute(
|
|
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
|
)
|
|
).scalar_one_or_none()
|
|
if not ra:
|
|
ra_dto = await services.federation.get_or_fetch_remote_actor(session, from_actor_url)
|
|
if ra_dto:
|
|
ra = (await session.execute(
|
|
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
|
)).scalar_one_or_none()
|
|
|
|
if ra:
|
|
notif = APNotification(
|
|
actor_profile_id=actor_row.id,
|
|
notification_type="follow",
|
|
from_remote_actor_id=ra.id,
|
|
app_domain=app_domain,
|
|
)
|
|
session.add(notif)
|
|
|
|
# Send Accept
|
|
await send_accept(actor_row, body, follower_inbox, domain)
|
|
|
|
# Backfill: deliver recent posts (filtered by origin_app for per-app follows)
|
|
backfill_origin = app_domain if app_domain != "federation" else None
|
|
await backfill_follower(session, actor_row, follower_inbox, domain, origin_app=backfill_origin)
|
|
|
|
|
|
async def handle_undo(
|
|
session: AsyncSession,
|
|
actor_row: ActorProfile,
|
|
body: dict,
|
|
from_actor_url: str,
|
|
app_domain: str = "federation",
|
|
) -> None:
|
|
"""Process an Undo activity (typically Undo Follow)."""
|
|
inner = body.get("object")
|
|
if not inner:
|
|
return
|
|
|
|
inner_type = inner.get("type") if isinstance(inner, dict) else None
|
|
if inner_type == "Follow":
|
|
from urllib.parse import urlparse
|
|
remote_domain = urlparse(from_actor_url).netloc
|
|
remote_actor = await fetch_remote_actor(from_actor_url)
|
|
remote_username = ""
|
|
if remote_actor:
|
|
remote_username = remote_actor.get("preferredUsername", "")
|
|
follower_acct = f"{remote_username}@{remote_domain}" if remote_username else from_actor_url
|
|
|
|
removed = await services.federation.remove_follower(
|
|
session, actor_row.preferred_username, follower_acct,
|
|
app_domain=app_domain,
|
|
)
|
|
if removed:
|
|
log.info("Unfollowed: %s → @%s (app_domain=%s)", follower_acct, actor_row.preferred_username, app_domain)
|
|
else:
|
|
log.debug("Undo Follow: follower not found: %s", follower_acct)
|
|
else:
|
|
log.debug("Undo for %s — not handled", inner_type)
|
|
|
|
|
|
async def handle_accept(
|
|
session: AsyncSession,
|
|
actor_row: ActorProfile,
|
|
body: dict,
|
|
from_actor_url: str,
|
|
) -> None:
|
|
"""Process Accept activity — update outbound follow state."""
|
|
inner = body.get("object")
|
|
if not inner:
|
|
return
|
|
|
|
inner_type = inner.get("type") if isinstance(inner, dict) else None
|
|
if inner_type == "Follow":
|
|
await services.federation.accept_follow_response(
|
|
session, actor_row.preferred_username, from_actor_url,
|
|
)
|
|
log.info("Follow accepted by %s for @%s", from_actor_url, actor_row.preferred_username)
|
|
|
|
|
|
async def handle_create(
|
|
session: AsyncSession,
|
|
actor_row: ActorProfile,
|
|
body: dict,
|
|
from_actor_url: str,
|
|
federation_domain: str,
|
|
) -> None:
|
|
"""Process Create(Note/Article) — ingest remote post."""
|
|
obj = body.get("object")
|
|
if not obj or not isinstance(obj, dict):
|
|
return
|
|
|
|
obj_type = obj.get("type", "")
|
|
if obj_type not in ("Note", "Article"):
|
|
log.debug("Create with type %s — skipping", obj_type)
|
|
return
|
|
|
|
remote = await services.federation.get_or_fetch_remote_actor(session, from_actor_url)
|
|
if not remote:
|
|
log.warning("Could not resolve remote actor for Create: %s", from_actor_url)
|
|
return
|
|
|
|
await services.federation.ingest_remote_post(session, remote.id, body, obj)
|
|
log.info("Ingested %s from %s", obj_type, from_actor_url)
|
|
|
|
# Mention notification
|
|
tags = obj.get("tag", [])
|
|
if isinstance(tags, list):
|
|
for tag in tags:
|
|
if not isinstance(tag, dict):
|
|
continue
|
|
if tag.get("type") != "Mention":
|
|
continue
|
|
href = tag.get("href", "")
|
|
if f"https://{federation_domain}/users/" in href:
|
|
mentioned_username = href.rsplit("/", 1)[-1]
|
|
mentioned = await services.federation.get_actor_by_username(
|
|
session, mentioned_username,
|
|
)
|
|
if mentioned:
|
|
rp = (await session.execute(
|
|
select(APRemotePost).where(
|
|
APRemotePost.object_id == obj.get("id")
|
|
)
|
|
)).scalar_one_or_none()
|
|
|
|
ra = (await session.execute(
|
|
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
|
)).scalar_one_or_none()
|
|
|
|
notif = APNotification(
|
|
actor_profile_id=mentioned.id,
|
|
notification_type="mention",
|
|
from_remote_actor_id=ra.id if ra else None,
|
|
target_remote_post_id=rp.id if rp else None,
|
|
)
|
|
session.add(notif)
|
|
|
|
# Reply notification
|
|
in_reply_to = obj.get("inReplyTo")
|
|
if in_reply_to and f"https://{federation_domain}/users/" in str(in_reply_to):
|
|
local_activity = (await session.execute(
|
|
select(APActivity).where(
|
|
APActivity.activity_id == in_reply_to,
|
|
)
|
|
)).scalar_one_or_none()
|
|
if local_activity:
|
|
ra = (await session.execute(
|
|
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
|
)).scalar_one_or_none()
|
|
rp = (await session.execute(
|
|
select(APRemotePost).where(
|
|
APRemotePost.object_id == obj.get("id")
|
|
)
|
|
)).scalar_one_or_none()
|
|
|
|
notif = APNotification(
|
|
actor_profile_id=local_activity.actor_profile_id,
|
|
notification_type="reply",
|
|
from_remote_actor_id=ra.id if ra else None,
|
|
target_remote_post_id=rp.id if rp else None,
|
|
)
|
|
session.add(notif)
|
|
|
|
|
|
async def handle_update(
|
|
session: AsyncSession,
|
|
actor_row: ActorProfile,
|
|
body: dict,
|
|
from_actor_url: str,
|
|
) -> None:
|
|
"""Process Update — re-ingest remote post."""
|
|
obj = body.get("object")
|
|
if not obj or not isinstance(obj, dict):
|
|
return
|
|
obj_type = obj.get("type", "")
|
|
if obj_type in ("Note", "Article"):
|
|
remote = await services.federation.get_or_fetch_remote_actor(session, from_actor_url)
|
|
if remote:
|
|
await services.federation.ingest_remote_post(session, remote.id, body, obj)
|
|
log.info("Updated %s from %s", obj_type, from_actor_url)
|
|
|
|
|
|
async def handle_delete(
|
|
session: AsyncSession,
|
|
actor_row: ActorProfile,
|
|
body: dict,
|
|
from_actor_url: str,
|
|
) -> None:
|
|
"""Process Delete — remove remote post."""
|
|
obj = body.get("object")
|
|
if isinstance(obj, str):
|
|
object_id = obj
|
|
elif isinstance(obj, dict):
|
|
object_id = obj.get("id", "")
|
|
else:
|
|
return
|
|
if object_id:
|
|
await services.federation.delete_remote_post(session, object_id)
|
|
log.info("Deleted remote post %s from %s", object_id, from_actor_url)
|
|
|
|
|
|
async def handle_like(
|
|
session: AsyncSession,
|
|
actor_row: ActorProfile,
|
|
body: dict,
|
|
from_actor_url: str,
|
|
) -> None:
|
|
"""Process incoming Like — record interaction + notify."""
|
|
object_id = body.get("object", "")
|
|
if isinstance(object_id, dict):
|
|
object_id = object_id.get("id", "")
|
|
if not object_id:
|
|
return
|
|
|
|
remote = await services.federation.get_or_fetch_remote_actor(session, from_actor_url)
|
|
if not remote:
|
|
return
|
|
|
|
ra = (await session.execute(
|
|
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
|
)).scalar_one_or_none()
|
|
|
|
target = (await session.execute(
|
|
select(APActivity).where(APActivity.activity_id == object_id)
|
|
)).scalar_one_or_none()
|
|
|
|
if not target:
|
|
log.info("Like from %s for %s (target not found locally)", from_actor_url, object_id)
|
|
return
|
|
|
|
interaction = APInteraction(
|
|
remote_actor_id=ra.id if ra else None,
|
|
post_type="local",
|
|
post_id=target.id,
|
|
interaction_type="like",
|
|
activity_id=body.get("id"),
|
|
)
|
|
session.add(interaction)
|
|
|
|
notif = APNotification(
|
|
actor_profile_id=target.actor_profile_id,
|
|
notification_type="like",
|
|
from_remote_actor_id=ra.id if ra else None,
|
|
target_activity_id=target.id,
|
|
)
|
|
session.add(notif)
|
|
log.info("Like from %s on activity %s", from_actor_url, object_id)
|
|
|
|
|
|
async def handle_announce(
|
|
session: AsyncSession,
|
|
actor_row: ActorProfile,
|
|
body: dict,
|
|
from_actor_url: str,
|
|
) -> None:
|
|
"""Process incoming Announce (boost) — record interaction + notify."""
|
|
object_id = body.get("object", "")
|
|
if isinstance(object_id, dict):
|
|
object_id = object_id.get("id", "")
|
|
if not object_id:
|
|
return
|
|
|
|
remote = await services.federation.get_or_fetch_remote_actor(session, from_actor_url)
|
|
if not remote:
|
|
return
|
|
|
|
ra = (await session.execute(
|
|
select(RemoteActor).where(RemoteActor.actor_url == from_actor_url)
|
|
)).scalar_one_or_none()
|
|
|
|
target = (await session.execute(
|
|
select(APActivity).where(APActivity.activity_id == object_id)
|
|
)).scalar_one_or_none()
|
|
|
|
if not target:
|
|
log.info("Announce from %s for %s (target not found locally)", from_actor_url, object_id)
|
|
return
|
|
|
|
interaction = APInteraction(
|
|
remote_actor_id=ra.id if ra else None,
|
|
post_type="local",
|
|
post_id=target.id,
|
|
interaction_type="boost",
|
|
activity_id=body.get("id"),
|
|
)
|
|
session.add(interaction)
|
|
|
|
notif = APNotification(
|
|
actor_profile_id=target.actor_profile_id,
|
|
notification_type="boost",
|
|
from_remote_actor_id=ra.id if ra else None,
|
|
target_activity_id=target.id,
|
|
)
|
|
session.add(notif)
|
|
log.info("Announce from %s on activity %s", from_actor_url, object_id)
|
|
|
|
|
|
async def dispatch_inbox_activity(
|
|
session: AsyncSession,
|
|
actor_row: ActorProfile,
|
|
body: dict,
|
|
from_actor_url: str,
|
|
domain: str,
|
|
app_domain: str = "federation",
|
|
) -> None:
|
|
"""Route an inbox activity to the correct handler."""
|
|
activity_type = body.get("type", "")
|
|
|
|
if activity_type == "Follow":
|
|
await handle_follow(session, actor_row, body, from_actor_url, domain, app_domain=app_domain)
|
|
elif activity_type == "Undo":
|
|
await handle_undo(session, actor_row, body, from_actor_url, app_domain=app_domain)
|
|
elif activity_type == "Accept":
|
|
await handle_accept(session, actor_row, body, from_actor_url)
|
|
elif activity_type == "Create":
|
|
await handle_create(session, actor_row, body, from_actor_url, domain)
|
|
elif activity_type == "Update":
|
|
await handle_update(session, actor_row, body, from_actor_url)
|
|
elif activity_type == "Delete":
|
|
await handle_delete(session, actor_row, body, from_actor_url)
|
|
elif activity_type == "Like":
|
|
await handle_like(session, actor_row, body, from_actor_url)
|
|
elif activity_type == "Announce":
|
|
await handle_announce(session, actor_row, body, from_actor_url)
|