|
5 | 5 | import base64 |
6 | 6 | import json |
7 | 7 | import os |
| 8 | +import socket |
8 | 9 | from typing import Any, Dict |
9 | 10 |
|
10 | 11 | import aiohttp |
| 12 | +from aiohttp.abc import AbstractResolver |
11 | 13 | from app.repositories.job_repository import JobRepository |
12 | 14 | from app.services.knowledge.kb_orchestrator import KBOrchestrator |
13 | 15 | from app.services.state_machine import JobStateMachine |
|
19 | 21 | from shared.core.state_machine.states import JobStatus |
20 | 22 | from shared.models.schemas.oss_event import OSSEvent |
21 | 23 | from shared.models.schemas.s3_event import S3Event |
| 24 | +from shared.services.webhook.validator import validate_webhook_url_async |
| 25 | +from shared.utils.url_security import SafePublicHTTPURL |
22 | 26 |
|
23 | 27 | router = APIRouter(tags=["Internal"]) |
24 | 28 |
|
| 29 | +SNS_SUBSCRIPTION_TIMEOUT_SECONDS = 10 |
| 30 | + |
25 | 31 |
|
26 | 32 | def verify_sns_signature(request_body: bytes, signature: str, message: str) -> bool: |
27 | 33 | """ |
@@ -207,22 +213,7 @@ async def handle_sns_event(body: bytes): |
207 | 213 | if subscribe_url: |
208 | 214 | logger.info(f"SNS subscription confirmation URL: {subscribe_url}") |
209 | 215 | # Visit the URL to confirm the subscription. |
210 | | - try: |
211 | | - async with aiohttp.ClientSession() as session: |
212 | | - async with session.get(subscribe_url) as response: |
213 | | - if response.status == 200: |
214 | | - logger.info("SNS subscription confirmed successfully") |
215 | | - return {"message": "SNS subscription confirmed"} |
216 | | - else: |
217 | | - logger.error( |
218 | | - f"SNS subscription confirmation failed, status={response.status}" |
219 | | - ) |
220 | | - return { |
221 | | - "message": "SNS subscription confirmation failed" |
222 | | - } |
223 | | - except Exception as e: |
224 | | - logger.error(f"Failed to reach the SNS confirmation URL: {e}") |
225 | | - return {"message": "SNS subscription confirmation failed"} |
| 216 | + return await confirm_sns_subscription(subscribe_url) |
226 | 217 | else: |
227 | 218 | logger.warning( |
228 | 219 | "SNS subscription confirmation did not include SubscribeURL" |
@@ -274,6 +265,80 @@ async def handle_sns_event(body: bytes): |
274 | 265 | raise |
275 | 266 |
|
276 | 267 |
|
| 268 | +async def confirm_sns_subscription(subscribe_url: str) -> dict[str, str]: |
| 269 | + """Confirm an SNS subscription after SSRF validation and IP pinning.""" |
| 270 | + validation = await validate_webhook_url_async(subscribe_url) |
| 271 | + if not validation.is_valid: |
| 272 | + logger.warning( |
| 273 | + f"SNS subscription confirmation URL failed validation: {validation.error_message}" |
| 274 | + ) |
| 275 | + return {"message": "SNS subscription confirmation failed"} |
| 276 | + |
| 277 | + if not validation.validated_ip: |
| 278 | + logger.warning("SNS subscription confirmation URL validation returned no IP") |
| 279 | + return {"message": "SNS subscription confirmation failed"} |
| 280 | + |
| 281 | + try: |
| 282 | + validated_subscribe_url = SafePublicHTTPURL(subscribe_url) |
| 283 | + connector = aiohttp.TCPConnector( |
| 284 | + resolver=_PinnedSNSResolver(validation.validated_ip), |
| 285 | + ) |
| 286 | + timeout = aiohttp.ClientTimeout(total=SNS_SUBSCRIPTION_TIMEOUT_SECONDS) |
| 287 | + async with aiohttp.ClientSession( |
| 288 | + connector=connector, |
| 289 | + timeout=timeout, |
| 290 | + ) as session: |
| 291 | + async with session.get( |
| 292 | + validated_subscribe_url, |
| 293 | + allow_redirects=False, |
| 294 | + ) as response: |
| 295 | + if response.status == 200: |
| 296 | + logger.info("SNS subscription confirmed successfully") |
| 297 | + return {"message": "SNS subscription confirmed"} |
| 298 | + |
| 299 | + if 300 <= response.status < 400: |
| 300 | + logger.warning( |
| 301 | + f"SNS subscription confirmation redirect blocked, status={response.status}" |
| 302 | + ) |
| 303 | + else: |
| 304 | + logger.error( |
| 305 | + f"SNS subscription confirmation failed, status={response.status}" |
| 306 | + ) |
| 307 | + return {"message": "SNS subscription confirmation failed"} |
| 308 | + except Exception as e: |
| 309 | + logger.error(f"Failed to reach the SNS confirmation URL: {e}") |
| 310 | + return {"message": "SNS subscription confirmation failed"} |
| 311 | + |
| 312 | + |
| 313 | +class _PinnedSNSResolver(AbstractResolver): |
| 314 | + """Resolver that pins SNS confirmation to a pre-validated public IP.""" |
| 315 | + |
| 316 | + def __init__(self, pinned_ip: str) -> None: |
| 317 | + self.pinned_ip = pinned_ip |
| 318 | + |
| 319 | + async def resolve( |
| 320 | + self, |
| 321 | + host: str, |
| 322 | + port: int = 0, |
| 323 | + family: int = socket.AF_INET, |
| 324 | + ) -> list[dict[str, Any]]: |
| 325 | + parsed_ip = self.pinned_ip |
| 326 | + pinned_family = socket.AF_INET6 if ":" in parsed_ip else socket.AF_INET |
| 327 | + return [ |
| 328 | + { |
| 329 | + "hostname": host, |
| 330 | + "host": parsed_ip, |
| 331 | + "port": port, |
| 332 | + "family": pinned_family, |
| 333 | + "proto": 0, |
| 334 | + "flags": socket.AI_NUMERICHOST, |
| 335 | + } |
| 336 | + ] |
| 337 | + |
| 338 | + async def close(self) -> None: |
| 339 | + pass |
| 340 | + |
| 341 | + |
277 | 342 | async def handle_minio_event(body: bytes, auth_token: str): |
278 | 343 | """ |
279 | 344 | Handle a MinIO webhook event. |
|
0 commit comments