diff --git a/crates/trapfall-ingest/src/envelope.rs b/crates/trapfall-ingest/src/envelope.rs index 85bc62c..92c5670 100644 --- a/crates/trapfall-ingest/src/envelope.rs +++ b/crates/trapfall-ingest/src/envelope.rs @@ -276,7 +276,8 @@ pub fn extract_sentry_key(auth_header: &str) -> Option { let header = auth_header.strip_prefix("Sentry ").unwrap_or(auth_header); header.split(',').find_map(|part| { let trimmed = part.trim(); - trimmed.strip_prefix("sentry_key=").map(str::to_string) + let (k, v) = trimmed.split_once('=')?; + (k.trim() == "sentry_key").then(|| v.trim().to_string()).filter(|v| !v.is_empty()) }) } @@ -414,6 +415,14 @@ mod tests { assert_eq!(key, "abc123"); } + #[test] + fn extract_sentry_key_tolerates_whitespace() { + assert_eq!(extract_sentry_key("Sentry sentry_key=abc,sentry_version=7").as_deref(), Some("abc")); + assert_eq!(extract_sentry_key("Sentry sentry_version=7,sentry_key=abc").as_deref(), Some("abc")); + assert_eq!(extract_sentry_key("Sentry sentry_version=7 , sentry_key = abc ").as_deref(), Some("abc")); + assert!(extract_sentry_key("Sentry sentry_key=").is_none()); + } + #[test] fn extract_sentry_key_plain_key() { let header = "sentry_key=xyz789"; diff --git a/crates/trapfalld/src/server.rs b/crates/trapfalld/src/server.rs index eb19c22..1781fd3 100644 --- a/crates/trapfalld/src/server.rs +++ b/crates/trapfalld/src/server.rs @@ -383,20 +383,27 @@ async fn rotate_dsn( Ok(Json(updated)) } +#[derive(Deserialize, Default)] +struct IngestAuthQuery { + sentry_key: Option, +} + async fn ingest_envelope( State(state): State, Path(project_id): Path, + Query(query): Query, headers: HeaderMap, body: axum::body::Bytes, -) -> StatusCode { +) -> axum::response::Response { tracing::info!("Ingest request: project_id={project_id} body_len={}", body.len()); if !state.rate_limiter.try_consume(&project_id, 1.0) { - return StatusCode::TOO_MANY_REQUESTS; + return StatusCode::TOO_MANY_REQUESTS.into_response(); } // Validate DSN key from Authorization header let store = state.store.clone(); - // Extract DSN key: try X-Sentry-Auth header first, then Authorization Bearer + // Extract DSN key: X-Sentry-Auth header, then Authorization Bearer, then + // `sentry_key` query param (JS SDKs). Header takes precedence. let dsn_key = headers .get("x-sentry-auth") .and_then(|v| v.to_str().ok()) @@ -406,10 +413,11 @@ async fn ingest_envelope( .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ").map(|s| s.trim().to_string()).filter(|s| !s.is_empty())) - }); + }) + .or_else(|| query.sentry_key.map(|k| k.trim().to_string()).filter(|k| !k.is_empty())); let dsn_key = match dsn_key { Some(k) => k, - None => return StatusCode::UNAUTHORIZED, + None => return StatusCode::UNAUTHORIZED.into_response(), }; // Verify DSN key matches project @@ -417,26 +425,26 @@ async fn ingest_envelope( Ok(Some(p)) => p, Ok(None) => { tracing::warn!("Project not found by id: {project_id}"); - return StatusCode::NOT_FOUND; + return StatusCode::NOT_FOUND.into_response(); } Err(e) => { tracing::error!("DB error looking up project: {e}"); - return StatusCode::INTERNAL_SERVER_ERROR; + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; match store.get_project_by_dsn_key(&dsn_key).await { Ok(Some(p)) if p.id == project.id => {} Ok(Some(p)) => { tracing::warn!("DSN key mismatch: expected project {} got {}", project.id, p.id); - return StatusCode::UNAUTHORIZED; + return StatusCode::UNAUTHORIZED.into_response(); } Ok(None) => { tracing::warn!("No project found for DSN key"); - return StatusCode::UNAUTHORIZED; + return StatusCode::UNAUTHORIZED.into_response(); } Err(e) => { tracing::error!("DB error checking DSN key: {e}"); - return StatusCode::INTERNAL_SERVER_ERROR; + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } } @@ -452,7 +460,7 @@ async fn ingest_envelope( } Err(e) => { tracing::warn!("Failed to parse envelope: {e}"); - return StatusCode::BAD_REQUEST; + return StatusCode::BAD_REQUEST.into_response(); } }; @@ -516,8 +524,16 @@ async fn ingest_envelope( tracing::info!("Skipping {} attachment(s): no events to associate with", parsed.attachments.len()); } + // Sentry SDKs expect `{"id": ""}` in the response body. + let response_id = parsed + .events + .first() + .map(|e| e.event_id.clone()) + .or_else(|| parsed.transactions.first().map(|t| t.event_id.clone())) + .unwrap_or_default(); + if parsed.events.is_empty() && parsed.transactions.is_empty() { - return StatusCode::OK; + return Json(serde_json::json!({ "id": response_id })).into_response(); } let mut accepted = 0; @@ -534,13 +550,13 @@ async fn ingest_envelope( Ok(()) => accepted += 1, Err(e) => { tracing::warn!("Ingest channel full or closed: {e}"); - return StatusCode::SERVICE_UNAVAILABLE; + return StatusCode::SERVICE_UNAVAILABLE.into_response(); } } } tracing::info!("Accepted {accepted} events for project {project_id}"); - StatusCode::OK + Json(serde_json::json!({ "id": response_id })).into_response() } // ── Issue / Event Handlers ────────────────────────────────────────────── diff --git a/crates/trapfalld/tests/integration.rs b/crates/trapfalld/tests/integration.rs index fedb2a8..da707fb 100644 --- a/crates/trapfalld/tests/integration.rs +++ b/crates/trapfalld/tests/integration.rs @@ -856,6 +856,25 @@ async fn parse_transaction_envelope() { assert_eq!(result.transactions[0].transaction, "GET /api/health"); } +async fn post_envelope(app: &Router, uri: String, header: Option<(&str, &str)>) -> axum::response::Response { + let mut b = Request::builder().method("POST").uri(uri).header("content-type", "application/octet-stream"); + if let Some((k, v)) = header { + b = b.header(k, v); + } + app.clone().oneshot(b.body(Body::from(make_envelope_body("Error", "test"))).unwrap()).await.unwrap() +} + +#[tokio::test] +async fn ingest_accepts_x_sentry_auth_header() { + let store = test_store().await; + let project_id = seed_project(&store).await; + let app = router(make_state(store, RateLimiter::default())); + for h in ["Sentry sentry_key=abc123, sentry_version=7", "Sentry sentry_key=abc123,sentry_version=7"] { + let resp = post_envelope(&app, format!("/api/{project_id}/envelope/"), Some(("x-sentry-auth", h))).await; + assert_eq!(resp.status(), StatusCode::OK); + } +} + // ── Auth bypass regression (#329) ────────────────────────────────────── async fn status_of(app: &Router, method: &str, uri: &str, cookie: Option<&str>, body: &str) -> StatusCode { @@ -881,6 +900,45 @@ async fn slug_lookalike_paths_require_auth() { } } +#[tokio::test] +async fn ingest_accepts_sentry_key_query_param() { + let store = test_store().await; + let project_id = seed_project(&store).await; + let app = router(make_state(store, RateLimiter::default())); + let resp = + post_envelope(&app, format!("/api/{project_id}/envelope/?sentry_key=abc123&sentry_version=7"), None).await; + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert!(json["id"].is_string()); +} + +#[tokio::test] +async fn ingest_query_param_invalid_or_empty_key_returns_401() { + let store = test_store().await; + let project_id = seed_project(&store).await; + let app = router(make_state(store, RateLimiter::default())); + for q in ["sentry_key=wrong", "sentry_key=", "sentry_version=7"] { + let resp = post_envelope(&app, format!("/api/{project_id}/envelope/?{q}"), None).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "query {q}"); + } +} + +#[tokio::test] +async fn ingest_header_takes_precedence_over_query_param() { + let store = test_store().await; + let project_id = seed_project(&store).await; + let app = router(make_state(store, RateLimiter::default())); + // Invalid header key + valid query key -> header wins -> 401 + let resp = post_envelope( + &app, + format!("/api/{project_id}/envelope/?sentry_key=abc123"), + Some(("x-sentry-auth", "Sentry sentry_key=wrong, sentry_version=7")), + ) + .await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + #[tokio::test] async fn real_public_routes_stay_reachable() { let app = router(make_state(test_store().await, RateLimiter::default()));