Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/trapfall-ingest/src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,8 @@ pub fn extract_sentry_key(auth_header: &str) -> Option<String> {
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())
})
}

Expand Down Expand Up @@ -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";
Expand Down
44 changes: 30 additions & 14 deletions crates/trapfalld/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,20 +383,27 @@ async fn rotate_dsn(
Ok(Json(updated))
}

#[derive(Deserialize, Default)]
struct IngestAuthQuery {
sentry_key: Option<String>,
}

async fn ingest_envelope(
State(state): State<AppState>,
Path(project_id): Path<String>,
Query(query): Query<IngestAuthQuery>,
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())
Expand All @@ -406,37 +413,38 @@ 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
let project = match store.resolve_project(&project_id).await {
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();
}
}

Expand All @@ -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();
}
};

Expand Down Expand Up @@ -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": "<event_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;
Expand All @@ -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 ──────────────────────────────────────────────
Expand Down
58 changes: 58 additions & 0 deletions crates/trapfalld/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()));
Expand Down
Loading