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
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 57 additions & 7 deletions crates/trapfalld/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,17 +237,31 @@ pub async fn change_password(

// ── Middleware ──────────────────────────────────────────────────────────

/// Exact public routes (no authentication required).
const PUBLIC_PATHS: &[&str] =
&["/health", "/metrics", "/api/0/config", "/api/0/setup", "/api/0/auth/login", "/api/0/auth/logout"];

/// Whether `path` is public. Uses exact matching (never suffix/substring), so
/// project slugs like `setup` or `envelope` cannot bypass authentication.
/// The ingest route `/api/{project_id}/envelope/` is matched by path segments.
pub(crate) fn is_public_path(path: &str) -> bool {
if PUBLIC_PATHS.contains(&path) {
return true;
}
// Ingest: exactly api / {id} / envelope, with an optional trailing slash.
let trimmed = path.strip_suffix('/').unwrap_or(path);
let mut segs = trimmed.split('/');
matches!(
(segs.next(), segs.next(), segs.next(), segs.next(), segs.next(), segs.next()),
(Some(""), Some("api"), Some(id), Some("envelope"), None, None) if !id.is_empty()
)
}

/// Auth middleware — extracts session cookie, validates, injects user.
pub async fn require_auth(State(state): State<AppState>, mut req: Request<axum::body::Body>, next: Next) -> Response {
// Public routes that don't require authentication
let path = req.uri().path();
let is_public = path.ends_with("/setup")
|| path.ends_with("/auth/login")
|| path.ends_with("/auth/logout")
|| path == "/health"
|| path == "/metrics"
|| path == "/api/0/config"
|| path.contains("/envelope/");
let is_public = is_public_path(path);
if is_public {
return next.run(req).await;
}
Expand Down Expand Up @@ -310,3 +324,39 @@ pub fn extract_session_token(headers: &axum::http::HeaderMap) -> Option<String>
}
None
}

#[cfg(test)]
mod public_path_tests {
use super::is_public_path;

#[test]
fn exact_public_routes() {
for p in ["/health", "/metrics", "/api/0/config", "/api/0/setup", "/api/0/auth/login", "/api/0/auth/logout"] {
assert!(is_public_path(p), "{p}");
}
}

#[test]
fn ingest_route_matched_by_segments() {
assert!(is_public_path("/api/42/envelope/"));
assert!(is_public_path("/api/42/envelope"));
assert!(!is_public_path("/api/0/projects/envelope/issues"));
assert!(!is_public_path("/api/42/envelope/extra"));
assert!(!is_public_path("/api//envelope/"));
assert!(!is_public_path("/x/api/42/envelope/"));
}

#[test]
fn slug_lookalikes_are_not_public() {
for p in [
"/api/0/projects/setup",
"/api/0/projects/x/setup",
"/api/0/projects/auth/login",
"/api/0/projects/x/auth/logout",
"/api/0/projects/envelope/issues",
"/api/0/projects/x/health",
] {
assert!(!is_public_path(p), "{p}");
}
}
}
55 changes: 49 additions & 6 deletions crates/trapfalld/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,44 @@ fn default_slowest_limit() -> i64 {
5
}

/// Slugs that collide with route segments or public paths.
const RESERVED_SLUGS: &[&str] = &[
"setup",
"envelope",
"login",
"logout",
"auth",
"config",
"health",
"metrics",
"api",
"admin",
"me",
"ws",
"projects",
"issues",
"events",
"rules",
"attachments",
"archive",
"search",
];

/// Reject empty, path-like or reserved project slugs.
fn validate_project_slug(slug: &str) -> Result<(), (StatusCode, String)> {
let bad = |m: String| Err((StatusCode::BAD_REQUEST, m));
if slug.trim().is_empty() {
return bad("Project slug must not be empty".into());
}
if slug.contains('/') {
return bad("Project slug must not contain '/'".into());
}
if RESERVED_SLUGS.contains(&slug.to_ascii_lowercase().as_str()) {
return bad(format!("Project slug '{slug}' is reserved; choose a different slug"));
}
Ok(())
}

/// Build the Axum router.
pub fn router(state: AppState) -> Router {
// All API routes flat — no .nest() to avoid Axum 0.8 routing quirks.
Expand Down Expand Up @@ -217,9 +255,10 @@ async fn create_project(
_user: AuthenticatedUser,
headers: axum::http::HeaderMap,
Json(req): Json<CreateProjectRequest>,
) -> Result<(StatusCode, Json<trapfall_proto::Project>), StatusCode> {
) -> Result<(StatusCode, Json<trapfall_proto::Project>), (StatusCode, String)> {
let store = state.store.clone();
let slug = req.slug.unwrap_or_else(|| req.name.to_lowercase().replace(' ', "-"));
validate_project_slug(&slug)?;
// Prefer configured `public_url` (TRAPFALL_PUBLIC_URL) for DSN generation.
// Fall back to the request Host header so local dev keeps working without
// extra config (e.g. user accesses via http://localhost:9090).
Expand All @@ -229,7 +268,7 @@ async fn create_project(
.unwrap_or_else(|| headers.get("host").and_then(|v| v.to_str().ok()).unwrap_or("localhost:9090").to_string());
let project = store.create_project_with_host(&slug, &req.name, &host).await.map_err(|e| {
tracing::warn!("Create project failed: {e}");
StatusCode::CONFLICT
(StatusCode::CONFLICT, "Project could not be created (slug may already exist)".to_string())
})?;
Ok((StatusCode::CREATED, Json(project)))
}
Expand All @@ -255,15 +294,19 @@ async fn update_project(
State(state): State<AppState>,
Path(slug): Path<String>,
Json(req): Json<UpdateProjectRequest>,
) -> Result<Json<trapfall_proto::Project>, StatusCode> {
) -> Result<Json<trapfall_proto::Project>, (StatusCode, String)> {
validate_project_slug(&slug)?;
let store = state.store.clone();
let project = store
.get_project_by_slug(&slug)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
if let Some(name) = req.name {
let updated = store.update_project(&project.id, &name).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let updated = store
.update_project(&project.id, &name)
.await
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string()))?;
Ok(Json(updated))
} else {
Ok(Json(project))
Expand Down
51 changes: 51 additions & 0 deletions crates/trapfalld/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -818,3 +818,54 @@ async fn parse_transaction_envelope() {
assert_eq!(result.transactions.len(), 1, "expected 1 transaction parsed, got {}", result.transactions.len());
assert_eq!(result.transactions[0].transaction, "GET /api/health");
}

// ── Auth bypass regression (#329) ──────────────────────────────────────

async fn status_of(app: &Router, method: &str, uri: &str, cookie: Option<&str>, body: &str) -> StatusCode {
let mut b = Request::builder().method(method).uri(uri).header("content-type", "application/json");
if let Some(c) = cookie {
b = b.header("cookie", c);
}
app.clone().oneshot(b.body(Body::from(body.to_string())).unwrap()).await.unwrap().status()
}

#[tokio::test]
// Note: unmatched paths (e.g. /api/0/projects/x/setup) hit the SPA fallback, outside
// route_layer; `is_public_path` unit tests cover those lookalikes.
async fn slug_lookalike_paths_require_auth() {
let app = router(make_state(test_store().await, RateLimiter::default()));
for uri in [
"/api/0/projects/setup",
"/api/0/projects/envelope/issues",
"/api/0/projects/setup/rules",
"/api/0/projects/envelope/search",
] {
assert_eq!(status_of(&app, "GET", uri, None, "").await, StatusCode::UNAUTHORIZED, "{uri}");
}
}

#[tokio::test]
async fn real_public_routes_stay_reachable() {
let app = router(make_state(test_store().await, RateLimiter::default()));
for uri in ["/health", "/api/0/config", "/api/0/setup"] {
assert_eq!(status_of(&app, "GET", uri, None, "").await, StatusCode::OK, "{uri}");
}
// Login/logout are reachable (not blocked by auth middleware with 401 "Not authenticated").
assert_ne!(status_of(&app, "POST", "/api/0/auth/logout", None, "").await, StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn reserved_slug_create_returns_400() {
let app = router(make_state(test_store().await, RateLimiter::default()));
let cookie = setup_and_get_cookie(&app).await;
for slug in ["setup", "envelope", "login", "Auth"] {
let body = format!(r#"{{"name":"P","slug":"{slug}"}}"#);
assert_eq!(
status_of(&app, "POST", "/api/0/projects", Some(&cookie), &body).await,
StatusCode::BAD_REQUEST,
"{slug}"
);
}
let ok = status_of(&app, "POST", "/api/0/projects", Some(&cookie), r#"{"name":"Fine","slug":"fine"}"#).await;
assert_eq!(ok, StatusCode::CREATED);
}
22 changes: 12 additions & 10 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading