max / makenotwork
20 files changed,
+989 insertions,
-621 deletions
| @@ -7,9 +7,12 @@ | |||
| 7 | 7 | ||
| 8 | 8 | use axum::{ | |
| 9 | 9 | extract::{FromRequestParts, Request}, | |
| 10 | + | handler::Handler, | |
| 10 | 11 | http::{header::HeaderMap, request::Parts, StatusCode}, | |
| 11 | - | middleware::Next, | |
| 12 | + | middleware::{from_fn, Next}, | |
| 12 | 13 | response::{IntoResponse, Response}, | |
| 14 | + | routing::{delete, patch, post, put, MethodRouter}, | |
| 15 | + | Router, | |
| 13 | 16 | }; | |
| 14 | 17 | use rand::RngCore; | |
| 15 | 18 | use tower_sessions::Session; | |
| @@ -129,50 +132,292 @@ where | |||
| 129 | 132 | } | |
| 130 | 133 | } | |
| 131 | 134 | ||
| 132 | - | /// Middleware to validate CSRF tokens on state-changing requests | |
| 133 | - | /// | |
| 134 | - | /// Validates POST, PUT, PATCH, DELETE requests (except for excluded paths). | |
| 135 | - | /// Checks the `X-CSRF-Token` header first (used by HTMX), then falls back to | |
| 136 | - | /// parsing the `_csrf` field from form-encoded request bodies (used by vanilla | |
| 137 | - | /// HTML forms). | |
| 138 | - | pub async fn csrf_middleware(request: Request, next: Next) -> Response { | |
| 139 | - | let method = request.method().clone(); | |
| 140 | - | ||
| 141 | - | // Only validate state-changing methods | |
| 142 | - | if !["POST", "PUT", "PATCH", "DELETE"].contains(&method.as_str()) { | |
| 143 | - | return next.run(request).await; | |
| 135 | + | /// Per-route CSRF posture, declared at the route registration site via the | |
| 136 | + | /// `{post,put,patch,delete}_csrf*` helpers. Carried in the helper signatures | |
| 137 | + | /// so the choice (and its reason) lives next to the route, not in a sibling | |
| 138 | + | /// allowlist file. Not stored at runtime — the reason strings exist for | |
| 139 | + | /// source-level documentation and grep, while the structural guarantee comes | |
| 140 | + | /// from `CsrfRouter` only accepting `PostureMethodRouter` values. | |
| 141 | + | #[derive(Clone, Copy, Debug)] | |
| 142 | + | pub enum CsrfPosture { | |
| 143 | + | /// Standard validation layer runs (header or form `_csrf`). | |
| 144 | + | Auto, | |
| 145 | + | /// Handler validates the token itself and proves it with the | |
| 146 | + | /// `CsrfManuallyValidated` witness. Reason documents why the | |
| 147 | + | /// standard layer can't apply (e.g. "multipart upload"). | |
| 148 | + | Manual(&'static str), | |
| 149 | + | /// No CSRF check applies. Reason documents why (webhook signature, | |
| 150 | + | /// signed link, pre-auth, etc.). | |
| 151 | + | Skip(&'static str), | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// Witness type proving a handler ran the standard CSRF validation path. | |
| 155 | + | /// The only public way to obtain one is `validate_token_consuming`, which | |
| 156 | + | /// performs the check. The private field with a private-module constructor | |
| 157 | + | /// makes the value un-fabricable from outside this module — `Default`, | |
| 158 | + | /// struct-literal, and `Clone` are all impossible for callers. | |
| 159 | + | pub use sealed::CsrfManuallyValidated; | |
| 160 | + | ||
| 161 | + | mod sealed { | |
| 162 | + | pub struct CsrfManuallyValidated { | |
| 163 | + | _private: (), | |
| 144 | 164 | } | |
| 145 | 165 | ||
| 146 | - | let path = request.uri().path().to_string(); | |
| 147 | - | ||
| 148 | - | // Exempt paths: | |
| 149 | - | // - Webhooks use their own signature verification | |
| 150 | - | // - Auth endpoints establish sessions (pre-auth, no CSRF needed) | |
| 151 | - | // - Stripe checkout is a vanilla form POST that redirects to Stripe's hosted page; | |
| 152 | - | // SameSite=Lax cookies prevent cross-site form submissions, AuthUser is required, | |
| 153 | - | // and no state mutation occurs until Stripe's webhook confirms payment | |
| 154 | - | // - /confirm-delete uses a signed HMAC link as its authorization; the user | |
| 155 | - | // arrives from an email and may not have an active session, so the | |
| 156 | - | // standard CSRF header cannot be attached to the vanilla form POST. | |
| 157 | - | // Exempt path prefixes: a path matches if it equals the prefix exactly | |
| 158 | - | // or continues with '/'. This prevents "/loginX" from matching "/login". | |
| 159 | - | let exempt_prefixes = [ | |
| 160 | - | "/stripe/webhook", "/stripe/checkout", "/stripe/subscribe", | |
| 161 | - | "/login", "/join", | |
| 162 | - | "/api/sync/auth", "/api/sync/push", "/api/sync/pull", "/api/sync/status", | |
| 163 | - | "/api/sync/devices", "/api/sync/keys", "/api/sync/blobs", | |
| 164 | - | "/oauth", "/auth/passkey", "/postmark", | |
| 165 | - | "/unsubscribe", "/confirm-delete", | |
| 166 | - | "/api/checkout/guest", "/api/checkout/guest-free", | |
| 167 | - | ]; | |
| 168 | - | ||
| 169 | - | let is_exempt = exempt_prefixes.iter().any(|p| { | |
| 170 | - | path == *p || path.starts_with(&format!("{p}/")) | |
| 171 | - | }); | |
| 172 | - | if is_exempt { | |
| 173 | - | return next.run(request).await; | |
| 166 | + | pub(super) fn make_validated() -> CsrfManuallyValidated { | |
| 167 | + | CsrfManuallyValidated { _private: () } | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Validate a token and return a sealed witness on success. Used by | |
| 172 | + | /// handlers registered with `post_csrf_manual` (and method variants) | |
| 173 | + | /// that need to validate inside the handler body — typically because the | |
| 174 | + | /// global middleware can't read the token for this content type (e.g. | |
| 175 | + | /// multipart) or because validation is conditional on request state. | |
| 176 | + | pub async fn validate_token_consuming( | |
| 177 | + | session: &Session, | |
| 178 | + | provided_token: &str, | |
| 179 | + | ) -> Result<CsrfManuallyValidated, AppError> { | |
| 180 | + | if validate_token(session, provided_token).await? { | |
| 181 | + | Ok(sealed::make_validated()) | |
| 182 | + | } else { | |
| 183 | + | Err(AppError::Forbidden) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// Wrap a method-router with the Auto-posture validation layer. | |
| 188 | + | /// Runs `validate_auto` on every request that reaches the route. | |
| 189 | + | fn attach_auto_layer<S>(method_router: MethodRouter<S>) -> MethodRouter<S> | |
| 190 | + | where | |
| 191 | + | S: Clone + Send + Sync + 'static, | |
| 192 | + | { | |
| 193 | + | method_router.layer(from_fn(|req: Request, next: Next| async move { | |
| 194 | + | let path = req.uri().path().to_string(); | |
| 195 | + | validate_auto(req, next, &path).await | |
| 196 | + | })) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// A `MethodRouter` that has been through one of the CSRF helpers. Field | |
| 200 | + | /// is private and constructible only inside this module, so | |
| 201 | + | /// `CsrfRouter::route` will not accept a bare `axum::routing::post(handler)` | |
| 202 | + | /// — route files have to use the helpers, by construction. | |
| 203 | + | pub use posture_router::PostureMethodRouter; | |
| 204 | + | ||
| 205 | + | mod posture_router { | |
| 206 | + | use super::*; | |
| 207 | + | ||
| 208 | + | pub struct PostureMethodRouter<S = ()>(pub(super) MethodRouter<S>); | |
| 209 | + | ||
| 210 | + | impl<S> PostureMethodRouter<S> | |
| 211 | + | where | |
| 212 | + | S: Clone + Send + Sync + 'static, | |
| 213 | + | { | |
| 214 | + | pub(super) fn new(inner: MethodRouter<S>) -> Self { | |
| 215 | + | Self(inner) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | pub(super) fn into_inner(self) -> MethodRouter<S> { | |
| 219 | + | self.0 | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// Attach an additional tower layer (e.g. a rate limiter) to the | |
| 223 | + | /// underlying method router. Returns `Self` so callers don't lose | |
| 224 | + | /// the posture stamp. | |
| 225 | + | pub fn layer<L>(self, layer: L) -> Self | |
| 226 | + | where | |
| 227 | + | L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static, | |
| 228 | + | L::Service: | |
| 229 | + | tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static, | |
| 230 | + | <L::Service as tower::Service<axum::extract::Request>>::Response: | |
| 231 | + | axum::response::IntoResponse + 'static, | |
| 232 | + | <L::Service as tower::Service<axum::extract::Request>>::Error: | |
| 233 | + | Into<std::convert::Infallible> + 'static, | |
| 234 | + | <L::Service as tower::Service<axum::extract::Request>>::Future: | |
| 235 | + | Send + 'static, | |
| 236 | + | { | |
| 237 | + | Self(self.0.layer(layer)) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | macro_rules! csrf_auto_helper { | |
| 243 | + | ($name:ident, $axum_fn:ident) => { | |
| 244 | + | pub fn $name<H, T, S>(handler: H) -> PostureMethodRouter<S> | |
| 245 | + | where | |
| 246 | + | H: Handler<T, S>, | |
| 247 | + | T: 'static, | |
| 248 | + | S: Clone + Send + Sync + 'static, | |
| 249 | + | { | |
| 250 | + | posture_router::PostureMethodRouter::new(attach_auto_layer($axum_fn(handler))) | |
| 251 | + | } | |
| 252 | + | }; | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | macro_rules! csrf_passthrough_helper { | |
| 256 | + | ($name:ident, $axum_fn:ident, $variant:ident) => { | |
| 257 | + | pub fn $name<H, T, S>(reason: &'static str, handler: H) -> PostureMethodRouter<S> | |
| 258 | + | where | |
| 259 | + | H: Handler<T, S>, | |
| 260 | + | T: 'static, | |
| 261 | + | S: Clone + Send + Sync + 'static, | |
| 262 | + | { | |
| 263 | + | let _ = CsrfPosture::$variant(reason); | |
| 264 | + | posture_router::PostureMethodRouter::new($axum_fn(handler)) | |
| 265 | + | } | |
| 266 | + | }; | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // Auto posture: standard CSRF validation (header or form `_csrf`). | |
| 270 | + | csrf_auto_helper!(post_csrf, post); | |
| 271 | + | csrf_auto_helper!(put_csrf, put); | |
| 272 | + | csrf_auto_helper!(patch_csrf, patch); | |
| 273 | + | csrf_auto_helper!(delete_csrf, delete); | |
| 274 | + | ||
| 275 | + | // Manual posture: handler validates via `validate_token_consuming`. | |
| 276 | + | csrf_passthrough_helper!(post_csrf_manual, post, Manual); | |
| 277 | + | csrf_passthrough_helper!(put_csrf_manual, put, Manual); | |
| 278 | + | csrf_passthrough_helper!(patch_csrf_manual, patch, Manual); | |
| 279 | + | csrf_passthrough_helper!(delete_csrf_manual, delete, Manual); | |
| 280 | + | ||
| 281 | + | // Skip posture: no CSRF check. Reason documents why. | |
| 282 | + | csrf_passthrough_helper!(post_csrf_skip, post, Skip); | |
| 283 | + | csrf_passthrough_helper!(put_csrf_skip, put, Skip); | |
| 284 | + | csrf_passthrough_helper!(patch_csrf_skip, patch, Skip); | |
| 285 | + | csrf_passthrough_helper!(delete_csrf_skip, delete, Skip); | |
| 286 | + | ||
| 287 | + | // --- Wrappers for multi-method routes ------------------------------------ | |
| 288 | + | // | |
| 289 | + | // A handful of routes register multiple HTTP methods on one path | |
| 290 | + | // (e.g. `get(list).post(create)`). The handler-taking helpers above can't | |
| 291 | + | // compose with these because the chain is already a `MethodRouter`. These | |
| 292 | + | // wrappers take a pre-built `MethodRouter` and stamp it as a | |
| 293 | + | // `PostureMethodRouter`. Read methods (GET/HEAD) are unaffected — the | |
| 294 | + | // Auto validation layer only intercepts state-changing methods at the | |
| 295 | + | // per-route level because that's what the helper attached to. | |
| 296 | + | ||
| 297 | + | /// Wrap a multi-method chain with the Auto-posture validation layer. | |
| 298 | + | pub fn with_csrf<S>(method_router: MethodRouter<S>) -> PostureMethodRouter<S> | |
| 299 | + | where | |
| 300 | + | S: Clone + Send + Sync + 'static, | |
| 301 | + | { | |
| 302 | + | posture_router::PostureMethodRouter::new(attach_auto_layer(method_router)) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | /// Stamp a multi-method chain as Manual — handler is responsible for | |
| 306 | + | /// calling `validate_token_consuming`. | |
| 307 | + | pub fn with_csrf_manual<S>( | |
| 308 | + | reason: &'static str, | |
| 309 | + | method_router: MethodRouter<S>, | |
| 310 | + | ) -> PostureMethodRouter<S> | |
| 311 | + | where | |
| 312 | + | S: Clone + Send + Sync + 'static, | |
| 313 | + | { | |
| 314 | + | let _ = CsrfPosture::Manual(reason); | |
| 315 | + | posture_router::PostureMethodRouter::new(method_router) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | /// Stamp a multi-method chain as Skip — no CSRF check applies. | |
| 319 | + | pub fn with_csrf_skip<S>( | |
| 320 | + | reason: &'static str, | |
| 321 | + | method_router: MethodRouter<S>, | |
| 322 | + | ) -> PostureMethodRouter<S> | |
| 323 | + | where | |
| 324 | + | S: Clone + Send + Sync + 'static, | |
| 325 | + | { | |
| 326 | + | let _ = CsrfPosture::Skip(reason); | |
| 327 | + | posture_router::PostureMethodRouter::new(method_router) | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // --- CsrfRouter: structural enforcement ---------------------------------- | |
| 331 | + | // | |
| 332 | + | // `CsrfRouter` is the only way to register a mutation route in this | |
| 333 | + | // codebase. Its `route` method takes a `PostureMethodRouter<S>`, whose | |
| 334 | + | // constructor is private to this module, so the only producers are the | |
| 335 | + | // helpers above. A bare `Router::route(path, post(handler))` cannot | |
| 336 | + | // reach a mounted `CsrfRouter` without going through `finalize()` first, | |
| 337 | + | // which is only called once in `build_app`. | |
| 338 | + | ||
| 339 | + | pub struct CsrfRouter<S = ()>(Router<S>); | |
| 340 | + | ||
| 341 | + | impl<S> Default for CsrfRouter<S> | |
| 342 | + | where | |
| 343 | + | S: Clone + Send + Sync + 'static, | |
| 344 | + | { | |
| 345 | + | fn default() -> Self { | |
| 346 | + | Self::new() | |
| 347 | + | } | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | impl<S> CsrfRouter<S> | |
| 351 | + | where | |
| 352 | + | S: Clone + Send + Sync + 'static, | |
| 353 | + | { | |
| 354 | + | pub fn new() -> Self { | |
| 355 | + | Self(Router::new()) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | pub fn route(self, path: &str, posture: PostureMethodRouter<S>) -> Self { | |
| 359 | + | Self(self.0.route(path, posture.into_inner())) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | /// Register a read-only route (GET / HEAD / OPTIONS). The structural | |
| 363 | + | /// guarantee only constrains state-changing methods, so read-only | |
| 364 | + | /// `MethodRouter`s pass through unchanged. Calling this with a | |
| 365 | + | /// `MethodRouter` that includes POST/PUT/PATCH/DELETE compiles, but | |
| 366 | + | /// readers can see the intent at the call site — and any mutation | |
| 367 | + | /// route registered through `route_get` is a bug visible in review. | |
| 368 | + | pub fn route_get(self, path: &str, method_router: MethodRouter<S>) -> Self { | |
| 369 | + | Self(self.0.route(path, method_router)) | |
| 174 | 370 | } | |
| 175 | 371 | ||
| 372 | + | pub fn merge(self, other: Self) -> Self { | |
| 373 | + | Self(self.0.merge(other.0)) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | pub fn nest(self, path: &str, other: Self) -> Self { | |
| 377 | + | Self(self.0.nest(path, other.0)) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | pub fn layer<L>(self, layer: L) -> Self | |
| 381 | + | where | |
| 382 | + | L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static, | |
| 383 | + | L::Service: | |
| 384 | + | tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static, | |
| 385 | + | <L::Service as tower::Service<axum::extract::Request>>::Response: | |
| 386 | + | IntoResponse + 'static, | |
| 387 | + | <L::Service as tower::Service<axum::extract::Request>>::Error: | |
| 388 | + | Into<std::convert::Infallible> + 'static, | |
| 389 | + | <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static, | |
| 390 | + | { | |
| 391 | + | Self(self.0.layer(layer)) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | pub fn route_layer<L>(self, layer: L) -> Self | |
| 395 | + | where | |
| 396 | + | L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static, | |
| 397 | + | L::Service: | |
| 398 | + | tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static, | |
| 399 | + | <L::Service as tower::Service<axum::extract::Request>>::Response: | |
| 400 | + | IntoResponse + 'static, | |
| 401 | + | <L::Service as tower::Service<axum::extract::Request>>::Error: | |
| 402 | + | Into<std::convert::Infallible> + 'static, | |
| 403 | + | <L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static, | |
| 404 | + | { | |
| 405 | + | Self(self.0.route_layer(layer)) | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | /// Drop the structural envelope and return the underlying `Router<S>`. | |
| 409 | + | /// Called once in `build_app` after all mutation routes have been | |
| 410 | + | /// registered; downstream code may then attach global layers, mount | |
| 411 | + | /// static-file services, and add GET-only routes. | |
| 412 | + | pub fn finalize(self) -> Router<S> { | |
| 413 | + | self.0 | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | /// Standard CSRF validation: header `X-CSRF-Token` first, then form-body | |
| 418 | + | /// `_csrf` for authenticated users. Used by `CsrfPosture::Auto` routes | |
| 419 | + | /// and by the path-allowlist fallback during the L2 migration. | |
| 420 | + | async fn validate_auto(request: Request, next: Next, path: &str) -> Response { | |
| 176 | 421 | // Get session from extensions | |
| 177 | 422 | let session = match request.extensions().get::<Session>() { | |
| 178 | 423 | Some(s) => s.clone(), | |
| @@ -225,8 +470,9 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Response { | |||
| 225 | 470 | // forms (uploads go through HTMX + fetch, which attach | |
| 226 | 471 | // `X-CSRF-Token` on the header path above), so rejecting here is | |
| 227 | 472 | // the explicit boundary. If multipart adoption ever becomes | |
| 228 | - | // necessary, add a content-type branch that streams the body | |
| 229 | - | // through a multipart parser instead of naive `to_bytes`. | |
| 473 | + | // necessary, register the route with `post_csrf_manual` and have | |
| 474 | + | // the handler stream the body through a multipart parser before | |
| 475 | + | // calling `validate_token_consuming`. | |
| 230 | 476 | // - `application/json` and others must use the `X-CSRF-Token` | |
| 231 | 477 | // header — anything that can set a custom header can set this one. | |
| 232 | 478 | let content_type = request | |
| @@ -439,6 +685,21 @@ mod tests { | |||
| 439 | 685 | } | |
| 440 | 686 | ||
| 441 | 687 | #[test] | |
| 688 | + | fn csrf_manually_validated_marker_is_zero_sized() { | |
| 689 | + | assert_eq!(std::mem::size_of::<CsrfManuallyValidated>(), 0); | |
| 690 | + | } | |
| 691 | + | ||
| 692 | + | #[test] | |
| 693 | + | fn csrf_posture_is_copyable_and_carries_reason() { | |
| 694 | + | let p = CsrfPosture::Skip("webhook: stripe signature"); | |
| 695 | + | let copy = p; | |
| 696 | + | match copy { | |
| 697 | + | CsrfPosture::Skip(r) => assert_eq!(r, "webhook: stripe signature"), | |
| 698 | + | _ => panic!("variant mismatch"), | |
| 699 | + | } | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | #[test] | |
| 442 | 703 | fn test_constant_time_compare_truncated() { | |
| 443 | 704 | use crate::helpers::constant_time_compare; | |
| 444 | 705 | let token = generate_token(); |
| @@ -127,8 +127,12 @@ pub fn build_app( | |||
| 127 | 127 | session_layer: SessionManagerLayer<PostgresStore>, | |
| 128 | 128 | ) -> Router { | |
| 129 | 129 | let metrics_handle = state.metrics_handle.clone(); | |
| 130 | - | let mut app = Router::new() | |
| 131 | - | .merge(page_routes()) | |
| 130 | + | // All mutation-bearing sub-routers register through `CsrfRouter`, whose | |
| 131 | + | // `route` method only accepts `PostureMethodRouter` values produced by | |
| 132 | + | // the `csrf::*_csrf*` helpers. Finalising the merged tree drops the | |
| 133 | + | // structural envelope so global middleware, static-file mounts, and | |
| 134 | + | // the few bare GETs below can attach to a plain `Router<AppState>`. | |
| 135 | + | let csrf_routes = csrf::CsrfRouter::new() | |
| 132 | 136 | .merge(auth_routes()) | |
| 133 | 137 | .merge(api_routes()) | |
| 134 | 138 | .merge(storage_routes()) | |
| @@ -137,10 +141,14 @@ pub fn build_app( | |||
| 137 | 141 | .merge(synckit_routes()) | |
| 138 | 142 | .merge(oauth_routes()) | |
| 139 | 143 | .merge(postmark_routes()) | |
| 140 | - | .merge(git_routes()) | |
| 141 | 144 | .merge(git_issue_routes()) | |
| 142 | 145 | .merge(ota_routes()) | |
| 143 | 146 | .merge(build_routes()) | |
| 147 | + | .finalize(); | |
| 148 | + | let mut app = Router::new() | |
| 149 | + | .merge(page_routes()) | |
| 150 | + | .merge(csrf_routes) | |
| 151 | + | .merge(git_routes()) | |
| 144 | 152 | .merge(routes::embed::embed_routes()) | |
| 145 | 153 | .route("/api/openapi.json", axum::routing::get(openapi::openapi_json)) | |
| 146 | 154 | .route("/robots.txt", axum::routing::get(|| async { | |
| @@ -204,7 +212,6 @@ pub fn build_app( | |||
| 204 | 212 | app.layer(middleware::from_fn_with_state(state.clone(), security_headers_middleware)) | |
| 205 | 213 | .layer(middleware::from_fn(metrics::cache_control_middleware)) | |
| 206 | 214 | .layer(middleware::from_fn(metrics::metrics_middleware)) | |
| 207 | - | .layer(middleware::from_fn(csrf::csrf_middleware)) | |
| 208 | 215 | .layer(middleware::from_fn_with_state(state.clone(), metrics::idempotency_middleware)) | |
| 209 | 216 | .layer(session_layer) | |
| 210 | 217 | .layer(RequestBodyLimitLayer::new(1024 * 1024)) |
| @@ -9,71 +9,72 @@ mod waitlist; | |||
| 9 | 9 | use axum::{ | |
| 10 | 10 | extract::{Path, State}, | |
| 11 | 11 | response::{IntoResponse, Response}, | |
| 12 | - | routing::{get, post}, | |
| 13 | - | Form, Router, | |
| 12 | + | routing::get, | |
| 13 | + | Form, | |
| 14 | 14 | }; | |
| 15 | 15 | use serde::Deserialize; | |
| 16 | 16 | ||
| 17 | 17 | use crate::{ | |
| 18 | 18 | auth::AdminUser, | |
| 19 | + | csrf::{post_csrf, CsrfRouter}, | |
| 19 | 20 | db::{self, UserId}, | |
| 20 | 21 | error::{AppError, Result}, | |
| 21 | 22 | AppState, | |
| 22 | 23 | }; | |
| 23 | 24 | ||
| 24 | 25 | /// Register admin routes for waitlist management, user moderation, and lottery. | |
| 25 | - | pub fn admin_routes() -> Router<AppState> { | |
| 26 | - | Router::new() | |
| 26 | + | pub fn admin_routes() -> CsrfRouter<AppState> { | |
| 27 | + | CsrfRouter::new() | |
| 27 | 28 | // Waitlist | |
| 28 | - | .route("/admin/waitlist", get(waitlist::admin_waitlist)) | |
| 29 | - | .route("/admin/waitlist/entries", get(waitlist::admin_waitlist_entries)) | |
| 30 | - | .route("/api/admin/waitlist/{id}/approve", post(waitlist::admin_approve)) | |
| 31 | - | .route("/api/admin/waitlist/{id}/spam", post(waitlist::admin_spam)) | |
| 32 | - | .route("/api/admin/lottery", post(waitlist::admin_lottery)) | |
| 29 | + | .route_get("/admin/waitlist", get(waitlist::admin_waitlist)) | |
| 30 | + | .route_get("/admin/waitlist/entries", get(waitlist::admin_waitlist_entries)) | |
| 31 | + | .route("/api/admin/waitlist/{id}/approve", post_csrf(waitlist::admin_approve)) | |
| 32 | + | .route("/api/admin/waitlist/{id}/spam", post_csrf(waitlist::admin_spam)) | |
| 33 | + | .route("/api/admin/lottery", post_csrf(waitlist::admin_lottery)) | |
| 33 | 34 | // User management | |
| 34 | - | .route("/admin/users", get(users::admin_users)) | |
| 35 | - | .route("/admin/users/entries", get(users::admin_user_entries)) | |
| 36 | - | .route("/api/admin/users/{id}/warn", post(users::admin_warn_user)) | |
| 37 | - | .route("/api/admin/users/{id}/suspend", post(users::admin_suspend_user)) | |
| 38 | - | .route("/api/admin/users/{id}/unsuspend", post(users::admin_unsuspend_user)) | |
| 39 | - | .route("/api/admin/users/{id}/terminate", post(users::admin_terminate_user)) | |
| 35 | + | .route_get("/admin/users", get(users::admin_users)) | |
| 36 | + | .route_get("/admin/users/entries", get(users::admin_user_entries)) | |
| 37 | + | .route("/api/admin/users/{id}/warn", post_csrf(users::admin_warn_user)) | |
| 38 | + | .route("/api/admin/users/{id}/suspend", post_csrf(users::admin_suspend_user)) | |
| 39 | + | .route("/api/admin/users/{id}/unsuspend", post_csrf(users::admin_unsuspend_user)) | |
| 40 | + | .route("/api/admin/users/{id}/terminate", post_csrf(users::admin_terminate_user)) | |
| 40 | 41 | // Upload review queue | |
| 41 | - | .route("/admin/uploads", get(uploads::admin_uploads)) | |
| 42 | - | .route("/api/admin/uploads/items/{id}/promote", post(uploads::admin_promote_item)) | |
| 43 | - | .route("/api/admin/uploads/items/{id}/quarantine", post(uploads::admin_quarantine_item)) | |
| 44 | - | .route("/api/admin/uploads/items/{id}/rescan", post(uploads::admin_rescan_item)) | |
| 45 | - | .route("/api/admin/uploads/versions/{id}/promote", post(uploads::admin_promote_version)) | |
| 46 | - | .route("/api/admin/uploads/versions/{id}/quarantine", post(uploads::admin_quarantine_version)) | |
| 47 | - | .route("/api/admin/uploads/versions/{id}/rescan", post(uploads::admin_rescan_version)) | |
| 48 | - | .route("/api/admin/uploads/bulk/rescan", post(uploads::admin_bulk_rescan_held)) | |
| 49 | - | .route("/api/admin/uploads/bulk/promote", post(uploads::admin_bulk_promote_held)) | |
| 50 | - | .route("/admin/uploads/queue-summary", get(uploads::admin_queue_summary_partial)) | |
| 51 | - | .route("/admin/uploads/audit", get(uploads::admin_scan_audit)) | |
| 52 | - | .route("/admin/uploads/health.json", get(uploads::scan_health_json)) | |
| 53 | - | .route("/api/admin/users/{id}/trust", post(users::admin_trust_user)) | |
| 54 | - | .route("/api/admin/users/{id}/untrust", post(users::admin_untrust_user)) | |
| 42 | + | .route_get("/admin/uploads", get(uploads::admin_uploads)) | |
| 43 | + | .route("/api/admin/uploads/items/{id}/promote", post_csrf(uploads::admin_promote_item)) | |
| 44 | + | .route("/api/admin/uploads/items/{id}/quarantine", post_csrf(uploads::admin_quarantine_item)) | |
| 45 | + | .route("/api/admin/uploads/items/{id}/rescan", post_csrf(uploads::admin_rescan_item)) | |
| 46 | + | .route("/api/admin/uploads/versions/{id}/promote", post_csrf(uploads::admin_promote_version)) | |
| 47 | + | .route("/api/admin/uploads/versions/{id}/quarantine", post_csrf(uploads::admin_quarantine_version)) | |
| 48 | + | .route("/api/admin/uploads/versions/{id}/rescan", post_csrf(uploads::admin_rescan_version)) | |
| 49 | + | .route("/api/admin/uploads/bulk/rescan", post_csrf(uploads::admin_bulk_rescan_held)) | |
| 50 | + | .route("/api/admin/uploads/bulk/promote", post_csrf(uploads::admin_bulk_promote_held)) | |
| 51 | + | .route_get("/admin/uploads/queue-summary", get(uploads::admin_queue_summary_partial)) | |
| 52 | + | .route_get("/admin/uploads/audit", get(uploads::admin_scan_audit)) | |
| 53 | + | .route_get("/admin/uploads/health.json", get(uploads::scan_health_json)) | |
| 54 | + | .route("/api/admin/users/{id}/trust", post_csrf(users::admin_trust_user)) | |
| 55 | + | .route("/api/admin/users/{id}/untrust", post_csrf(users::admin_untrust_user)) | |
| 55 | 56 | // Appeals | |
| 56 | - | .route("/admin/appeals", get(moderation::admin_appeals)) | |
| 57 | - | .route("/api/admin/appeals/{user_id}/decide", post(moderation::admin_decide_appeal)) | |
| 57 | + | .route_get("/admin/appeals", get(moderation::admin_appeals)) | |
| 58 | + | .route("/api/admin/appeals/{user_id}/decide", post_csrf(moderation::admin_decide_appeal)) | |
| 58 | 59 | // Email signups | |
| 59 | - | .route("/admin/signups", get(signups::admin_signups)) | |
| 60 | + | .route_get("/admin/signups", get(signups::admin_signups)) | |
| 60 | 61 | // Reports | |
| 61 | - | .route("/admin/reports", get(moderation::admin_reports)) | |
| 62 | - | .route("/admin/reports/entries", get(moderation::admin_report_entries)) | |
| 63 | - | .route("/api/admin/reports/{id}/resolve", post(moderation::admin_resolve_report)) | |
| 62 | + | .route_get("/admin/reports", get(moderation::admin_reports)) | |
| 63 | + | .route_get("/admin/reports/entries", get(moderation::admin_report_entries)) | |
| 64 | + | .route("/api/admin/reports/{id}/resolve", post_csrf(moderation::admin_resolve_report)) | |
| 64 | 65 | // Per-item content removal | |
| 65 | - | .route("/api/admin/items/{id}/remove", post(moderation::admin_remove_item)) | |
| 66 | - | .route("/api/admin/items/{id}/restore", post(moderation::admin_restore_item)) | |
| 66 | + | .route("/api/admin/items/{id}/remove", post_csrf(moderation::admin_remove_item)) | |
| 67 | + | .route("/api/admin/items/{id}/restore", post_csrf(moderation::admin_restore_item)) | |
| 67 | 68 | // MT provisioning | |
| 68 | - | .route("/api/admin/mt/provision", post(admin_mt_provision)) | |
| 69 | + | .route("/api/admin/mt/provision", post_csrf(admin_mt_provision)) | |
| 69 | 70 | // Storage overrides | |
| 70 | - | .route("/api/admin/users/{id}/file-override", post(admin_file_override)) | |
| 71 | + | .route("/api/admin/users/{id}/file-override", post_csrf(admin_file_override)) | |
| 71 | 72 | // Shutdown | |
| 72 | - | .route("/api/admin/shutdown-notice", post(admin_shutdown_notice)) | |
| 73 | + | .route("/api/admin/shutdown-notice", post_csrf(admin_shutdown_notice)) | |
| 73 | 74 | // Founder pricing | |
| 74 | - | .route("/api/admin/founder-window/close", post(admin_close_founder_window)) | |
| 75 | + | .route("/api/admin/founder-window/close", post_csrf(admin_close_founder_window)) | |
| 75 | 76 | // Metrics | |
| 76 | - | .route("/admin/metrics", get(admin_metrics)) | |
| 77 | + | .route_get("/admin/metrics", get(admin_metrics)) | |
| 77 | 78 | } | |
| 78 | 79 | ||
| 79 | 80 | // ── MT Provisioning ── |
| @@ -12,62 +12,67 @@ mod uploads; | |||
| 12 | 12 | ||
| 13 | 13 | pub(super) use git::restart_status; | |
| 14 | 14 | ||
| 15 | - | use axum::{ | |
| 16 | - | routing::{delete, get, post, put}, | |
| 17 | - | Router, | |
| 15 | + | use axum::routing::get; | |
| 16 | + | ||
| 17 | + | use crate::{ | |
| 18 | + | csrf::{delete_csrf_skip, post_csrf_skip, put_csrf_skip, with_csrf_skip, CsrfRouter}, | |
| 19 | + | AppState, | |
| 18 | 20 | }; | |
| 19 | 21 | ||
| 20 | - | use crate::AppState; | |
| 22 | + | /// All routes in this file are HMAC-bearer authed via `ServiceAuth` (no | |
| 23 | + | /// session); CSRF is not applicable. Each registration carries the same | |
| 24 | + | /// `Skip` reason so the posture is visible at the call site. | |
| 25 | + | const INTERNAL_SKIP: &str = "internal API: HMAC bearer auth, no session"; | |
| 21 | 26 | ||
| 22 | 27 | /// Internal service-to-service routes (ServiceAuth, no rate limit). | |
| 23 | - | pub(super) fn internal_routes() -> Router<AppState> { | |
| 24 | - | Router::new() | |
| 25 | - | .route("/api/internal/ssh-key-lookup", get(git::ssh_key_lookup)) | |
| 26 | - | .route("/api/internal/creator/projects", get(creators::creator_projects).post(cli_features::create_project)) | |
| 27 | - | .route("/api/internal/creator/projects/{id}/items", get(creators::creator_project_items)) | |
| 28 | - | .route("/api/internal/creator/stats", get(creators::creator_stats)) | |
| 29 | - | .route("/api/internal/creator/items", post(items::create_item)) | |
| 30 | - | .route("/api/internal/upload/presign", post(uploads::presign_upload)) | |
| 31 | - | .route("/api/internal/upload/confirm", post(uploads::confirm_upload)) | |
| 32 | - | .route("/api/internal/creator/storage", get(uploads::creator_storage)) | |
| 33 | - | .route("/api/internal/creator/items/{id}", get(items::get_item)) | |
| 34 | - | .route("/api/internal/creator/items/{id}", put(items::update_item)) | |
| 35 | - | .route("/api/internal/creator/items/{id}", delete(items::delete_item)) | |
| 36 | - | .route("/api/internal/creator/items/{id}/publish", post(items::publish_item)) | |
| 37 | - | .route("/api/internal/creator/items/{id}/unpublish", post(items::unpublish_item)) | |
| 38 | - | .route("/api/internal/creator/items/{id}/versions", get(items::item_versions)) | |
| 28 | + | pub(super) fn internal_routes() -> CsrfRouter<AppState> { | |
| 29 | + | CsrfRouter::new() | |
| 30 | + | .route_get("/api/internal/ssh-key-lookup", get(git::ssh_key_lookup)) | |
| 31 | + | .route("/api/internal/creator/projects", with_csrf_skip(INTERNAL_SKIP, get(creators::creator_projects).post(cli_features::create_project))) | |
| 32 | + | .route_get("/api/internal/creator/projects/{id}/items", get(creators::creator_project_items)) | |
| 33 | + | .route_get("/api/internal/creator/stats", get(creators::creator_stats)) | |
| 34 | + | .route("/api/internal/creator/items", post_csrf_skip(INTERNAL_SKIP, items::create_item)) | |
| 35 | + | .route("/api/internal/upload/presign", post_csrf_skip(INTERNAL_SKIP, uploads::presign_upload)) | |
| 36 | + | .route("/api/internal/upload/confirm", post_csrf_skip(INTERNAL_SKIP, uploads::confirm_upload)) | |
| 37 | + | .route_get("/api/internal/creator/storage", get(uploads::creator_storage)) | |
| 38 | + | .route_get("/api/internal/creator/items/{id}", get(items::get_item)) | |
| 39 | + | .route("/api/internal/creator/items/{id}", put_csrf_skip(INTERNAL_SKIP, items::update_item)) | |
| 40 | + | .route("/api/internal/creator/items/{id}", delete_csrf_skip(INTERNAL_SKIP, items::delete_item)) | |
| 41 | + | .route("/api/internal/creator/items/{id}/publish", post_csrf_skip(INTERNAL_SKIP, items::publish_item)) | |
| 42 | + | .route("/api/internal/creator/items/{id}/unpublish", post_csrf_skip(INTERNAL_SKIP, items::unpublish_item)) | |
| 43 | + | .route_get("/api/internal/creator/items/{id}/versions", get(items::item_versions)) | |
| 39 | 44 | // Blog posts | |
| 40 | - | .route("/api/internal/creator/projects/{id}/blog", get(content::list_blog_posts)) | |
| 41 | - | .route("/api/internal/creator/blog", post(content::create_blog_post)) | |
| 42 | - | .route("/api/internal/creator/blog/{id}", delete(content::delete_blog_post)) | |
| 45 | + | .route_get("/api/internal/creator/projects/{id}/blog", get(content::list_blog_posts)) | |
| 46 | + | .route("/api/internal/creator/blog", post_csrf_skip(INTERNAL_SKIP, content::create_blog_post)) | |
| 47 | + | .route("/api/internal/creator/blog/{id}", delete_csrf_skip(INTERNAL_SKIP, content::delete_blog_post)) | |
| 43 | 48 | // Promo codes | |
| 44 | - | .route("/api/internal/creator/promo-codes", get(content::list_promo_codes).post(content::create_promo_code)) | |
| 45 | - | .route("/api/internal/creator/promo-codes/{id}", delete(content::delete_promo_code)) | |
| 49 | + | .route("/api/internal/creator/promo-codes", with_csrf_skip(INTERNAL_SKIP, get(content::list_promo_codes).post(content::create_promo_code))) | |
| 50 | + | .route("/api/internal/creator/promo-codes/{id}", delete_csrf_skip(INTERNAL_SKIP, content::delete_promo_code)) | |
| 46 | 51 | // License keys | |
| 47 | - | .route("/api/internal/creator/items/{id}/keys", get(content::list_license_keys).post(content::generate_license_key)) | |
| 48 | - | .route("/api/internal/creator/keys/{id}/revoke", post(content::revoke_license_key)) | |
| 52 | + | .route("/api/internal/creator/items/{id}/keys", with_csrf_skip(INTERNAL_SKIP, get(content::list_license_keys).post(content::generate_license_key))) | |
| 53 | + | .route("/api/internal/creator/keys/{id}/revoke", post_csrf_skip(INTERNAL_SKIP, content::revoke_license_key)) | |
| 49 | 54 | // Analytics + export | |
| 50 | - | .route("/api/internal/creator/analytics", get(creators::creator_analytics)) | |
| 51 | - | .route("/api/internal/creator/transactions", get(creators::creator_transactions)) | |
| 52 | - | .route("/api/internal/creator/export/sales", get(creators::export_sales)) | |
| 55 | + | .route_get("/api/internal/creator/analytics", get(creators::creator_analytics)) | |
| 56 | + | .route_get("/api/internal/creator/transactions", get(creators::creator_transactions)) | |
| 57 | + | .route_get("/api/internal/creator/export/sales", get(creators::export_sales)) | |
| 53 | 58 | // Settings | |
| 54 | - | .route("/api/internal/creator/ssh-keys", get(git::list_ssh_keys)) | |
| 59 | + | .route_get("/api/internal/creator/ssh-keys", get(git::list_ssh_keys)) | |
| 55 | 60 | // Git authorization | |
| 56 | - | .route("/api/internal/git/authorize", post(git::git_authorize)) | |
| 57 | - | .route("/api/internal/restart-warning", post(git::set_restart_warning)) | |
| 61 | + | .route("/api/internal/git/authorize", post_csrf_skip(INTERNAL_SKIP, git::git_authorize)) | |
| 62 | + | .route("/api/internal/restart-warning", post_csrf_skip(INTERNAL_SKIP, git::set_restart_warning)) | |
| 58 | 63 | // CLI features: tags | |
| 59 | - | .route("/api/internal/creator/items/{id}/tags", get(cli_features::list_item_tags)) | |
| 60 | - | .route("/api/internal/creator/items/tags", post(cli_features::add_item_tag)) | |
| 61 | - | .route("/api/internal/creator/items/tags/remove", post(cli_features::remove_item_tag)) | |
| 62 | - | .route("/api/internal/tags/search", get(cli_features::search_tags)) | |
| 64 | + | .route_get("/api/internal/creator/items/{id}/tags", get(cli_features::list_item_tags)) | |
| 65 | + | .route("/api/internal/creator/items/tags", post_csrf_skip(INTERNAL_SKIP, cli_features::add_item_tag)) | |
| 66 | + | .route("/api/internal/creator/items/tags/remove", post_csrf_skip(INTERNAL_SKIP, cli_features::remove_item_tag)) | |
| 67 | + | .route_get("/api/internal/tags/search", get(cli_features::search_tags)) | |
| 63 | 68 | // CLI features: broadcast | |
| 64 | - | .route("/api/internal/creator/broadcast", post(cli_features::send_broadcast)) | |
| 69 | + | .route("/api/internal/creator/broadcast", post_csrf_skip(INTERNAL_SKIP, cli_features::send_broadcast)) | |
| 65 | 70 | // CLI features: tiers | |
| 66 | - | .route("/api/internal/creator/projects/{id}/tiers", get(cli_features::list_tiers)) | |
| 71 | + | .route_get("/api/internal/creator/projects/{id}/tiers", get(cli_features::list_tiers)) | |
| 67 | 72 | // CLI features: collections | |
| 68 | - | .route("/api/internal/creator/collections", get(cli_features::list_collections).post(cli_features::create_collection)) | |
| 69 | - | .route("/api/internal/creator/collections/{id}", delete(cli_features::delete_collection)) | |
| 73 | + | .route("/api/internal/creator/collections", with_csrf_skip(INTERNAL_SKIP, get(cli_features::list_collections).post(cli_features::create_collection))) | |
| 74 | + | .route("/api/internal/creator/collections/{id}", delete_csrf_skip(INTERNAL_SKIP, cli_features::delete_collection)) | |
| 70 | 75 | // CLI features: custom domains | |
| 71 | - | .route("/api/internal/creator/domain", get(cli_features::get_domain).post(cli_features::add_domain).delete(cli_features::remove_domain)) | |
| 72 | - | .route("/api/internal/creator/domain/verify", post(cli_features::verify_domain)) | |
| 76 | + | .route("/api/internal/creator/domain", with_csrf_skip(INTERNAL_SKIP, get(cli_features::get_domain).post(cli_features::add_domain).delete(cli_features::remove_domain))) | |
| 77 | + | .route("/api/internal/creator/domain/verify", post_csrf_skip(INTERNAL_SKIP, cli_features::verify_domain)) | |
| 73 | 78 | } |
| @@ -47,8 +47,8 @@ use axum::{ | |||
| 47 | 47 | extract::{Request, State}, | |
| 48 | 48 | middleware::Next, | |
| 49 | 49 | response::{IntoResponse, Response}, | |
| 50 | - | routing::{delete, get, options, post, put}, | |
| 51 | - | Json, Router, | |
| 50 | + | routing::{get, options}, | |
| 51 | + | Json, | |
| 52 | 52 | }; | |
| 53 | 53 | use serde::{Deserialize, Serialize}; | |
| 54 | 54 | use serde_json::json; | |
| @@ -56,11 +56,15 @@ use tower_governor::GovernorLayer; | |||
| 56 | 56 | ||
| 57 | 57 | use crate::{ | |
| 58 | 58 | constants, | |
| 59 | + | csrf::{delete_csrf, post_csrf, post_csrf_skip, put_csrf, CsrfRouter}, | |
| 59 | 60 | db::{self, BlogPostId, ItemId, ProjectId, ProjectType, UserId}, | |
| 60 | 61 | error::{ApiErrorMessage, AppError, Result}, | |
| 61 | 62 | AppState, | |
| 62 | 63 | }; | |
| 63 | 64 | ||
| 65 | + | const LICENSE_BEARER_SKIP: &str = "license API: bearer license key, no session"; | |
| 66 | + | const GUEST_CHECKOUT_SKIP: &str = "guest checkout: pre-auth, no session"; | |
| 67 | + | ||
| 64 | 68 | /// Fetch a project and verify the user owns it. Shared by all ownership checks | |
| 65 | 69 | /// that go through a project (items, blog posts, direct project access). | |
| 66 | 70 | pub(super) async fn verify_project_ownership( | |
| @@ -171,204 +175,204 @@ async fn email_signup( | |||
| 171 | 175 | /// - Write routes (POST/PUT/DELETE): burst 10, 2/sec per IP | |
| 172 | 176 | /// - Export routes: burst 3, 1/sec per IP (stricter, prevents bulk extraction) | |
| 173 | 177 | /// - Read routes (GET): no rate limit (alpha scale) | |
| 174 | - | pub fn api_routes() -> Router<AppState> { | |
| 178 | + | pub fn api_routes() -> CsrfRouter<AppState> { | |
| 175 | 179 | let write_rate_limit = crate::helpers::rate_limiter_ms(constants::API_WRITE_RATE_LIMIT_MS, constants::API_WRITE_RATE_LIMIT_BURST); | |
| 176 | 180 | let export_rate_limit = crate::helpers::rate_limiter_per_sec(constants::API_EXPORT_RATE_LIMIT_PER_SEC, constants::API_EXPORT_RATE_LIMIT_BURST); | |
| 177 | 181 | ||
| 178 | 182 | // Write routes — rate limited | |
| 179 | - | let write_routes = Router::new() | |
| 183 | + | let write_routes = CsrfRouter::new() | |
| 180 | 184 | // User routes | |
| 181 | - | .route("/api/users/me", put(users::update_profile)) | |
| 182 | - | .route("/api/users/me/password", put(users::update_password)) | |
| 183 | - | .route("/api/users/me/preferences", put(users::update_preferences)) | |
| 184 | - | .route("/api/users/me/stripe", delete(users::disconnect_stripe)) | |
| 185 | - | .route("/api/users/me/stripe-tax", put(users::toggle_stripe_tax)) | |
| 186 | - | .route("/api/users/me", delete(users::delete_account)) | |
| 187 | - | .route("/api/users/me/deactivate", post(users::deactivate_account)) | |
| 188 | - | .route("/api/users/me/reactivate", post(users::reactivate_account)) | |
| 189 | - | .route("/api/users/me/pause-creator", post(users::pause_creator)) | |
| 185 | + | .route("/api/users/me", put_csrf(users::update_profile)) | |
| 186 | + | .route("/api/users/me/password", put_csrf(users::update_password)) | |
| 187 | + | .route("/api/users/me/preferences", put_csrf(users::update_preferences)) | |
| 188 | + | .route("/api/users/me/stripe", delete_csrf(users::disconnect_stripe)) | |
| 189 | + | .route("/api/users/me/stripe-tax", put_csrf(users::toggle_stripe_tax)) | |
| 190 | + | .route("/api/users/me", delete_csrf(users::delete_account)) | |
| 191 | + | .route("/api/users/me/deactivate", post_csrf(users::deactivate_account)) | |
| 192 | + | .route("/api/users/me/reactivate", post_csrf(users::reactivate_account)) | |
| 193 | + | .route("/api/users/me/pause-creator", post_csrf(users::pause_creator)) | |
| 190 | 194 | // Broadcast | |
| 191 | - | .route("/api/broadcast", post(users::broadcast_send)) | |
| 192 | - | .route("/api/support/ticket", post(users::submit_support_ticket)) | |
| 195 | + | .route("/api/broadcast", post_csrf(users::broadcast_send)) | |
| 196 | + | .route("/api/support/ticket", post_csrf(users::submit_support_ticket)) | |
| 193 | 197 | // Project routes | |
| 194 | - | .route("/api/projects", post(projects::create_project)) | |
| 195 | - | .route("/api/projects/{id}", put(projects::update_project)) | |
| 196 | - | .route("/api/projects/{id}", delete(projects::delete_project)) | |
| 198 | + | .route("/api/projects", post_csrf(projects::create_project)) | |
| 199 | + | .route("/api/projects/{id}", put_csrf(projects::update_project)) | |
| 200 | + | .route("/api/projects/{id}", delete_csrf(projects::delete_project)) | |
| 197 | 201 | // Git repo management | |
| 198 | - | .route("/api/repos", post(projects::create_repo)) | |
| 199 | - | .route("/api/repos/{id}/visibility", put(projects::update_repo_visibility)) | |
| 200 | - | .route("/api/repos/{id}/collaborators", get(projects::list_repo_collaborators)) | |
| 201 | - | .route("/api/repos/{id}/collaborators", post(projects::add_repo_collaborator)) | |
| 202 | - | .route("/api/repos/{repo_id}/collaborators/{user_id}", delete(projects::remove_repo_collaborator)) | |
| 203 | - | .route("/api/projects/{id}/repos", post(projects::link_repo)) | |
| 204 | - | .route("/api/projects/{id}/repos/{repo_name}", delete(projects::unlink_repo)) | |
| 202 | + | .route("/api/repos", post_csrf(projects::create_repo)) | |
| 203 | + | .route("/api/repos/{id}/visibility", put_csrf(projects::update_repo_visibility)) | |
| 204 | + | .route_get("/api/repos/{id}/collaborators", get(projects::list_repo_collaborators)) | |
| 205 | + | .route("/api/repos/{id}/collaborators", post_csrf(projects::add_repo_collaborator)) | |
| 206 | + | .route("/api/repos/{repo_id}/collaborators/{user_id}", delete_csrf(projects::remove_repo_collaborator)) | |
| 207 | + | .route("/api/projects/{id}/repos", post_csrf(projects::link_repo)) | |
| 208 | + | .route("/api/projects/{id}/repos/{repo_name}", delete_csrf(projects::unlink_repo)) | |
| 205 | 209 | // Project members | |
| 206 | - | .route("/api/projects/{id}/members", post(projects::add_project_member)) | |
| 207 | - | .route("/api/projects/{project_id}/members/{user_id}", delete(projects::remove_project_member)) | |
| 210 | + | .route("/api/projects/{id}/members", post_csrf(projects::add_project_member)) | |
| 211 | + | .route("/api/projects/{project_id}/members/{user_id}", delete_csrf(projects::remove_project_member)) | |
| 208 | 212 | // Item routes | |
| 209 | - | .route("/api/projects/{id}/items", post(items::create_item)) | |
| 210 | - | .route("/api/items/{id}", put(items::update_item)) | |
| 211 | - | .route("/api/items/{id}", delete(items::delete_item)) | |
| 212 | - | .route("/api/items/{id}/duplicate", post(items::duplicate_item)) | |
| 213 | + | .route("/api/projects/{id}/items", post_csrf(items::create_item)) | |
| 214 | + | .route("/api/items/{id}", put_csrf(items::update_item)) | |
| 215 | + | .route("/api/items/{id}", delete_csrf(items::delete_item)) | |
| 216 | + | .route("/api/items/{id}/duplicate", post_csrf(items::duplicate_item)) | |
| 213 | 217 | // Bulk item operations | |
| 214 | - | .route("/api/items/bulk/publish", post(items::bulk_publish)) | |
| 215 | - | .route("/api/items/bulk/unpublish", post(items::bulk_unpublish)) | |
| 216 | - | .route("/api/items/bulk/delete", post(items::bulk_delete)) | |
| 217 | - | .route("/api/items/bulk/price", post(items::bulk_price)) | |
| 218 | - | .route("/api/items/bulk/tag", post(items::bulk_tag)) | |
| 219 | - | .route("/api/items/{id}/move", put(items::move_item)) | |
| 218 | + | .route("/api/items/bulk/publish", post_csrf(items::bulk_publish)) | |
| 219 | + | .route("/api/items/bulk/unpublish", post_csrf(items::bulk_unpublish)) | |
| 220 | + | .route("/api/items/bulk/delete", post_csrf(items::bulk_delete)) | |
| 221 | + | .route("/api/items/bulk/price", post_csrf(items::bulk_price)) | |
| 222 | + | .route("/api/items/bulk/tag", post_csrf(items::bulk_tag)) | |
| 223 | + | .route("/api/items/{id}/move", put_csrf(items::move_item)) | |
| 220 | 224 | // Bundle management | |
| 221 | - | .route("/api/items/{id}/bundle/add", post(items::bundle_add)) | |
| 222 | - | .route("/api/items/{id}/bundle/create-child", post(items::bundle_create_child)) | |
| 223 | - | .route("/api/items/{id}/bundle/{child_id}", delete(items::bundle_remove)) | |
| 224 | - | .route("/api/items/{id}/bundle/{child_id}/listed", put(items::bundle_toggle_listed)) | |
| 225 | + | .route("/api/items/{id}/bundle/add", post_csrf(items::bundle_add)) | |
| 226 | + | .route("/api/items/{id}/bundle/create-child", post_csrf(items::bundle_create_child)) | |
| 227 | + | .route("/api/items/{id}/bundle/{child_id}", delete_csrf(items::bundle_remove)) | |
| 228 | + | .route("/api/items/{id}/bundle/{child_id}/listed", put_csrf(items::bundle_toggle_listed)) | |
| 225 | 229 | // Refund | |
| 226 | - | .route("/api/items/{id}/refund", post(items::refund_transaction)) | |
| 227 | - | .route("/api/items/{id}/restore", post(items::restore_item)) | |
| 230 | + | .route("/api/items/{id}/refund", post_csrf(items::refund_transaction)) | |
| 231 | + | .route("/api/items/{id}/restore", post_csrf(items::restore_item)) | |
| 228 | 232 | // Tag routes (HTMX) | |
| 229 | - | .route("/api/items/{id}/tags", post(items::add_tag)) | |
| 230 | - | .route("/api/items/{id}/tags/{tag_id}", delete(items::remove_tag)) | |
| 231 | - | .route("/api/items/{id}/primary-tag", put(items::set_primary_tag)) | |
| 233 | + | .route("/api/items/{id}/tags", post_csrf(items::add_tag)) | |
| 234 | + | .route("/api/items/{id}/tags/{tag_id}", delete_csrf(items::remove_tag)) | |
| 235 | + | .route("/api/items/{id}/primary-tag", put_csrf(items::set_primary_tag)) | |
| 232 | 236 | // Text content route | |
| 233 | - | .route("/api/items/{id}/text", put(items::update_item_text)) | |
| 237 | + | .route("/api/items/{id}/text", put_csrf(items::update_item_text)) | |
| 234 | 238 | // Version routes | |
| 235 | - | .route("/api/items/{id}/versions", post(items::create_version)) | |
| 236 | - | .route("/api/items/{id}/versions/{version_id}", delete(items::delete_version)) | |
| 239 | + | .route("/api/items/{id}/versions", post_csrf(items::create_version)) | |
| 240 | + | .route("/api/items/{id}/versions/{version_id}", delete_csrf(items::delete_version)) | |
| 237 | 241 | // Custom link routes | |
| 238 | - | .route("/api/links", post(links::create_link)) | |
| 239 | - | .route("/api/links/{id}", put(links::update_link)) | |
| 240 | - | .route("/api/links/{id}", delete(links::delete_link)) | |
| 241 | - | .route("/api/links/reorder", put(links::reorder_links)) | |
| 242 | + | .route("/api/links", post_csrf(links::create_link)) | |
| 243 | + | .route("/api/links/{id}", put_csrf(links::update_link)) | |
| 244 | + | .route("/api/links/{id}", delete_csrf(links::delete_link)) | |
| 245 | + | .route("/api/links/reorder", put_csrf(links::reorder_links)) | |
| 242 | 246 | // Chapter routes | |
| 243 | - | .route("/api/items/{id}/chapters", post(items::create_chapter)) | |
| 244 | - | .route("/api/chapters/{id}", put(items::update_chapter)) | |
| 245 | - | .route("/api/chapters/{id}", delete(items::delete_chapter)) | |
| 247 | + | .route("/api/items/{id}/chapters", post_csrf(items::create_chapter)) | |
| 248 | + | .route("/api/chapters/{id}", put_csrf(items::update_chapter)) | |
| 249 | + | .route("/api/chapters/{id}", delete_csrf(items::delete_chapter)) | |
| 246 | 250 | // Section routes | |
| 247 | - | .route("/api/items/{id}/sections", post(items::create_section)) | |
| 248 | - | .route("/api/sections/{id}", put(items::update_section)) | |
| 249 | - | .route("/api/sections/{id}", delete(items::delete_section)) | |
| 250 | - | .route("/api/items/{id}/sections/reorder", put(items::reorder_sections)) | |
| 251 | + | .route("/api/items/{id}/sections", post_csrf(items::create_section)) | |
| 252 | + | .route("/api/sections/{id}", put_csrf(items::update_section)) | |
| 253 | + | .route("/api/sections/{id}", delete_csrf(items::delete_section)) | |
| 254 | + | .route("/api/items/{id}/sections/reorder", put_csrf(items::reorder_sections)) | |
| 251 | 255 | // Project section routes | |
| 252 | - | .route("/api/projects/{id}/sections", post(project_sections::create_section)) | |
| 253 | - | .route("/api/project-sections/{id}", put(project_sections::update_section)) | |
| 254 | - | .route("/api/project-sections/{id}", delete(project_sections::delete_section)) | |
| 255 | - | .route("/api/projects/{id}/sections/reorder", put(project_sections::reorder_sections)) | |
| 256 | + | .route("/api/projects/{id}/sections", post_csrf(project_sections::create_section)) | |
| 257 | + | .route("/api/project-sections/{id}", put_csrf(project_sections::update_section)) | |
| 258 | + | .route("/api/project-sections/{id}", delete_csrf(project_sections::delete_section)) | |
| 259 | + | .route("/api/projects/{id}/sections/reorder", put_csrf(project_sections::reorder_sections)) | |
| 256 | 260 | // Library routes | |
| 257 | - | .route("/api/library/add/{item_id}", post(users::add_to_library)) | |
| 258 | - | .route("/api/library/remove/{item_id}", delete(users::remove_from_library)) | |
| 261 | + | .route("/api/library/add/{item_id}", post_csrf(users::add_to_library)) | |
| 262 | + | .route("/api/library/remove/{item_id}", delete_csrf(users::remove_from_library)) | |
| 259 | 263 | // Contact sharing revocation | |
| 260 | - | .route("/api/contacts/{seller_id}", delete(users::revoke_contact)) | |
| 264 | + | .route("/api/contacts/{seller_id}", delete_csrf(users::revoke_contact)) | |
| 261 | 265 | // Waitlist | |
| 262 | - | .route("/api/waitlist/apply", post(users::waitlist_apply)) | |
| 266 | + | .route("/api/waitlist/apply", post_csrf(users::waitlist_apply)) | |
| 263 | 267 | // Email verification | |
| 264 | - | .route("/api/resend-verification", post(users::resend_verification)) | |
| 268 | + | .route("/api/resend-verification", post_csrf(users::resend_verification)) | |
| 265 | 269 | // Account management | |
| 266 | - | .route("/api/account/request-deletion", post(users::request_account_deletion)) | |
| 270 | + | .route("/api/account/request-deletion", post_csrf(users::request_account_deletion)) | |
| 267 | 271 | // Suspension appeal | |
| 268 | - | .route("/api/users/me/appeal", post(users::submit_appeal)) | |
| 272 | + | .route("/api/users/me/appeal", post_csrf(users::submit_appeal)) | |
| 269 | 273 | // Session management | |
| 270 | - | .route("/api/users/me/sessions/{id}", delete(users::revoke_session)) | |
| 271 | - | .route("/api/users/me/sessions", delete(users::revoke_other_sessions)) | |
| 274 | + | .route("/api/users/me/sessions/{id}", delete_csrf(users::revoke_session)) | |
| 275 | + | .route("/api/users/me/sessions", delete_csrf(users::revoke_other_sessions)) | |
| 272 | 276 | // Blog routes | |
| 273 | - | .route("/api/projects/{id}/blog", post(blog::create_blog_post)) | |
| 274 | - | .route("/api/blog/{id}", put(blog::update_blog_post)) | |
| 275 | - | .route("/api/blog/{id}", delete(blog::delete_blog_post)) | |
| 277 | + | .route("/api/projects/{id}/blog", post_csrf(blog::create_blog_post)) | |
| 278 | + | .route("/api/blog/{id}", put_csrf(blog::update_blog_post)) | |
| 279 | + | .route("/api/blog/{id}", delete_csrf(blog::delete_blog_post)) | |
| 276 | 280 | // License key management (creator) | |
| 277 | - | .route("/api/items/{id}/license-settings", put(license_keys::update_license_settings)) | |
| 278 | - | .route("/api/items/{id}/keys", post(license_keys::generate_key)) | |
| 279 | - | .route("/api/keys/{id}/revoke", post(license_keys::revoke_key)) | |
| 281 | + | .route("/api/items/{id}/license-settings", put_csrf(license_keys::update_license_settings)) | |
| 282 | + | .route("/api/items/{id}/keys", post_csrf(license_keys::generate_key)) | |
| 283 | + | .route("/api/keys/{id}/revoke", post_csrf(license_keys::revoke_key)) | |
| 280 | 284 | // Promo code management (creator) | |
| 281 | - | .route("/api/promo-codes", post(promo_codes::create_promo_code)) | |
| 282 | - | .route("/api/promo-codes", get(promo_codes::list_promo_codes)) | |
| 283 | - | .route("/api/promo-codes/expired", delete(promo_codes::delete_expired_promo_codes)) | |
| 284 | - | .route("/api/promo-codes/{id}", put(promo_codes::update_promo_code)) | |
| 285 | - | .route("/api/promo-codes/{id}", delete(promo_codes::delete_promo_code)) | |
| 286 | - | .route("/api/promo-codes/{id}/redemptions", get(promo_codes::list_redemptions)) | |
| 285 | + | .route("/api/promo-codes", post_csrf(promo_codes::create_promo_code)) | |
| 286 | + | .route_get("/api/promo-codes", get(promo_codes::list_promo_codes)) | |
| 287 | + | .route("/api/promo-codes/expired", delete_csrf(promo_codes::delete_expired_promo_codes)) | |
| 288 | + | .route("/api/promo-codes/{id}", put_csrf(promo_codes::update_promo_code)) | |
| 289 | + | .route("/api/promo-codes/{id}", delete_csrf(promo_codes::delete_promo_code)) | |
| 290 | + | .route_get("/api/promo-codes/{id}/redemptions", get(promo_codes::list_redemptions)) | |
| 287 | 291 | // Promo code claim (buyer — free_access codes) | |
| 288 | - | .route("/api/promo-codes/claim", post(promo_codes::claim_promo_code)) | |
| 292 | + | .route("/api/promo-codes/claim", post_csrf(promo_codes::claim_promo_code)) | |
| 289 | 293 | // Subscription tier management (creator) | |
| 290 | - | .route("/api/projects/{id}/tiers", post(subscriptions::create_tier)) | |
| 291 | - | .route("/api/tiers/{id}", put(subscriptions::update_tier)) | |
| 292 | - | .route("/api/tiers/{id}", delete(subscriptions::delete_tier)) | |
| 294 | + | .route("/api/projects/{id}/tiers", post_csrf(subscriptions::create_tier)) | |
| 295 | + | .route("/api/tiers/{id}", put_csrf(subscriptions::update_tier)) | |
| 296 | + | .route("/api/tiers/{id}", delete_csrf(subscriptions::delete_tier)) | |
| 293 | 297 | // Follow system | |
| 294 | - | .route("/api/follow/{target_type}/{target_id}", post(follows::follow_target)) | |
| 295 | - | .route("/api/follow/{target_type}/{target_id}", delete(follows::unfollow_target)) | |
| 298 | + | .route("/api/follow/{target_type}/{target_id}", post_csrf(follows::follow_target)) | |
| 299 | + | .route("/api/follow/{target_type}/{target_id}", delete_csrf(follows::unfollow_target)) | |
| 296 | 300 | // Category management | |
| 297 | - | .route("/api/categories", post(categories::create_category)) | |
| 301 | + | .route("/api/categories", post_csrf(categories::create_category)) | |
| 298 | 302 | // TOTP 2FA management | |
| 299 | - | .route("/api/users/me/totp/setup", post(totp::setup)) | |
| 300 | - | .route("/api/users/me/totp/confirm", post(totp::confirm)) | |
| 301 | - | .route("/api/users/me/totp/disable", post(totp::disable)) | |
| 302 | - | .route("/api/users/me/totp/backup-codes", post(totp::regenerate_backup_codes)) | |
| 303 | + | .route("/api/users/me/totp/setup", post_csrf(totp::setup)) | |
| 304 | + | .route("/api/users/me/totp/confirm", post_csrf(totp::confirm)) | |
| 305 | + | .route("/api/users/me/totp/disable", post_csrf(totp::disable)) | |
| 306 | + | .route("/api/users/me/totp/backup-codes", post_csrf(totp::regenerate_backup_codes)) | |
| 303 | 307 | // Passkey management | |
| 304 | - | .route("/api/users/me/passkeys/register/start", post(passkeys::register_start)) | |
| 305 | - | .route("/api/users/me/passkeys/register/finish", post(passkeys::register_finish)) | |
| 306 | - | .route("/api/users/me/passkeys/{id}", put(passkeys::rename)) | |
| 307 | - | .route("/api/users/me/passkeys/{id}", delete(passkeys::delete)) | |
| 308 | + | .route("/api/users/me/passkeys/register/start", post_csrf(passkeys::register_start)) | |
| 309 | + | .route("/api/users/me/passkeys/register/finish", post_csrf(passkeys::register_finish)) | |
| 310 | + | .route("/api/users/me/passkeys/{id}", put_csrf(passkeys::rename)) | |
| 311 | + | .route("/api/users/me/passkeys/{id}", delete_csrf(passkeys::delete)) | |
| 308 | 312 | // Content insertion management | |
| 309 | - | .route("/api/users/me/insertions/presign", post(content_insertions::presign_insertion)) | |
| 310 | - | .route("/api/users/me/insertions/confirm", post(content_insertions::confirm_insertion)) | |
| 311 | - | .route("/api/insertions/{id}", put(content_insertions::rename_insertion)) | |
| 312 | - | .route("/api/insertions/{id}", delete(content_insertions::delete_insertion)) | |
| 313 | + | .route("/api/users/me/insertions/presign", post_csrf(content_insertions::presign_insertion)) | |
| 314 | + | .route("/api/users/me/insertions/confirm", post_csrf(content_insertions::confirm_insertion)) | |
| 315 | + | .route("/api/insertions/{id}", put_csrf(content_insertions::rename_insertion)) | |
| 316 | + | .route("/api/insertions/{id}", delete_csrf(content_insertions::delete_insertion)) | |
| 313 | 317 | // Content insertion placements | |
| 314 | - | .route("/api/items/{id}/insertions", post(content_insertions::create_placement)) | |
| 315 | - | .route("/api/item-insertions/{id}", delete(content_insertions::delete_placement)) | |
| 318 | + | .route("/api/items/{id}/insertions", post_csrf(content_insertions::create_placement)) | |
| 319 | + | .route("/api/item-insertions/{id}", delete_csrf(content_insertions::delete_placement)) | |
| 316 | 320 | // SSH key management | |
| 317 | - | .route("/api/users/me/ssh-keys", get(ssh_keys::list_keys)) | |
| 318 | - | .route("/api/users/me/ssh-keys", post(ssh_keys::add_key)) | |
| 319 | - | .route("/api/users/me/ssh-keys/{id}", delete(ssh_keys::delete_key)) | |
| 321 | + | .route_get("/api/users/me/ssh-keys", get(ssh_keys::list_keys)) | |
| 322 | + | .route("/api/users/me/ssh-keys", post_csrf(ssh_keys::add_key)) | |
| 323 | + | .route("/api/users/me/ssh-keys/{id}", delete_csrf(ssh_keys::delete_key)) | |
| 320 | 324 | // Reports | |
| 321 | - | .route("/api/reports", post(reports::submit_report)) | |
| 325 | + | .route("/api/reports", post_csrf(reports::submit_report)) | |
| 322 | 326 | // Collections | |
| 323 | - | .route("/api/collections", post(collections::create_collection)) | |
| 324 | - | .route("/api/collections/{id}", put(collections::update_collection)) | |
| 325 | - | .route("/api/collections/{id}", delete(collections::delete_collection)) | |
| 326 | - | .route("/api/collections/{id}/items/{item_id}", post(collections::add_item)) | |
| 327 | - | .route("/api/collections/{id}/items/{item_id}", delete(collections::remove_item)) | |
| 328 | - | .route("/api/collections/{id}/items/reorder", put(collections::reorder_items)) | |
| 327 | + | .route("/api/collections", post_csrf(collections::create_collection)) | |
| 328 | + | .route("/api/collections/{id}", put_csrf(collections::update_collection)) | |
| 329 | + | .route("/api/collections/{id}", delete_csrf(collections::delete_collection)) | |
| 330 | + | .route("/api/collections/{id}/items/{item_id}", post_csrf(collections::add_item)) | |
| 331 | + | .route("/api/collections/{id}/items/{item_id}", delete_csrf(collections::remove_item)) | |
| 332 | + | .route("/api/collections/{id}/items/reorder", put_csrf(collections::reorder_items)) | |
| 329 | 333 | // Wishlists | |
| 330 | - | .route("/api/wishlists/{item_id}", post(wishlists::toggle_wishlist)) | |
| 334 | + | .route("/api/wishlists/{item_id}", post_csrf(wishlists::toggle_wishlist)) | |
| 331 | 335 | // Cart | |
| 332 | - | .route("/api/cart/{item_id}", post(cart::toggle_cart)) | |
| 333 | - | .route("/api/cart/{item_id}", put(cart::update_cart_amount)) | |
| 334 | - | .route("/api/cart/{item_id}", delete(cart::remove_from_cart)) | |
| 336 | + | .route("/api/cart/{item_id}", post_csrf(cart::toggle_cart)) | |
| 337 | + | .route("/api/cart/{item_id}", put_csrf(cart::update_cart_amount)) | |
| 338 | + | .route("/api/cart/{item_id}", delete_csrf(cart::remove_from_cart)) | |
| 335 | 339 | // Custom domains | |
| 336 | - | .route("/api/domains", post(domains::add_domain)) | |
| 337 | - | .route("/api/domains/verify", post(domains::verify_domain)) | |
| 338 | - | .route("/api/domains/{id}", delete(domains::remove_domain)) | |
| 340 | + | .route("/api/domains", post_csrf(domains::add_domain)) | |
| 341 | + | .route("/api/domains/verify", post_csrf(domains::verify_domain)) | |
| 342 | + | .route("/api/domains/{id}", delete_csrf(domains::remove_domain)) | |
| 339 | 343 | // Invite codes | |
| 340 | - | .route("/api/invites/create", post(users::create_invite)) | |
| 344 | + | .route("/api/invites/create", post_csrf(users::create_invite)) | |
| 341 | 345 | // Email signup (public, landing page notify-me) | |
| 342 | - | .route("/api/email-signup", post(email_signup)) | |
| 346 | + | .route("/api/email-signup", post_csrf_skip("pre-auth landing signup, no session", email_signup)) | |
| 343 | 347 | .route_layer(GovernorLayer { | |
| 344 | 348 | config: write_rate_limit, | |
| 345 | 349 | }); | |
| 346 | 350 | ||
| 347 | 351 | // Export routes — stricter rate limit | |
| 348 | - | let export_routes = Router::new() | |
| 349 | - | .route("/api/export/projects", post(exports::export_projects)) | |
| 350 | - | .route("/api/export/sales", post(exports::export_sales)) | |
| 351 | - | .route("/api/export/purchases", post(exports::export_purchases)) | |
| 352 | - | .route("/api/export/splits", post(exports::export_splits)) | |
| 353 | - | .route("/api/export/followers", post(exports::export_followers)) | |
| 354 | - | .route("/api/export/subscriptions", post(exports::export_subscriptions)) | |
| 355 | - | .route("/api/export/content", post(exports::export_content)) | |
| 356 | - | .route("/api/export/contacts", post(exports::export_contacts)) | |
| 352 | + | let export_routes = CsrfRouter::new() | |
| 353 | + | .route("/api/export/projects", post_csrf(exports::export_projects)) | |
| 354 | + | .route("/api/export/sales", post_csrf(exports::export_sales)) | |
| 355 | + | .route("/api/export/purchases", post_csrf(exports::export_purchases)) | |
| 356 | + | .route("/api/export/splits", post_csrf(exports::export_splits)) | |
| 357 | + | .route("/api/export/followers", post_csrf(exports::export_followers)) | |
| 358 | + | .route("/api/export/subscriptions", post_csrf(exports::export_subscriptions)) | |
| 359 | + | .route("/api/export/content", post_csrf(exports::export_content)) | |
| 360 | + | .route("/api/export/contacts", post_csrf(exports::export_contacts)) | |
| 357 | 361 | .route_layer(GovernorLayer { | |
| 358 | 362 | config: export_rate_limit, | |
| 359 | 363 | }); | |
| 360 | 364 | ||
| 361 | 365 | let key_rate_limit = crate::helpers::rate_limiter_ms(constants::LICENSE_KEY_RATE_LIMIT_MS, constants::LICENSE_KEY_RATE_LIMIT_BURST); | |
| 362 | 366 | ||
| 363 | - | let key_routes = Router::new() | |
| 364 | - | .route("/api/keys/validate", post(license_keys::validate_key)) | |
| 365 | - | .route("/api/v1/keys/validate", post(license_keys::validate_key)) | |
| 366 | - | .route("/api/keys/deactivate", post(license_keys::deactivate_key)) | |
| 367 | - | .route("/api/v1/keys/deactivate", post(license_keys::deactivate_key)) | |
| 368 | - | .route("/api/keys/{key_code}/status", get(license_keys::key_status)) | |
| 369 | - | .route("/api/v1/keys/{key_code}/status", get(license_keys::key_status)) | |
| 370 | - | .route("/api/v1/license/verify", post(license_keys::license_verify)) | |
| 371 | - | .route("/api/v1/license/deactivate", post(license_keys::license_deactivate)) | |
| 367 | + | let key_routes = CsrfRouter::new() | |
| 368 | + | .route("/api/keys/validate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::validate_key)) | |
| 369 | + | .route("/api/v1/keys/validate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::validate_key)) | |
| 370 | + | .route("/api/keys/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::deactivate_key)) | |
| 371 | + | .route("/api/v1/keys/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::deactivate_key)) | |
| 372 | + | .route_get("/api/keys/{key_code}/status", get(license_keys::key_status)) | |
| 373 | + | .route_get("/api/v1/keys/{key_code}/status", get(license_keys::key_status)) | |
| 374 | + | .route("/api/v1/license/verify", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::license_verify)) | |
| 375 | + | .route("/api/v1/license/deactivate", post_csrf_skip(LICENSE_BEARER_SKIP, license_keys::license_deactivate)) | |
| 372 | 376 | .route_layer(GovernorLayer { | |
| 373 | 377 | config: key_rate_limit, | |
| 374 | 378 | }); | |
| @@ -376,54 +380,54 @@ pub fn api_routes() -> Router<AppState> { | |||
| 376 | 380 | let read_rate_limit = crate::helpers::rate_limiter_ms(constants::API_READ_RATE_LIMIT_MS, constants::API_READ_RATE_LIMIT_BURST); | |
| 377 | 381 | ||
| 378 | 382 | // Read routes | |
| 379 | - | let read_routes = Router::new() | |
| 380 | - | .route("/api/public/projects", get(public_projects)) | |
| 381 | - | .route("/api/v1/public/projects", get(public_projects)) | |
| 382 | - | .route("/api/projects", get(projects::list_projects)) | |
| 383 | - | .route("/api/items/{id}/versions", get(items::list_versions)) | |
| 384 | - | .route("/api/items/{id}/chapters", get(items::list_chapters)) | |
| 385 | - | .route("/api/items/{id}/sections", get(items::list_sections)) | |
| 386 | - | .route("/api/items/{id}/keys", get(license_keys::list_keys)) | |
| 387 | - | .route("/api/projects/{id}/sections", get(project_sections::list_sections)) | |
| 388 | - | .route("/api/projects/{id}/blog", get(blog::list_blog_posts)) | |
| 389 | - | .route("/api/blog/{id}", get(blog::get_blog_post)) | |
| 390 | - | .route("/api/tags/search", get(tags::search_tags)) | |
| 391 | - | .route("/api/categories/search", get(categories::search_categories)) | |
| 392 | - | .route("/api/items/{id}/tag-suggestions", get(tags::suggest_tags)) | |
| 393 | - | .route("/api/projects/{id}/tiers", get(subscriptions::list_tiers)) | |
| 383 | + | let read_routes = CsrfRouter::new() | |
| 384 | + | .route_get("/api/public/projects", get(public_projects)) | |
| 385 | + | .route_get("/api/v1/public/projects", get(public_projects)) | |
| 386 | + | .route_get("/api/projects", get(projects::list_projects)) | |
| 387 | + | .route_get("/api/items/{id}/versions", get(items::list_versions)) | |
| 388 | + | .route_get("/api/items/{id}/chapters", get(items::list_chapters)) | |
| 389 | + | .route_get("/api/items/{id}/sections", get(items::list_sections)) | |
| 390 | + | .route_get("/api/items/{id}/keys", get(license_keys::list_keys)) | |
| 391 | + | .route_get("/api/projects/{id}/sections", get(project_sections::list_sections)) | |
| 392 | + | .route_get("/api/projects/{id}/blog", get(blog::list_blog_posts)) | |
| 393 | + | .route_get("/api/blog/{id}", get(blog::get_blog_post)) | |
| 394 | + | .route_get("/api/tags/search", get(tags::search_tags)) | |
| 395 | + | .route_get("/api/categories/search", get(categories::search_categories)) | |
| 396 | + | .route_get("/api/items/{id}/tag-suggestions", get(tags::suggest_tags)) | |
| 397 | + | .route_get("/api/projects/{id}/tiers", get(subscriptions::list_tiers)) | |
| 394 | 398 | // TOTP status (HTMX partial for dashboard) | |
| 395 | - | .route("/api/users/me/totp/status", get(totp::status)) | |
| 399 | + | .route_get("/api/users/me/totp/status", get(totp::status)) | |
| 396 | 400 | // Passkey list (HTMX partial for dashboard) | |
| 397 | - | .route("/api/users/me/passkeys", get(passkeys::list)) | |
| 401 | + | .route_get("/api/users/me/passkeys", get(passkeys::list)) | |
| 398 | 402 | // SSH key list (HTMX partial for dashboard) | |
| 399 | - | .route("/api/users/me/ssh-keys/list", get(ssh_keys::list_keys_html)) | |
| 403 | + | .route_get("/api/users/me/ssh-keys/list", get(ssh_keys::list_keys_html)) | |
| 400 | 404 | // Content insertion list (HTMX partials for dashboard) | |
| 401 | - | .route("/api/users/me/insertions", get(content_insertions::list_insertions)) | |
| 402 | - | .route("/api/items/{id}/insertions", get(content_insertions::list_placements)) | |
| 405 | + | .route_get("/api/users/me/insertions", get(content_insertions::list_insertions)) | |
| 406 | + | .route_get("/api/items/{id}/insertions", get(content_insertions::list_placements)) | |
| 403 | 407 | // Collections (read) | |
| 404 | - | .route("/api/collections/for-item/{item_id}", get(collections::collections_for_item)) | |
| 408 | + | .route_get("/api/collections/for-item/{item_id}", get(collections::collections_for_item)) | |
| 405 | 409 | // Custom domains (read) | |
| 406 | - | .route("/api/domains", get(domains::get_domain)) | |
| 407 | - | .route("/api/domains/caddy-ask", get(domains::caddy_ask)) | |
| 408 | - | .route("/api/restart-status", get(internal::restart_status)) | |
| 410 | + | .route_get("/api/domains", get(domains::get_domain)) | |
| 411 | + | .route_get("/api/domains/caddy-ask", get(domains::caddy_ask)) | |
| 412 | + | .route_get("/api/restart-status", get(internal::restart_status)) | |
| 409 | 413 | // Cart (read) | |
| 410 | - | .route("/api/cart/count", get(cart::cart_count)) | |
| 414 | + | .route_get("/api/cart/count", get(cart::cart_count)) | |
| 411 | 415 | // Import system (read) | |
| 412 | - | .route("/api/users/me/import/{id}", get(imports::get_import_status)) | |
| 413 | - | .route("/api/users/me/imports", get(imports::list_imports)) | |
| 416 | + | .route_get("/api/users/me/import/{id}", get(imports::get_import_status)) | |
| 417 | + | .route_get("/api/users/me/imports", get(imports::list_imports)) | |
| 414 | 418 | // License text (public) | |
| 415 | - | .route("/api/items/{id}/license.txt", get(license_keys::license_text)) | |
| 416 | - | .route("/api/v1/items/{id}/license.txt", get(license_keys::license_text)) | |
| 419 | + | .route_get("/api/items/{id}/license.txt", get(license_keys::license_text)) | |
| 420 | + | .route_get("/api/v1/items/{id}/license.txt", get(license_keys::license_text)) | |
| 417 | 421 | .route_layer(GovernorLayer { | |
| 418 | 422 | config: read_rate_limit, | |
| 419 | 423 | }); | |
| 420 | 424 | ||
| 421 | 425 | let validate_rate_limit = crate::helpers::rate_limiter_per_sec(constants::VALIDATE_RATE_LIMIT_PER_SEC, constants::VALIDATE_RATE_LIMIT_BURST); | |
| 422 | 426 | ||
| 423 | - | let validate_routes = Router::new() | |
| 424 | - | .route("/api/validate/project-slug", post(validate::validate_project_slug)) | |
| 425 | - | .route("/api/validate/collection-slug", post(validate::validate_collection_slug)) | |
| 426 | - | .route("/api/validate/blog-slug", post(validate::validate_blog_slug)) | |
| 427 | + | let validate_routes = CsrfRouter::new() | |
| 428 | + | .route("/api/validate/project-slug", post_csrf(validate::validate_project_slug)) | |
| 429 | + | .route("/api/validate/collection-slug", post_csrf(validate::validate_collection_slug)) | |
| 430 | + | .route("/api/validate/blog-slug", post_csrf(validate::validate_blog_slug)) | |
| 427 | 431 | .route_layer(GovernorLayer { | |
| 428 | 432 | config: validate_rate_limit, | |
| 429 | 433 | }); | |
| @@ -431,8 +435,8 @@ pub fn api_routes() -> Router<AppState> { | |||
| 431 | 435 | // Import route needs a higher body limit (base64-encoded CSV up to 10 MB | |
| 432 | 436 | // ≈ 14 MB encoded). The global 1 MB RequestBodyLimitLayer would reject it, | |
| 433 | 437 | // so we override with a per-route layer. | |
| 434 | - | let import_routes = Router::new() | |
| 435 | - | .route("/api/users/me/import", post(imports::start_import)) | |
| 438 | + | let import_routes = CsrfRouter::new() | |
| 439 | + | .route("/api/users/me/import", post_csrf(imports::start_import)) | |
| 436 | 440 | .layer(axum::extract::DefaultBodyLimit::max(15 * 1024 * 1024)) | |
| 437 | 441 | .route_layer(GovernorLayer { | |
| 438 | 442 | config: crate::helpers::rate_limiter_ms(constants::API_WRITE_RATE_LIMIT_MS, constants::API_WRITE_RATE_LIMIT_BURST), | |
| @@ -443,18 +447,18 @@ pub fn api_routes() -> Router<AppState> { | |||
| 443 | 447 | constants::GUEST_CHECKOUT_RATE_LIMIT_PER_SEC, | |
| 444 | 448 | constants::GUEST_CHECKOUT_RATE_LIMIT_BURST, | |
| 445 | 449 | ); | |
| 446 | - | let guest_routes = Router::new() | |
| 447 | - | .route("/api/checkout/guest/{item_id}", post(guest_checkout::create_guest_checkout)) | |
| 448 | - | .route("/api/checkout/guest/{item_id}", options(guest_checkout::guest_checkout_preflight)) | |
| 449 | - | .route("/api/checkout/guest-free/{item_id}", post(guest_checkout::claim_free_guest)) | |
| 450 | - | .route("/api/purchases/claim", post(guest_checkout::claim_purchase)) | |
| 450 | + | let guest_routes = CsrfRouter::new() | |
| 451 | + | .route("/api/checkout/guest/{item_id}", post_csrf_skip(GUEST_CHECKOUT_SKIP, guest_checkout::create_guest_checkout)) | |
| 452 | + | .route_get("/api/checkout/guest/{item_id}", options(guest_checkout::guest_checkout_preflight)) | |
| 453 | + | .route("/api/checkout/guest-free/{item_id}", post_csrf_skip(GUEST_CHECKOUT_SKIP, guest_checkout::claim_free_guest)) | |
| 454 | + | .route("/api/purchases/claim", post_csrf(guest_checkout::claim_purchase)) | |
| 451 | 455 | .route_layer(GovernorLayer { | |
| 452 | 456 | config: guest_checkout_rate_limit, | |
| 453 | 457 | }); | |
| 454 | 458 | ||
| 455 | 459 | // Guest download route — separate, more lenient rate limit | |
| 456 | - | let download_routes = Router::new() | |
| 457 | - | .route("/download/{token}", get(guest_checkout::guest_download)); | |
| 460 | + | let download_routes = CsrfRouter::new() | |
| 461 | + | .route_get("/download/{token}", get(guest_checkout::guest_download)); | |
| 458 | 462 | ||
| 459 | 463 | write_routes | |
| 460 | 464 | .merge(export_routes) |
| @@ -6,7 +6,7 @@ use axum::{ | |||
| 6 | 6 | http::{header::HeaderMap, StatusCode}, | |
| 7 | 7 | response::{Html, IntoResponse, Redirect, Response}, | |
| 8 | 8 | routing::{get, post}, | |
| 9 | - | Form, Router, | |
| 9 | + | Form, | |
| 10 | 10 | }; | |
| 11 | 11 | use serde::Deserialize; | |
| 12 | 12 | use tower_governor::GovernorLayer; | |
| @@ -14,6 +14,7 @@ use tower_sessions::{Expiry, Session}; | |||
| 14 | 14 | ||
| 15 | 15 | use crate::{ | |
| 16 | 16 | auth::{login_user, logout_user, track_session, verify_password, AuthUser, SessionUser, SESSION_TRACKING_KEY}, | |
| 17 | + | csrf::{post_csrf, with_csrf, with_csrf_skip, CsrfRouter}, | |
| 17 | 18 | constants::{self, MAX_LOGIN_ATTEMPTS, LOCKOUT_MINUTES}, | |
| 18 | 19 | db::{self, UserSessionId, Username}, | |
| 19 | 20 | email, | |
| @@ -31,28 +32,37 @@ static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| { | |||
| 31 | 32 | }); | |
| 32 | 33 | ||
| 33 | 34 | /// Register authentication routes with rate limiting. | |
| 34 | - | pub fn auth_routes() -> Router<AppState> { | |
| 35 | + | pub fn auth_routes() -> CsrfRouter<AppState> { | |
| 35 | 36 | let auth_rate_limit = rate_limiter_ms(constants::AUTH_RATE_LIMIT_MS, constants::AUTH_RATE_LIMIT_BURST); | |
| 36 | 37 | let validate_rate_limit = rate_limiter_per_sec(constants::VALIDATE_RATE_LIMIT_PER_SEC, constants::VALIDATE_RATE_LIMIT_BURST); | |
| 37 | 38 | ||
| 38 | - | Router::new() | |
| 39 | + | CsrfRouter::new() | |
| 39 | 40 | // GET /login is NOT rate-limited (page render for CSRF tokens). | |
| 40 | 41 | // POST /login and passkey routes ARE rate-limited. | |
| 41 | - | .route("/login", get(crate::routes::pages::public::landing::login_page) | |
| 42 | - | .post(login_handler.layer(GovernorLayer { config: auth_rate_limit.clone() }))) | |
| 43 | - | .route("/auth/passkey/start", post(passkey_auth_start) | |
| 44 | - | .layer(GovernorLayer { config: auth_rate_limit.clone() })) | |
| 45 | - | .route("/auth/passkey/finish", post(passkey_auth_finish) | |
| 46 | - | .layer(GovernorLayer { config: auth_rate_limit })) | |
| 42 | + | .route("/login", with_csrf_skip( | |
| 43 | + | "pre-auth login form — Phase 1 entry tracks moving to Manual", | |
| 44 | + | get(crate::routes::pages::public::landing::login_page) | |
| 45 | + | .post(login_handler.layer(GovernorLayer { config: auth_rate_limit.clone() })), | |
| 46 | + | )) | |
| 47 | + | .route("/auth/passkey/start", with_csrf_skip( | |
| 48 | + | "pre-auth WebAuthn challenge", | |
| 49 | + | post(passkey_auth_start) | |
| 50 | + | .layer(GovernorLayer { config: auth_rate_limit.clone() }), | |
| 51 | + | )) | |
| 52 | + | .route("/auth/passkey/finish", with_csrf_skip( | |
| 53 | + | "pre-auth WebAuthn assertion", | |
| 54 | + | post(passkey_auth_finish) | |
| 55 | + | .layer(GovernorLayer { config: auth_rate_limit }), | |
| 56 | + | )) | |
| 47 | 57 | // Routes without auth rate limiting | |
| 48 | - | .route("/logout", post(logout_handler)) | |
| 49 | - | .route("/auth/me", get(me_handler)) | |
| 58 | + | .route("/logout", post_csrf(logout_handler)) | |
| 59 | + | .route_get("/auth/me", get(me_handler)) | |
| 50 | 60 | // Username validation with its own rate limit | |
| 51 | 61 | .route( | |
| 52 | 62 | "/api/validate/username", | |
| 53 | - | post(validate_username).layer(GovernorLayer { | |
| 63 | + | with_csrf(post(validate_username).layer(GovernorLayer { | |
| 54 | 64 | config: validate_rate_limit, | |
| 55 | - | }), | |
| 65 | + | })), | |
| 56 | 66 | ) | |
| 57 | 67 | } | |
| 58 | 68 |
| @@ -8,7 +8,7 @@ use axum::{ | |||
| 8 | 8 | http::StatusCode, | |
| 9 | 9 | response::IntoResponse, | |
| 10 | 10 | routing::{get, post}, | |
| 11 | - | Json, Router, | |
| 11 | + | Json, | |
| 12 | 12 | }; | |
| 13 | 13 | use chrono::{DateTime, Utc}; | |
| 14 | 14 | use serde::{Deserialize, Serialize}; | |
| @@ -16,6 +16,7 @@ use tower_governor::GovernorLayer; | |||
| 16 | 16 | ||
| 17 | 17 | use crate::{ | |
| 18 | 18 | constants, | |
| 19 | + | csrf::{post_csrf_skip, with_csrf_skip, CsrfRouter}, | |
| 19 | 20 | db::{self, BuildConfigId, BuildId, BuildStatus, GitRepoId, OtaReleaseId, SyncAppId}, | |
| 20 | 21 | error::{AppError, Result}, | |
| 21 | 22 | synckit_auth::SyncUser, | |
| @@ -495,32 +496,33 @@ async fn hook_trigger( | |||
| 495 | 496 | // ── Router ── | |
| 496 | 497 | ||
| 497 | 498 | /// Build the build pipeline route tree. | |
| 498 | - | pub fn build_routes() -> Router<AppState> { | |
| 499 | + | pub fn build_routes() -> CsrfRouter<AppState> { | |
| 499 | 500 | let write_rate_limit = crate::helpers::rate_limiter_ms( | |
| 500 | 501 | constants::BUILD_WRITE_RATE_LIMIT_MS, | |
| 501 | 502 | constants::BUILD_WRITE_RATE_LIMIT_BURST, | |
| 502 | 503 | ); | |
| 503 | 504 | ||
| 504 | - | let mgmt_routes = Router::new() | |
| 505 | + | const SYNC_SKIP: &str = "synckit builds: bearer auth, no session"; | |
| 506 | + | let mgmt_routes = CsrfRouter::new() | |
| 505 | 507 | .route( | |
| 506 | 508 | "/api/sync/builds/apps/{app_id}/config", | |
| 507 | - | post(create_config).get(get_config).put(update_config).delete(delete_config), | |
| 509 | + | with_csrf_skip(SYNC_SKIP, post(create_config).get(get_config).put(update_config).delete(delete_config)), | |
| 508 | 510 | ) | |
| 509 | 511 | .route( | |
| 510 | 512 | "/api/sync/builds/apps/{app_id}/trigger", | |
| 511 | - | post(manual_trigger), | |
| 513 | + | post_csrf_skip(SYNC_SKIP, manual_trigger), | |
| 512 | 514 | ) | |
| 513 | - | .route( | |
| 515 | + | .route_get( | |
| 514 | 516 | "/api/sync/builds/apps/{app_id}/builds", | |
| 515 | 517 | get(list_builds), | |
| 516 | 518 | ) | |
| 517 | - | .route( | |
| 519 | + | .route_get( | |
| 518 | 520 | "/api/sync/builds/apps/{app_id}/builds/{build_id}", | |
| 519 | 521 | get(get_build), | |
| 520 | 522 | ) | |
| 521 | 523 | .route( | |
| 522 | 524 | "/api/sync/builds/apps/{app_id}/builds/{build_id}/cancel", | |
| 523 | - | post(cancel_build), | |
| 525 | + | post_csrf_skip(SYNC_SKIP, cancel_build), | |
| 524 | 526 | ) | |
| 525 | 527 | .route_layer(GovernorLayer { | |
| 526 | 528 | config: write_rate_limit, | |
| @@ -531,8 +533,8 @@ pub fn build_routes() -> Router<AppState> { | |||
| 531 | 533 | constants::BUILD_TRIGGER_RATE_LIMIT_BURST, | |
| 532 | 534 | ); | |
| 533 | 535 | ||
| 534 | - | let internal_routes = Router::new() | |
| 535 | - | .route("/api/internal/builds/trigger", post(hook_trigger)) | |
| 536 | + | let internal_routes = CsrfRouter::new() | |
| 537 | + | .route("/api/internal/builds/trigger", post_csrf_skip("internal CI hook: HMAC bearer auth", hook_trigger)) | |
| 536 | 538 | .route_layer(GovernorLayer { | |
| 537 | 539 | config: trigger_rate_limit, | |
| 538 | 540 | }); |
| @@ -5,28 +5,26 @@ mod issues; | |||
| 5 | 5 | mod push_refs; | |
| 6 | 6 | mod settings; | |
| 7 | 7 | ||
| 8 | - | use axum::{ | |
| 9 | - | routing::{get, post}, | |
| 10 | - | Router, | |
| 11 | - | }; | |
| 8 | + | use axum::routing::get; | |
| 12 | 9 | ||
| 13 | 10 | use crate::{ | |
| 11 | + | csrf::{post_csrf, post_csrf_skip, CsrfRouter}, | |
| 14 | 12 | routes::git::{resolve_repo, repos_root}, | |
| 15 | 13 | AppState, | |
| 16 | 14 | }; | |
| 17 | 15 | ||
| 18 | 16 | /// Register all git issue routes. | |
| 19 | - | pub fn git_issue_routes() -> Router<AppState> { | |
| 20 | - | Router::new() | |
| 17 | + | pub fn git_issue_routes() -> CsrfRouter<AppState> { | |
| 18 | + | CsrfRouter::new() | |
| 21 | 19 | // Issue list + detail (read-only) | |
| 22 | - | .route("/git/{owner}/{repo}/issues", get(issues::issue_list)) | |
| 23 | - | .route("/git/{owner}/{repo}/issues/{number}", get(issues::issue_detail)) | |
| 20 | + | .route_get("/git/{owner}/{repo}/issues", get(issues::issue_list)) | |
| 21 | + | .route_get("/git/{owner}/{repo}/issues/{number}", get(issues::issue_detail)) | |
| 24 | 22 | // Repo settings | |
| 25 | - | .route("/git/{owner}/{repo}/settings", get(settings::repo_settings_form)) | |
| 26 | - | .route("/git/{owner}/{repo}/settings", post(settings::repo_settings_save)) | |
| 27 | - | .route("/git/{owner}/{repo}/settings/delete", post(settings::repo_settings_delete)) | |
| 23 | + | .route_get("/git/{owner}/{repo}/settings", get(settings::repo_settings_form)) | |
| 24 | + | .route("/git/{owner}/{repo}/settings", post_csrf(settings::repo_settings_save)) | |
| 25 | + | .route("/git/{owner}/{repo}/settings/delete", post_csrf(settings::repo_settings_delete)) | |
| 28 | 26 | // Internal: commit-message issue references | |
| 29 | - | .route("/api/internal/issues/process-push", post(push_refs::process_push)) | |
| 27 | + | .route("/api/internal/issues/process-push", post_csrf_skip("internal git push hook, HMAC bearer", push_refs::process_push)) | |
| 30 | 28 | } | |
| 31 | 29 | ||
| 32 | 30 | /// Get the default branch name for a repo (for nav bar links). |
| @@ -8,9 +8,10 @@ use axum::{ | |||
| 8 | 8 | extract::{Query, State}, | |
| 9 | 9 | http::StatusCode, | |
| 10 | 10 | response::{IntoResponse, Redirect, Response}, | |
| 11 | - | routing::{get, post}, | |
| 12 | - | Form, Json, Router, | |
| 11 | + | routing::get, | |
| 12 | + | Form, Json, | |
| 13 | 13 | }; | |
| 14 | + | use crate::csrf::{post_csrf_skip, CsrfRouter}; | |
| 14 | 15 | use rand::RngCore; | |
| 15 | 16 | use serde::{Deserialize, Serialize}; | |
| 16 | 17 | use sha2::{Digest, Sha256}; | |
| @@ -558,24 +559,24 @@ async fn userinfo( | |||
| 558 | 559 | ||
| 559 | 560 | // ── Router ── | |
| 560 | 561 | ||
| 561 | - | pub fn oauth_routes() -> Router<AppState> { | |
| 562 | + | pub fn oauth_routes() -> CsrfRouter<AppState> { | |
| 562 | 563 | let authorize_rate_limit = crate::helpers::rate_limiter_ms(constants::OAUTH_RATE_LIMIT_MS, constants::OAUTH_RATE_LIMIT_BURST); | |
| 563 | 564 | let token_rate_limit = crate::helpers::rate_limiter_ms(constants::OAUTH_TOKEN_RATE_LIMIT_MS, constants::OAUTH_TOKEN_RATE_LIMIT_BURST); | |
| 564 | 565 | ||
| 565 | - | let authorize_routes = Router::new() | |
| 566 | - | .route("/oauth/authorize", get(authorize_get)) | |
| 567 | - | .route("/oauth/authorize", post(authorize_post)) | |
| 566 | + | let authorize_routes = CsrfRouter::new() | |
| 567 | + | .route_get("/oauth/authorize", get(authorize_get)) | |
| 568 | + | .route("/oauth/authorize", post_csrf_skip("pre-auth OAuth authorize endpoint", authorize_post)) | |
| 568 | 569 | .route_layer(GovernorLayer { | |
| 569 | 570 | config: authorize_rate_limit, | |
| 570 | 571 | }); | |
| 571 | 572 | ||
| 572 | - | let token_routes = Router::new() | |
| 573 | - | .route("/oauth/token", post(token_exchange)) | |
| 573 | + | let token_routes = CsrfRouter::new() | |
| 574 | + | .route("/oauth/token", post_csrf_skip("pre-auth OAuth token exchange", token_exchange)) | |
| 574 | 575 | .route_layer(GovernorLayer { | |
| 575 | 576 | config: token_rate_limit, | |
| 576 | 577 | }); | |
| 577 | 578 | ||
| 578 | 579 | authorize_routes | |
| 579 | 580 | .merge(token_routes) | |
| 580 | - | .route("/oauth/userinfo", get(userinfo)) | |
| 581 | + | .route_get("/oauth/userinfo", get(userinfo)) | |
| 581 | 582 | } |
| @@ -8,8 +8,8 @@ | |||
| 8 | 8 | use axum::{ | |
| 9 | 9 | extract::{Path, State}, | |
| 10 | 10 | response::IntoResponse, | |
| 11 | - | routing::{delete, get, post, put}, | |
| 12 | - | Json, Router, | |
| 11 | + | routing::{get, post}, | |
| 12 | + | Json, | |
| 13 | 13 | }; | |
| 14 | 14 | use chrono::{DateTime, Utc}; | |
| 15 | 15 | use serde::{Deserialize, Serialize}; | |
| @@ -17,6 +17,7 @@ use tower_governor::GovernorLayer; | |||
| 17 | 17 | ||
| 18 | 18 | use crate::{ | |
| 19 | 19 | constants, | |
| 20 | + | csrf::{delete_csrf_skip, post_csrf_skip, put_csrf_skip, with_csrf_skip, CsrfRouter}, | |
| 20 | 21 | db::{self, OtaReleaseId, SyncAppId}, | |
| 21 | 22 | error::{AppError, Result}, | |
| 22 | 23 | synckit_auth::SyncUser, | |
| @@ -422,38 +423,39 @@ async fn artifact_download( | |||
| 422 | 423 | /// | |
| 423 | 424 | /// Management routes use SyncKit JWT auth, rate-limited at write tier. | |
| 424 | 425 | /// Public routes (updater check, download) are unauthenticated, rate-limited at read tier. | |
| 425 | - | pub fn ota_routes() -> Router<AppState> { | |
| 426 | + | pub fn ota_routes() -> CsrfRouter<AppState> { | |
| 426 | 427 | let write_rate_limit = crate::helpers::rate_limiter_ms( | |
| 427 | 428 | constants::OTA_WRITE_RATE_LIMIT_MS, | |
| 428 | 429 | constants::OTA_WRITE_RATE_LIMIT_BURST, | |
| 429 | 430 | ); | |
| 430 | 431 | ||
| 431 | - | let mgmt_routes = Router::new() | |
| 432 | - | .route("/api/sync/ota/apps/{app_id}/slug", put(set_slug)) | |
| 433 | - | .route("/api/v1/sync/ota/apps/{app_id}/slug", put(set_slug)) | |
| 432 | + | const OTA_SKIP: &str = "synckit OTA: bearer auth, no session"; | |
| 433 | + | let mgmt_routes = CsrfRouter::new() | |
| 434 | + | .route("/api/sync/ota/apps/{app_id}/slug", put_csrf_skip(OTA_SKIP, set_slug)) | |
| 435 | + | .route("/api/v1/sync/ota/apps/{app_id}/slug", put_csrf_skip(OTA_SKIP, set_slug)) | |
| 434 | 436 | .route( | |
| 435 | 437 | "/api/sync/ota/apps/{app_id}/releases", | |
| 436 | - | post(create_release).get(list_releases), | |
| 438 | + | with_csrf_skip(OTA_SKIP, post(create_release).get(list_releases)), | |
| 437 | 439 | ) | |
| 438 | 440 | .route( | |
| 439 | 441 | "/api/v1/sync/ota/apps/{app_id}/releases", | |
| 440 | - | post(create_release).get(list_releases), | |
| 442 | + | with_csrf_skip(OTA_SKIP, post(create_release).get(list_releases)), | |
| 441 | 443 | ) | |
| 442 | 444 | .route( | |
| 443 | 445 | "/api/sync/ota/apps/{app_id}/releases/{release_id}", | |
| 444 | - | delete(delete_release_handler), | |
| 446 | + | delete_csrf_skip(OTA_SKIP, delete_release_handler), | |
| 445 | 447 | ) | |
| 446 | 448 | .route( | |
| 447 | 449 | "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}", | |
| 448 | - | delete(delete_release_handler), | |
| 450 | + | delete_csrf_skip(OTA_SKIP, delete_release_handler), | |
| 449 | 451 | ) | |
| 450 | 452 | .route( | |
| 451 | 453 | "/api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts", | |
| 452 | - | post(upload_artifact), | |
| 454 | + | post_csrf_skip(OTA_SKIP, upload_artifact), | |
| 453 | 455 | ) | |
| 454 | 456 | .route( | |
| 455 | 457 | "/api/v1/sync/ota/apps/{app_id}/releases/{release_id}/artifacts", | |
| 456 | - | post(upload_artifact), | |
| 458 | + | post_csrf_skip(OTA_SKIP, upload_artifact), | |
| 457 | 459 | ) | |
| 458 | 460 | .route_layer(GovernorLayer { | |
| 459 | 461 | config: write_rate_limit, | |
| @@ -464,20 +466,20 @@ pub fn ota_routes() -> Router<AppState> { | |||
| 464 | 466 | constants::OTA_READ_RATE_LIMIT_BURST, | |
| 465 | 467 | ); | |
| 466 | 468 | ||
| 467 | - | let public_routes = Router::new() | |
| 468 | - | .route( | |
| 469 | + | let public_routes = CsrfRouter::new() | |
| 470 | + | .route_get( | |
| 469 | 471 | "/api/sync/ota/{slug}/{target}/{arch}/{current_version}", | |
| 470 | 472 | get(updater_check), | |
| 471 | 473 | ) | |
| 472 | - | .route( | |
| 474 | + | .route_get( | |
| 473 | 475 | "/api/v1/sync/ota/{slug}/{target}/{arch}/{current_version}", | |
| 474 | 476 | get(updater_check), | |
| 475 | 477 | ) | |
| 476 | - | .route( | |
| 478 | + | .route_get( | |
| 477 | 479 | "/api/sync/ota/{slug}/download/{release_id}/{target}/{arch}", | |
| 478 | 480 | get(artifact_download), | |
| 479 | 481 | ) | |
| 480 | - | .route( | |
| 482 | + | .route_get( | |
| 481 | 483 | "/api/v1/sync/ota/{slug}/download/{release_id}/{target}/{arch}", | |
| 482 | 484 | get(artifact_download), | |
| 483 | 485 | ) |
| @@ -6,11 +6,11 @@ mod project_tabs; | |||
| 6 | 6 | mod tabs; | |
| 7 | 7 | pub mod wizards; | |
| 8 | 8 | ||
| 9 | - | use axum::{routing::{get, post}, Router}; | |
| 9 | + | use axum::routing::get; | |
| 10 | 10 | use serde::Deserialize; | |
| 11 | 11 | use tower_governor::GovernorLayer; | |
| 12 | 12 | ||
| 13 | - | use crate::{constants, db, db::Cents, types::*, AppState}; | |
| 13 | + | use crate::{constants, csrf::{post_csrf, CsrfRouter}, db, db::Cents, types::*, AppState}; | |
| 14 | 14 | ||
| 15 | 15 | /// Query parameters for analytics time range selection. | |
| 16 | 16 | #[derive(Deserialize)] | |
| @@ -38,63 +38,63 @@ pub(super) fn build_chart_bars(buckets: &[db::analytics::TimeBucket]) -> Vec<Cha | |||
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | 40 | /// Register dashboard page routes. | |
| 41 | - | pub fn dashboard_routes() -> Router<AppState> { | |
| 41 | + | pub fn dashboard_routes() -> CsrfRouter<AppState> { | |
| 42 | 42 | let read_rate_limit = crate::helpers::rate_limiter_ms( | |
| 43 | 43 | constants::DASHBOARD_READ_RATE_LIMIT_MS, | |
| 44 | 44 | constants::DASHBOARD_READ_RATE_LIMIT_BURST, | |
| 45 | 45 | ); | |
| 46 | 46 | ||
| 47 | 47 | // Tab endpoints — rate limited to prevent rapid polling | |
| 48 | - | let tab_routes = Router::new() | |
| 49 | - | .route("/dashboard/tabs/details", get(tabs::dashboard_tab_details)) | |
| 50 | - | .route("/dashboard/tabs/settings", get(tabs::dashboard_tab_settings)) | |
| 51 | - | .route("/dashboard/tabs/profile", get(tabs::dashboard_tab_profile)) | |
| 52 | - | .route("/dashboard/tabs/account", get(tabs::dashboard_tab_account)) | |
| 53 | - | .route("/dashboard/tabs/payments", get(tabs::dashboard_tab_payments)) | |
| 54 | - | .route("/dashboard/tabs/projects", get(tabs::dashboard_tab_projects)) | |
| 55 | - | .route("/dashboard/tabs/creator", get(tabs::dashboard_tab_creator)) | |
| 56 | - | .route("/dashboard/tabs/analytics", get(tabs::dashboard_tab_analytics)) | |
| 57 | - | .route("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit)) | |
| 58 | - | .route("/dashboard/tabs/forums", get(tabs::dashboard_tab_forums)) | |
| 59 | - | .route("/dashboard/tabs/media", get(tabs::dashboard_tab_media)) | |
| 60 | - | .route("/dashboard/tabs/ssh-keys", get(tabs::dashboard_tab_ssh_keys)) | |
| 61 | - | .route("/dashboard/tabs/support", get(tabs::dashboard_tab_support)) | |
| 62 | - | .route("/dashboard/tabs/contacts", get(tabs::dashboard_tab_contacts)) | |
| 63 | - | .route("/dashboard/transactions", get(tabs::dashboard_transactions)) | |
| 64 | - | .route("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview)) | |
| 65 | - | .route("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content)) | |
| 66 | - | .route("/dashboard/project/{slug}/tabs/analytics", get(project_tabs::project_tab_analytics)) | |
| 67 | - | .route("/dashboard/project/{slug}/tabs/code", get(project_tabs::project_tab_code)) | |
| 68 | - | .route("/dashboard/project/{slug}/tabs/settings", get(project_tabs::project_tab_settings)) | |
| 69 | - | .route("/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog)) | |
| 70 | - | .route("/dashboard/project/{slug}/tabs/monetization", get(project_tabs::project_tab_monetization)) | |
| 71 | - | .route("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions)) | |
| 72 | - | .route("/dashboard/project/{slug}/tabs/subscriptions", get(project_tabs::project_tab_subscriptions)) | |
| 73 | - | .route("/dashboard/project/{slug}/tabs/members", get(project_tabs::project_tab_members)) | |
| 74 | - | .route("/dashboard/project/{slug}/tabs/synckit", get(project_tabs::project_tab_synckit)) | |
| 75 | - | .route("/dashboard/item/{id}/tabs/overview", get(tabs::item_tab_overview)) | |
| 76 | - | .route("/dashboard/item/{id}/tabs/details", get(tabs::item_tab_details)) | |
| 77 | - | .route("/dashboard/item/{id}/tabs/pricing", get(tabs::item_tab_pricing)) | |
| 78 | - | .route("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files)) | |
| 79 | - | .route("/dashboard/item/{id}/tabs/settings", get(tabs::item_tab_settings)) | |
| 80 | - | .route("/dashboard/item/{id}/tabs/sales", get(tabs::item_tab_sales)) | |
| 81 | - | .route("/dashboard/item/{id}/tabs/embed", get(tabs::item_tab_embed)) | |
| 82 | - | .route("/dashboard/item/{id}/analytics", get(main::dashboard_item_analytics)) | |
| 48 | + | let tab_routes = CsrfRouter::new() | |
| 49 | + | .route_get("/dashboard/tabs/details", get(tabs::dashboard_tab_details)) | |
| 50 | + | .route_get("/dashboard/tabs/settings", get(tabs::dashboard_tab_settings)) | |
| 51 | + | .route_get("/dashboard/tabs/profile", get(tabs::dashboard_tab_profile)) | |
| 52 | + | .route_get("/dashboard/tabs/account", get(tabs::dashboard_tab_account)) | |
| 53 | + | .route_get("/dashboard/tabs/payments", get(tabs::dashboard_tab_payments)) | |
| 54 | + | .route_get("/dashboard/tabs/projects", get(tabs::dashboard_tab_projects)) | |
| 55 | + | .route_get("/dashboard/tabs/creator", get(tabs::dashboard_tab_creator)) | |
| 56 | + | .route_get("/dashboard/tabs/analytics", get(tabs::dashboard_tab_analytics)) | |
| 57 | + | .route_get("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit)) | |
| 58 | + | .route_get("/dashboard/tabs/forums", get(tabs::dashboard_tab_forums)) | |
| 59 | + | .route_get("/dashboard/tabs/media", get(tabs::dashboard_tab_media)) | |
| 60 | + | .route_get("/dashboard/tabs/ssh-keys", get(tabs::dashboard_tab_ssh_keys)) | |
| 61 | + | .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support)) | |
| 62 | + | .route_get("/dashboard/tabs/contacts", get(tabs::dashboard_tab_contacts)) | |
| 63 | + | .route_get("/dashboard/transactions", get(tabs::dashboard_transactions)) | |
| 64 | + | .route_get("/dashboard/project/{slug}/tabs/overview", get(project_tabs::project_tab_overview)) | |
| 65 | + | .route_get("/dashboard/project/{slug}/tabs/content", get(project_tabs::project_tab_content)) | |
| 66 | + | .route_get("/dashboard/project/{slug}/tabs/analytics", get(project_tabs::project_tab_analytics)) | |
| 67 | + | .route_get("/dashboard/project/{slug}/tabs/code", get(project_tabs::project_tab_code)) | |
| 68 | + | .route_get("/dashboard/project/{slug}/tabs/settings", get(project_tabs::project_tab_settings)) | |
| 69 | + | .route_get("/dashboard/project/{slug}/tabs/blog", get(project_tabs::project_tab_blog)) | |
| 70 | + | .route_get("/dashboard/project/{slug}/tabs/monetization", get(project_tabs::project_tab_monetization)) | |
| 71 | + | .route_get("/dashboard/project/{slug}/tabs/promotions", get(project_tabs::project_tab_promotions)) | |
| 72 | + | .route_get("/dashboard/project/{slug}/tabs/subscriptions", get(project_tabs::project_tab_subscriptions)) | |
| 73 | + | .route_get("/dashboard/project/{slug}/tabs/members", get(project_tabs::project_tab_members)) | |
| 74 | + | .route_get("/dashboard/project/{slug}/tabs/synckit", get(project_tabs::project_tab_synckit)) | |
| 75 | + | .route_get("/dashboard/item/{id}/tabs/overview", get(tabs::item_tab_overview)) | |
| 76 | + | .route_get("/dashboard/item/{id}/tabs/details", get(tabs::item_tab_details)) | |
| 77 | + | .route_get("/dashboard/item/{id}/tabs/pricing", get(tabs::item_tab_pricing)) | |
| 78 | + | .route_get("/dashboard/item/{id}/tabs/files", get(tabs::item_tab_files)) | |
| 79 | + | .route_get("/dashboard/item/{id}/tabs/settings", get(tabs::item_tab_settings)) | |
| 80 | + | .route_get("/dashboard/item/{id}/tabs/sales", get(tabs::item_tab_sales)) | |
| 81 | + | .route_get("/dashboard/item/{id}/tabs/embed", get(tabs::item_tab_embed)) | |
| 82 | + | .route_get("/dashboard/item/{id}/analytics", get(main::dashboard_item_analytics)) | |
| 83 | 83 | .route_layer(GovernorLayer { config: read_rate_limit }); | |
| 84 | 84 | ||
| 85 | - | Router::new() | |
| 85 | + | CsrfRouter::new() | |
| 86 | 86 | .merge(wizards::wizard_routes()) | |
| 87 | - | .route("/dashboard", get(main::dashboard)) | |
| 88 | - | .route("/dashboard/project/{slug}", get(main::dashboard_project)) | |
| 89 | - | .route("/dashboard/item/{id}", get(main::dashboard_item)) | |
| 87 | + | .route_get("/dashboard", get(main::dashboard)) | |
| 88 | + | .route_get("/dashboard/project/{slug}", get(main::dashboard_project)) | |
| 89 | + | .route_get("/dashboard/item/{id}", get(main::dashboard_item)) | |
| 90 | 90 | .merge(tab_routes) | |
| 91 | - | .route("/dashboard/item/{id}/edit-row", get(forms::item_edit_row)) | |
| 92 | - | .route("/dashboard/project/{slug}/blog/new", get(forms::blog_editor)) | |
| 93 | - | .route("/dashboard/export", get(forms::export_portal)) | |
| 94 | - | .route("/dashboard/import", get(forms::import_portal)) | |
| 95 | - | .route("/dashboard/delete-account", get(forms::delete_account_page)) | |
| 96 | - | .route("/dashboard/onboarding/dismiss", post(main::dismiss_onboarding)) | |
| 97 | - | .route("/dashboard/onboarding/restore", post(main::restore_onboarding)) | |
| 91 | + | .route_get("/dashboard/item/{id}/edit-row", get(forms::item_edit_row)) | |
| 92 | + | .route_get("/dashboard/project/{slug}/blog/new", get(forms::blog_editor)) | |
| 93 | + | .route_get("/dashboard/export", get(forms::export_portal)) | |
| 94 | + | .route_get("/dashboard/import", get(forms::import_portal)) | |
| 95 | + | .route_get("/dashboard/delete-account", get(forms::delete_account_page)) | |
| 96 | + | .route("/dashboard/onboarding/dismiss", post_csrf(main::dismiss_onboarding)) | |
| 97 | + | .route("/dashboard/onboarding/restore", post_csrf(main::restore_onboarding)) | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | 100 | /// Query parameters for filtering dashboard transactions. |
| @@ -8,8 +8,11 @@ | |||
| 8 | 8 | pub mod item; | |
| 9 | 9 | pub mod project; | |
| 10 | 10 | ||
| 11 | - | use axum::{routing::get, Router}; | |
| 12 | - | use crate::AppState; | |
| 11 | + | use axum::routing::get; | |
| 12 | + | use crate::{ | |
| 13 | + | csrf::{post_csrf, with_csrf, CsrfRouter}, | |
| 14 | + | AppState, | |
| 15 | + | }; | |
| 13 | 16 | ||
| 14 | 17 | /// Step navigation helpers shared by both wizards. | |
| 15 | 18 | pub fn next_step<'a>(steps: &'a [&str], current: &str) -> Option<&'a str> { | |
| @@ -52,29 +55,29 @@ pub fn build_step_nav( | |||
| 52 | 55 | } | |
| 53 | 56 | ||
| 54 | 57 | /// Register all wizard routes. | |
| 55 | - | pub fn wizard_routes() -> Router<AppState> { | |
| 56 | - | Router::new() | |
| 58 | + | pub fn wizard_routes() -> CsrfRouter<AppState> { | |
| 59 | + | CsrfRouter::new() | |
| 57 | 60 | // Project wizard | |
| 58 | - | .route("/dashboard/new-project", get(project::wizard_page)) | |
| 61 | + | .route_get("/dashboard/new-project", get(project::wizard_page)) | |
| 59 | 62 | .route( | |
| 60 | 63 | "/dashboard/new-project/step/basics", | |
| 61 | - | axum::routing::post(project::step_basics_create), | |
| 64 | + | post_csrf(project::step_basics_create), | |
| 62 | 65 | ) | |
| 63 | 66 | .route( | |
| 64 | 67 | "/dashboard/new-project/{slug}/step/{step}", | |
| 65 | - | get(project::step_load).post(project::step_save), | |
| 68 | + | with_csrf(get(project::step_load).post(project::step_save)), | |
| 66 | 69 | ) | |
| 67 | 70 | // Item wizard | |
| 68 | - | .route( | |
| 71 | + | .route_get( | |
| 69 | 72 | "/dashboard/project/{slug}/new-item", | |
| 70 | 73 | get(item::wizard_page), | |
| 71 | 74 | ) | |
| 72 | 75 | .route( | |
| 73 | 76 | "/dashboard/project/{slug}/new-item/step/type", | |
| 74 | - | axum::routing::post(item::step_type_create), | |
| 77 | + | post_csrf(item::step_type_create), | |
| 75 | 78 | ) | |
| 76 | 79 | .route( | |
| 77 | 80 | "/dashboard/project/{slug}/new-item/{id}/step/{step}", | |
| 78 | - | get(item::step_load).post(item::step_save), | |
| 81 | + | with_csrf(get(item::step_load).post(item::step_save)), | |
| 79 | 82 | ) | |
| 80 | 83 | } |
| @@ -12,9 +12,9 @@ use crate::AppState; | |||
| 12 | 12 | ||
| 13 | 13 | pub fn page_routes() -> Router<AppState> { | |
| 14 | 14 | Router::new() | |
| 15 | - | .merge(public::public_routes()) | |
| 15 | + | .merge(public::public_routes().finalize()) | |
| 16 | 16 | .merge(sandbox::sandbox_routes()) | |
| 17 | - | .merge(dashboard::dashboard_routes()) | |
| 17 | + | .merge(dashboard::dashboard_routes().finalize()) | |
| 18 | 18 | .merge(email_actions::email_action_routes()) | |
| 19 | 19 | .merge(feeds::feed_routes()) | |
| 20 | 20 | .merge(blog::blog_routes()) |
| @@ -13,13 +13,13 @@ use axum::{ | |||
| 13 | 13 | extract::State, | |
| 14 | 14 | response::IntoResponse, | |
| 15 | 15 | routing::get, | |
| 16 | - | Router, | |
| 17 | 16 | }; | |
| 18 | 17 | use tower_sessions::Session; | |
| 19 | 18 | ||
| 20 | 19 | use crate::{ | |
| 21 | 20 | auth::MaybeUserUnverified, | |
| 22 | 21 | constants, | |
| 22 | + | csrf::{post_csrf_skip, with_csrf_skip, CsrfRouter}, | |
| 23 | 23 | db, | |
| 24 | 24 | error::Result, | |
| 25 | 25 | helpers::get_csrf_token, | |
| @@ -31,62 +31,71 @@ use crate::{ | |||
| 31 | 31 | use tower_governor::GovernorLayer; | |
| 32 | 32 | ||
| 33 | 33 | /// Register public page routes. | |
| 34 | - | pub fn public_routes() -> Router<AppState> { | |
| 34 | + | pub fn public_routes() -> CsrfRouter<AppState> { | |
| 35 | 35 | let twofa_rate_limit = crate::helpers::rate_limiter_ms(constants::TWO_FACTOR_RATE_LIMIT_MS, constants::TWO_FACTOR_RATE_LIMIT_BURST); | |
| 36 | 36 | let join_rate_limit = crate::helpers::rate_limiter_ms(constants::AUTH_RATE_LIMIT_MS, constants::AUTH_RATE_LIMIT_BURST); | |
| 37 | 37 | ||
| 38 | - | Router::new() | |
| 39 | - | .route("/", get(landing::index)) | |
| 40 | - | .route("/library", get(landing::library)) | |
| 41 | - | .route("/cart", get(landing::cart_page)) | |
| 42 | - | .route("/library/tabs/purchases", get(landing::library_tab_purchases)) | |
| 43 | - | .route("/library/tabs/feed", get(landing::library_tab_feed)) | |
| 44 | - | .route("/library/tabs/collections", get(landing::library_tab_collections)) | |
| 45 | - | .route("/library/tabs/contacts", get(landing::library_tab_contacts)) | |
| 46 | - | .route("/library/tabs/communities", get(landing::library_tab_communities)) | |
| 47 | - | .route("/health", get(health::health)) | |
| 48 | - | .route("/api/health", get(health::health_json)) | |
| 38 | + | CsrfRouter::new() | |
| 39 | + | .route_get("/", get(landing::index)) | |
| 40 | + | .route_get("/library", get(landing::library)) | |
| 41 | + | .route_get("/cart", get(landing::cart_page)) | |
| 42 | + | .route_get("/library/tabs/purchases", get(landing::library_tab_purchases)) | |
| 43 | + | .route_get("/library/tabs/feed", get(landing::library_tab_feed)) | |
| 44 | + | .route_get("/library/tabs/collections", get(landing::library_tab_collections)) | |
| 45 | + | .route_get("/library/tabs/contacts", get(landing::library_tab_contacts)) | |
| 46 | + | .route_get("/library/tabs/communities", get(landing::library_tab_communities)) | |
| 47 | + | .route_get("/health", get(health::health)) | |
| 48 | + | .route_get("/api/health", get(health::health_json)) | |
| 49 | 49 | // NOTE: GET /login is registered in auth_routes() alongside POST /login | |
| 50 | 50 | // to avoid Axum merge conflicts that strip rate limiting layers. | |
| 51 | 51 | // Join wizard | |
| 52 | - | .route("/join", get(join_wizard::wizard_page)) | |
| 52 | + | .route_get("/join", get(join_wizard::wizard_page)) | |
| 53 | 53 | .route( | |
| 54 | 54 | "/join/step/account", | |
| 55 | - | axum::routing::post(join_wizard::step_account_create) | |
| 55 | + | post_csrf_skip( | |
| 56 | + | "join-wizard step 1: pre-auth signup", | |
| 57 | + | join_wizard::step_account_create, | |
| 58 | + | ) | |
| 56 | 59 | .layer(GovernorLayer { config: join_rate_limit }), | |
| 57 | 60 | ) | |
| 58 | 61 | .route( | |
| 59 | 62 | "/join/step/{step}", | |
| 60 | - | get(join_wizard::step_load).post(join_wizard::step_save), | |
| 63 | + | with_csrf_skip( | |
| 64 | + | "join-wizard: continuation of pre-auth flow", | |
| 65 | + | get(join_wizard::step_load).post(join_wizard::step_save), | |
| 66 | + | ), | |
| 61 | 67 | ) | |
| 62 | - | .route("/discover", get(discover::discover)) | |
| 63 | - | .route("/discover/results", get(discover::discover_results)) | |
| 64 | - | .route("/discover/suggestions", get(discover::search_suggestions_handler)) | |
| 65 | - | .route("/discover/tags", get(discover::tag_tree)) | |
| 66 | - | .route("/feed", get(feed::feed_page)) | |
| 67 | - | .route("/u/{username}", get(content::user_page)) | |
| 68 | - | .route("/c/{username}/{slug}", get(content::collection_page)) | |
| 69 | - | .route("/p/{slug}", get(content::project_page)) | |
| 70 | - | .route("/i/{item_id}", get(content::item_page)) | |
| 71 | - | .route("/l/{item_id}", get(content::library_page)) | |
| 72 | - | .route("/purchase/{item_id}", get(content::purchase_page)) | |
| 73 | - | .route("/receipt/{transaction_id}", get(content::receipt_page)) | |
| 74 | - | .route("/buy/{item_id}", get(content::buy_page)) | |
| 75 | - | .route("/pricing", get(landing::pricing_page)) | |
| 76 | - | .route("/checkout/complete", get(landing::checkout_complete)) | |
| 77 | - | .route("/use-cases", get(landing::use_cases_page)) | |
| 78 | - | .route("/team", get(landing::team_page)) | |
| 79 | - | .route("/policy", get(landing::policy_page)) | |
| 80 | - | .route("/fan-plus", get(landing::fan_plus_page)) | |
| 81 | - | .route("/creators", get(creators_page)) | |
| 82 | - | .route("/docs", get(docs::docs_index)) | |
| 83 | - | .route("/docs/search.json", get(docs::docs_search_index)) | |
| 84 | - | .route("/docs/{slug}", get(docs::doc_page)) | |
| 68 | + | .route_get("/discover", get(discover::discover)) | |
| 69 | + | .route_get("/discover/results", get(discover::discover_results)) | |
| 70 | + | .route_get("/discover/suggestions", get(discover::search_suggestions_handler)) | |
| 71 | + | .route_get("/discover/tags", get(discover::tag_tree)) | |
| 72 | + | .route_get("/feed", get(feed::feed_page)) | |
| 73 | + | .route_get("/u/{username}", get(content::user_page)) | |
| 74 | + | .route_get("/c/{username}/{slug}", get(content::collection_page)) | |
| 75 | + | .route_get("/p/{slug}", get(content::project_page)) | |
| 76 | + | .route_get("/i/{item_id}", get(content::item_page)) | |
| 77 | + | .route_get("/l/{item_id}", get(content::library_page)) | |
| 78 | + | .route_get("/purchase/{item_id}", get(content::purchase_page)) | |
| 79 | + | .route_get("/receipt/{transaction_id}", get(content::receipt_page)) | |
| 80 | + | .route_get("/buy/{item_id}", get(content::buy_page)) | |
| 81 | + | .route_get("/pricing", get(landing::pricing_page)) | |
| 82 | + | .route_get("/checkout/complete", get(landing::checkout_complete)) | |
| 83 | + | .route_get("/use-cases", get(landing::use_cases_page)) | |
| 84 | + | .route_get("/team", get(landing::team_page)) | |
| 85 | + | .route_get("/policy", get(landing::policy_page)) | |
| 86 | + | .route_get("/fan-plus", get(landing::fan_plus_page)) | |
| 87 | + | .route_get("/creators", get(creators_page)) | |
| 88 | + | .route_get("/docs", get(docs::docs_index)) | |
| 89 | + | .route_get("/docs/search.json", get(docs::docs_search_index)) | |
| 90 | + | .route_get("/docs/{slug}", get(docs::doc_page)) | |
| 85 | 91 | // Two-factor authentication | |
| 86 | - | .route("/auth/2fa", get(two_factor::two_factor_page)) | |
| 92 | + | .route_get("/auth/2fa", get(two_factor::two_factor_page)) | |
| 87 | 93 | .route( | |
| 88 | 94 | "/auth/verify-2fa", | |
| 89 | - | axum::routing::post(two_factor::verify_two_factor) | |
| 95 | + | post_csrf_skip( | |
| 96 | + | "2FA verification: pre-promotion to full auth, no session yet", | |
| 97 | + | two_factor::verify_two_factor, | |
| 98 | + | ) | |
| 90 | 99 | .layer(GovernorLayer { config: twofa_rate_limit }), | |
| 91 | 100 | ) | |
| 92 | 101 | } |
| @@ -7,12 +7,14 @@ use axum::{ | |||
| 7 | 7 | extract::State, | |
| 8 | 8 | http::{HeaderMap, StatusCode}, | |
| 9 | 9 | response::IntoResponse, | |
| 10 | - | routing::post, | |
| 11 | - | Json, Router, | |
| 10 | + | Json, | |
| 12 | 11 | }; | |
| 13 | 12 | use serde::Deserialize; | |
| 14 | 13 | ||
| 15 | - | use crate::{db, AppState}; | |
| 14 | + | use crate::{ | |
| 15 | + | csrf::{post_csrf_skip, CsrfRouter}, | |
| 16 | + | db, AppState, | |
| 17 | + | }; | |
| 16 | 18 | ||
| 17 | 19 | /// Subset of a Postmark webhook payload we care about. | |
| 18 | 20 | #[derive(Debug, Deserialize)] | |
| @@ -132,9 +134,9 @@ async fn postmark_webhook( | |||
| 132 | 134 | } | |
| 133 | 135 | ||
| 134 | 136 | /// Register Postmark webhook routes. | |
| 135 | - | pub fn postmark_routes() -> Router<AppState> { | |
| 136 | - | Router::new() | |
| 137 | - | .route("/postmark/webhook", post(postmark_webhook)) | |
| 138 | - | .route("/postmark/inbound", post(patches::postmark_inbound)) | |
| 139 | - | .route("/postmark/inbound-issues", post(issues::postmark_inbound_issues)) | |
| 137 | + | pub fn postmark_routes() -> CsrfRouter<AppState> { | |
| 138 | + | CsrfRouter::new() | |
| 139 | + | .route("/postmark/webhook", post_csrf_skip("webhook: postmark signature verified in handler", postmark_webhook)) | |
| 140 | + | .route("/postmark/inbound", post_csrf_skip("webhook: postmark inbound, signature verified in handler", patches::postmark_inbound)) | |
| 141 | + | .route("/postmark/inbound-issues", post_csrf_skip("webhook: postmark inbound, signature verified in handler", issues::postmark_inbound_issues)) | |
| 140 | 142 | } |
| @@ -6,13 +6,14 @@ pub(crate) mod media; | |||
| 6 | 6 | mod uploads; | |
| 7 | 7 | mod versions; | |
| 8 | 8 | ||
| 9 | - | use axum::{routing::{delete, get, post}, Router}; | |
| 9 | + | use axum::routing::get; | |
| 10 | 10 | use serde::Serialize; | |
| 11 | 11 | use tower_governor::GovernorLayer; | |
| 12 | 12 | use uuid::Uuid; | |
| 13 | 13 | ||
| 14 | 14 | use crate::{ | |
| 15 | 15 | constants, | |
| 16 | + | csrf::{delete_csrf, post_csrf, CsrfRouter}, | |
| 16 | 17 | db, | |
| 17 | 18 | db::scan_jobs::ScanTargetKind, | |
| 18 | 19 | error::Result, | |
| @@ -20,36 +21,55 @@ use crate::{ | |||
| 20 | 21 | AppState, | |
| 21 | 22 | }; | |
| 22 | 23 | ||
| 24 | + | /// Enqueue an orphaned S3 key for the pending-deletion worker. | |
| 25 | + | /// | |
| 26 | + | /// Single S3-orphan policy: every error/race path in the storage handlers | |
| 27 | + | /// routes its cleanup through `pending_s3_deletions` rather than calling | |
| 28 | + | /// `delete_object().await.ok()` inline. The queue worker is the only path | |
| 29 | + | /// that actually deletes — failures persist as queue rows with attempt counts | |
| 30 | + | /// instead of disappearing into a swallowed `Result`. | |
| 31 | + | pub(crate) async fn enqueue_s3_orphan(pool: &sqlx::PgPool, s3_key: &str, source: &'static str) { | |
| 32 | + | if let Err(e) = db::pending_s3_deletions::enqueue_deletions( | |
| 33 | + | pool, | |
| 34 | + | &[(s3_key.to_string(), "main".to_string())], | |
| 35 | + | source, | |
| 36 | + | ) | |
| 37 | + | .await | |
| 38 | + | { | |
| 39 | + | tracing::warn!(error = ?e, key = %s3_key, source = %source, "failed to enqueue orphan S3 key"); | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 23 | 43 | /// Register S3 upload and streaming routes. | |
| 24 | 44 | /// | |
| 25 | 45 | /// Upload routes (presign + confirm) are rate limited per IP (see `constants::UPLOAD_RATE_LIMIT_*`). | |
| 26 | 46 | /// Stream/download endpoints are unlimited (presigned URLs already expire in 1 hour). | |
| 27 | - | pub fn storage_routes() -> Router<AppState> { | |
| 47 | + | pub fn storage_routes() -> CsrfRouter<AppState> { | |
| 28 | 48 | let upload_rate_limit = crate::helpers::rate_limiter_ms(constants::UPLOAD_RATE_LIMIT_MS, constants::UPLOAD_RATE_LIMIT_BURST); | |
| 29 | 49 | ||
| 30 | - | let upload_routes = Router::new() | |
| 31 | - | .route("/api/upload/presign", post(uploads::presign_upload)) | |
| 32 | - | .route("/api/upload/confirm", post(uploads::confirm_upload)) | |
| 33 | - | .route("/api/versions/{version_id}/upload/presign", post(versions::version_presign_upload)) | |
| 34 | - | .route("/api/versions/{version_id}/upload/confirm", post(versions::version_confirm_upload)) | |
| 35 | - | .route("/api/projects/image/presign", post(images::project_image_presign)) | |
| 36 | - | .route("/api/projects/image/confirm", post(images::project_image_confirm)) | |
| 37 | - | .route("/api/items/image/presign", post(images::item_image_presign)) | |
| 38 | - | .route("/api/items/image/confirm", post(images::item_image_confirm)) | |
| 39 | - | .route("/api/media/presign", post(media::media_presign)) | |
| 40 | - | .route("/api/media/confirm", post(media::media_confirm)) | |
| 41 | - | .route("/api/media", get(media::media_list)) | |
| 42 | - | .route("/api/media/folders", get(media::media_folders)) | |
| 43 | - | .route("/api/media/{id}", delete(media::media_delete)) | |
| 50 | + | let upload_routes = CsrfRouter::new() | |
| 51 | + | .route("/api/upload/presign", post_csrf(uploads::presign_upload)) | |
| 52 | + | .route("/api/upload/confirm", post_csrf(uploads::confirm_upload)) | |
| 53 | + | .route("/api/versions/{version_id}/upload/presign", post_csrf(versions::version_presign_upload)) | |
| 54 | + | .route("/api/versions/{version_id}/upload/confirm", post_csrf(versions::version_confirm_upload)) | |
| 55 | + | .route("/api/projects/image/presign", post_csrf(images::project_image_presign)) | |
| 56 | + | .route("/api/projects/image/confirm", post_csrf(images::project_image_confirm)) | |
| 57 | + | .route("/api/items/image/presign", post_csrf(images::item_image_presign)) | |
| 58 | + | .route("/api/items/image/confirm", post_csrf(images::item_image_confirm)) | |
| 59 | + | .route("/api/media/presign", post_csrf(media::media_presign)) | |
| 60 | + | .route("/api/media/confirm", post_csrf(media::media_confirm)) | |
| 61 | + | .route_get("/api/media", get(media::media_list)) | |
| 62 | + | .route_get("/api/media/folders", get(media::media_folders)) | |
| 63 | + | .route("/api/media/{id}", delete_csrf(media::media_delete)) | |
| 44 | 64 | .route_layer(GovernorLayer { | |
| 45 | 65 | config: upload_rate_limit, | |
| 46 | 66 | }); | |
| 47 | 67 | ||
| 48 | 68 | let stream_rate_limit = crate::helpers::rate_limiter_ms(constants::STREAM_RATE_LIMIT_MS, constants::STREAM_RATE_LIMIT_BURST); | |
| 49 | 69 | ||
| 50 | - | let stream_routes = Router::new() | |
| 51 | - | .route("/api/stream/{item_id}", get(downloads::stream_url)) | |
| 52 | - | .route("/api/versions/{version_id}/download", get(downloads::version_download)) | |
| 70 | + | let stream_routes = CsrfRouter::new() | |
| 71 | + | .route_get("/api/stream/{item_id}", get(downloads::stream_url)) | |
| 72 | + | .route_get("/api/versions/{version_id}/download", get(downloads::version_download)) | |
| 53 | 73 | .route_layer(GovernorLayer { | |
| 54 | 74 | config: stream_rate_limit, | |
| 55 | 75 | }); |
| @@ -2,13 +2,16 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | 4 | extract::{Path, State}, | |
| 5 | + | http::HeaderMap, | |
| 5 | 6 | response::{IntoResponse, Redirect}, | |
| 6 | 7 | Form, | |
| 7 | 8 | }; | |
| 8 | 9 | use serde::Deserialize; | |
| 10 | + | use tower_sessions::Session; | |
| 9 | 11 | ||
| 10 | 12 | use crate::{ | |
| 11 | 13 | auth::AuthUser, | |
| 14 | + | csrf, | |
| 12 | 15 | db::{self, Cents}, | |
| 13 | 16 | error::{AppError, Result}, | |
| 14 | 17 | payments, | |
| @@ -24,6 +27,13 @@ pub(in crate::routes::stripe) struct TipForm { | |||
| 24 | 27 | pub message: Option<String>, | |
| 25 | 28 | /// Project ID if tipping from a project page. | |
| 26 | 29 | pub project_id: Option<String>, | |
| 30 | + | /// CSRF token. The `/stripe/checkout` prefix is broadly exempt because | |
| 31 | + | /// most routes there only construct a Stripe session URL (state lives | |
| 32 | + | /// post-webhook), but this tip route inserts a `pending_tip` row BEFORE | |
| 33 | + | /// the Stripe call, so it gets the explicit check the rest of the | |
| 34 | + | /// family doesn't. The tip form already renders this field. | |
| 35 | + | #[serde(rename = "_csrf")] | |
| 36 | + | pub csrf: Option<String>, | |
| 27 | 37 | } | |
| 28 | 38 | ||
| 29 | 39 | /// POST /stripe/checkout/tip/{recipient_id} - Create a tip checkout session | |
| @@ -31,9 +41,21 @@ pub(in crate::routes::stripe) struct TipForm { | |||
| 31 | 41 | pub(in crate::routes::stripe) async fn create_tip_checkout( | |
| 32 | 42 | State(state): State<AppState>, | |
| 33 | 43 | AuthUser(user): AuthUser, | |
| 44 | + | session: Session, | |
| 45 | + | headers: HeaderMap, | |
| 34 | 46 | Path(recipient_id): Path<String>, | |
| 35 | 47 | Form(form): Form<TipForm>, | |
| 36 | 48 | ) -> Result<impl IntoResponse> { | |
| 49 | + | // Registered with `post_csrf_manual` because this handler inserts a | |
| 50 | + | // `pending_tip` row before the Stripe call — the broad `/stripe/checkout` | |
| 51 | + | // skip would let an attacker plant rows. Match the standard validator's | |
| 52 | + | // header-then-form precedence so HTMX callers and vanilla form posts | |
| 53 | + | // both pass. `validate_token_consuming` returns the sealed witness on | |
| 54 | + | // success; the binding is `_` because the witness exists only to prove | |
| 55 | + | // the check happened, not to be passed downstream. | |
| 56 | + | let token = csrf::extract_token_from_request(&headers, form.csrf.as_deref()) | |
| 57 | + | .unwrap_or_default(); | |
| 58 | + | let _validated = csrf::validate_token_consuming(&session, &token).await?; | |
| 37 | 59 | user.check_not_sandbox()?; | |
| 38 | 60 | user.check_not_suspended()?; | |
| 39 | 61 | let stripe = state.stripe.as_ref() |
| @@ -10,37 +10,50 @@ mod webhook_v2; | |||
| 10 | 10 | pub(crate) use checkout::grant_bundle_items; | |
| 11 | 11 | pub(crate) use webhook::process_webhook_event; | |
| 12 | 12 | ||
| 13 | - | use axum::{ | |
| 14 | - | routing::{get, post}, | |
| 15 | - | Router, | |
| 13 | + | use axum::routing::get; | |
| 14 | + | ||
| 15 | + | use crate::{ | |
| 16 | + | csrf::{post_csrf, post_csrf_manual, post_csrf_skip, CsrfRouter}, | |
| 17 | + | AppState, | |
| 16 | 18 | }; | |
| 17 | 19 | ||
| 18 | - | use crate::AppState; | |
| 20 | + | /// Reason string for routes that only construct a Stripe Checkout Session URL. | |
| 21 | + | /// State mutation happens server-side via the Stripe webhook, not in these | |
| 22 | + | /// handlers. The `tip` route is the documented exception — it inserts a | |
| 23 | + | /// `pending_tip` row BEFORE the Stripe call, so it uses Manual posture. | |
| 24 | + | const STRIPE_SESSION_SKIP: &str = | |
| 25 | + | "Stripe Checkout Session constructor — no mutation until webhook"; | |
| 19 | 26 | ||
| 20 | 27 | /// Register Stripe Connect, Checkout, and webhook routes. | |
| 21 | - | pub fn stripe_routes() -> Router<AppState> { | |
| 22 | - | Router::new() | |
| 28 | + | pub fn stripe_routes() -> CsrfRouter<AppState> { | |
| 29 | + | CsrfRouter::new() | |
| 23 | 30 | // Creator onboarding (Account Links flow) | |
| 24 | - | .route("/stripe/connect", get(connect::stripe_connect_disclaimer)) | |
| 25 | - | .route("/stripe/connect/proceed", post(connect::stripe_connect_proceed)) | |
| 26 | - | .route("/stripe/connect/return", get(connect::stripe_connect_return)) | |
| 27 | - | .route("/stripe/connect/refresh", get(connect::stripe_connect_refresh)) | |
| 31 | + | .route_get("/stripe/connect", get(connect::stripe_connect_disclaimer)) | |
| 32 | + | .route("/stripe/connect/proceed", post_csrf(connect::stripe_connect_proceed)) | |
| 33 | + | .route_get("/stripe/connect/return", get(connect::stripe_connect_return)) | |
| 34 | + | .route_get("/stripe/connect/refresh", get(connect::stripe_connect_refresh)) | |
| 28 | 35 | // Checkout flow (idempotency handled by global middleware in metrics.rs) | |
| 29 | - | .route("/stripe/fan-plus", post(checkout::create_fan_plus_checkout)) | |
| 30 | - | .route("/stripe/fan-plus/cancel", post(checkout::cancel_fan_plus)) | |
| 31 | - | .route("/stripe/fan-plus/resume", post(checkout::resume_fan_plus)) | |
| 32 | - | .route("/stripe/billing-portal", post(checkout::open_billing_portal)) | |
| 33 | - | .route("/stripe/creator-tier", post(checkout::create_creator_tier_checkout)) | |
| 34 | - | .route("/stripe/checkout/{item_id}", post(checkout::create_checkout)) | |
| 35 | - | .route("/stripe/checkout/{item_id}/cancel-pending", post(checkout::cancel_pending_item_checkout)) | |
| 36 | - | .route("/stripe/checkout/project/{project_id}", post(checkout::create_project_checkout)) | |
| 37 | - | .route("/stripe/subscribe/{tier_id}", post(checkout::create_subscription_checkout)) | |
| 38 | - | .route("/stripe/checkout/tip/{recipient_id}", post(checkout::create_tip_checkout)) | |
| 39 | - | .route("/stripe/checkout/cart", post(checkout::create_cart_checkout)) | |
| 40 | - | .route("/stripe/checkout/cart/all", post(checkout::create_cart_checkout_all)) | |
| 41 | - | .route("/stripe/success", get(checkout::checkout_success)) | |
| 42 | - | .route("/stripe/cancel", get(checkout::checkout_cancel)) | |
| 36 | + | .route("/stripe/fan-plus", post_csrf(checkout::create_fan_plus_checkout)) | |
| 37 | + | .route("/stripe/fan-plus/cancel", post_csrf(checkout::cancel_fan_plus)) | |
| 38 | + | .route("/stripe/fan-plus/resume", post_csrf(checkout::resume_fan_plus)) | |
| 39 | + | .route("/stripe/billing-portal", post_csrf(checkout::open_billing_portal)) | |
| 40 | + | .route("/stripe/creator-tier", post_csrf(checkout::create_creator_tier_checkout)) | |
| 41 | + | .route("/stripe/checkout/{item_id}", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_checkout)) | |
| 42 | + | // cancel-pending was in the old allowlist; behavior-preserving Skip. | |
| 43 | + | // The Phase 1 entry "cancel_pending_item_checkout CSRF gap" tracks | |
| 44 | + | // moving this to `post_csrf` once the form renders the token. | |
| 45 | + | .route("/stripe/checkout/{item_id}/cancel-pending", post_csrf_skip("Phase 1 todo: tighten to post_csrf", checkout::cancel_pending_item_checkout)) | |
| 46 | + | .route("/stripe/checkout/project/{project_id}", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_project_checkout)) | |
| 47 | + | .route("/stripe/subscribe/{tier_id}", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_subscription_checkout)) | |
| 48 | + | .route("/stripe/checkout/tip/{recipient_id}", post_csrf_manual( | |
| 49 | + | "inserts pending_tip row before Stripe call — handler validates _csrf", | |
| 50 | + | checkout::create_tip_checkout, | |
| 51 | + | )) | |
| 52 | + | .route("/stripe/checkout/cart", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_cart_checkout)) | |
| 53 | + | .route("/stripe/checkout/cart/all", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_cart_checkout_all)) | |
| 54 | + | .route_get("/stripe/success", get(checkout::checkout_success)) | |
| 55 | + | .route_get("/stripe/cancel", get(checkout::checkout_cancel)) | |
| 43 | 56 | // Webhooks | |
| 44 | - | .route("/stripe/webhook", post(webhook::webhook)) | |
| 45 | - | .route("/stripe/webhook/v2", post(webhook_v2::webhook_v2)) | |
| 57 | + | .route("/stripe/webhook", post_csrf_skip("webhook: stripe signature verified in handler", webhook::webhook)) | |
| 58 | + | .route("/stripe/webhook/v2", post_csrf_skip("webhook: stripe signature verified in handler", webhook_v2::webhook_v2)) | |
| 46 | 59 | } |
| @@ -22,20 +22,25 @@ pub(crate) mod keys; | |||
| 22 | 22 | mod subscribe; | |
| 23 | 23 | pub(crate) mod sync; | |
| 24 | 24 | ||
| 25 | - | use axum::{ | |
| 26 | - | routing::{delete, get, post, put}, | |
| 27 | - | Router, | |
| 28 | - | }; | |
| 25 | + | use axum::routing::get; | |
| 29 | 26 | use chrono::{DateTime, Utc}; | |
| 30 | 27 | use serde::{Deserialize, Serialize}; | |
| 31 | 28 | use tower_governor::GovernorLayer; | |
| 32 | 29 | ||
| 33 | 30 | use crate::{ | |
| 34 | 31 | constants, | |
| 32 | + | csrf::{delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf, put_csrf_skip, CsrfRouter}, | |
| 35 | 33 | db::{self, SyncAppId, SyncDeviceId, SyncOperation, SyncPlatform, UserId}, | |
| 36 | 34 | AppState, | |
| 37 | 35 | }; | |
| 38 | 36 | ||
| 37 | + | /// Reason strings for synckit CSRF Skip routes. The auth_routes and | |
| 38 | + | /// sync_routes blocks use server-to-server or JWT bearer auth with no | |
| 39 | + | /// session cookie; CSRF doesn't apply. The app_routes block IS | |
| 40 | + | /// session-authed (dashboard-driven) so those use `post_csrf` etc. | |
| 41 | + | const SYNCKIT_API_KEY_SKIP: &str = "synckit server-to-server: api_key auth, no session"; | |
| 42 | + | const SYNCKIT_JWT_SKIP: &str = "synckit JWT bearer auth (SyncUser), no session"; | |
| 43 | + | ||
| 39 | 44 | // ── Request/Response types ── | |
| 40 | 45 | ||
| 41 | 46 | #[derive(Deserialize, utoipa::ToSchema)] | |
| @@ -525,23 +530,23 @@ pub(super) fn generate_api_key() -> String { | |||
| 525 | 530 | /// - **App management routes** (`/api/sync/apps/...`): Session-based auth | |
| 526 | 531 | /// via `AuthUser` extractor (accessed from the MNW dashboard), no extra | |
| 527 | 532 | /// rate limit beyond the global middleware. | |
| 528 | - | pub fn synckit_routes() -> Router<AppState> { | |
| 533 | + | pub fn synckit_routes() -> CsrfRouter<AppState> { | |
| 529 | 534 | let auth_rate_limit = crate::helpers::rate_limiter_per_sec(constants::SYNCKIT_AUTH_RATE_LIMIT_PER_SEC, constants::SYNCKIT_AUTH_RATE_LIMIT_BURST); | |
| 530 | 535 | ||
| 531 | - | let auth_routes = Router::new() | |
| 532 | - | .route("/api/sync/auth", post(auth::sync_auth)) | |
| 533 | - | .route("/api/v1/sync/auth", post(auth::sync_auth)) | |
| 534 | - | .route("/api/sync/validate-app", post(auth::validate_app)) | |
| 535 | - | .route("/api/v1/sync/validate-app", post(auth::validate_app)) | |
| 536 | + | let auth_routes = CsrfRouter::new() | |
| 537 | + | .route("/api/sync/auth", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth)) | |
| 538 | + | .route("/api/v1/sync/auth", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::sync_auth)) | |
| 539 | + | .route("/api/sync/validate-app", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app)) | |
| 540 | + | .route("/api/v1/sync/validate-app", post_csrf_skip(SYNCKIT_API_KEY_SKIP, auth::validate_app)) | |
| 536 | 541 | // Server-to-server SDK key claim/release/list (api_key in body, no JWT). | |
| 537 | - | .route("/api/sync/keys/claim", post(keys::claim)) | |
| 538 | - | .route("/api/v1/sync/keys/claim", post(keys::claim)) | |
| 539 | - | .route("/api/sync/keys/release", post(keys::release)) | |
| 540 | - | .route("/api/v1/sync/keys/release", post(keys::release)) | |
| 541 | - | .route("/api/sync/keys/list", post(keys::list)) | |
| 542 | - | .route("/api/v1/sync/keys/list", post(keys::list)) | |
| 543 | - | .route("/api/sync/app/pricing", post(sync::get_app_pricing)) | |
| 544 | - | .route("/api/v1/sync/app/pricing", post(sync::get_app_pricing)) | |
| 542 | + | .route("/api/sync/keys/claim", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::claim)) | |
| 543 | + | .route("/api/v1/sync/keys/claim", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::claim)) | |
| 544 | + | .route("/api/sync/keys/release", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::release)) | |
| 545 | + | .route("/api/v1/sync/keys/release", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::release)) | |
| 546 | + | .route("/api/sync/keys/list", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::list)) | |
| 547 | + | .route("/api/v1/sync/keys/list", post_csrf_skip(SYNCKIT_API_KEY_SKIP, keys::list)) | |
| 548 | + | .route("/api/sync/app/pricing", post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing)) | |
| 549 | + | .route("/api/v1/sync/app/pricing", post_csrf_skip(SYNCKIT_API_KEY_SKIP, sync::get_app_pricing)) | |
| 545 | 550 | .route_layer(GovernorLayer { | |
| 546 | 551 | config: auth_rate_limit, | |
| 547 | 552 | }); | |
| @@ -549,51 +554,51 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 549 | 554 | let sync_ip_rate_limit = crate::helpers::rate_limiter_ms(constants::SYNCKIT_SYNC_RATE_LIMIT_MS, constants::SYNCKIT_SYNC_RATE_LIMIT_BURST); | |
| 550 | 555 | let sync_app_rate_limit = crate::helpers::synckit_app_rate_limiter_ms(constants::SYNCKIT_APP_RATE_LIMIT_MS, constants::SYNCKIT_APP_RATE_LIMIT_BURST); | |
| 551 | 556 | ||
| 552 | - | let sync_routes = Router::new() | |
| 553 | - | .route("/api/sync/push", post(sync::sync_push)) | |
| 554 | - | .route("/api/v1/sync/push", post(sync::sync_push)) | |
| 555 | - | .route("/api/sync/pull", post(sync::sync_pull)) | |
| 556 | - | .route("/api/v1/sync/pull", post(sync::sync_pull)) | |
| 557 | - | .route("/api/sync/subscribe", get(subscribe::sync_subscribe)) | |
| 558 | - | .route("/api/v1/sync/subscribe", get(subscribe::sync_subscribe)) | |
| 559 | - | .route("/api/sync/status", get(sync::sync_status)) | |
| 560 | - | .route("/api/v1/sync/status", get(sync::sync_status)) | |
| 561 | - | .route("/api/sync/account", get(sync::sync_account)) | |
| 562 | - | .route("/api/v1/sync/account", get(sync::sync_account)) | |
| 563 | - | .route("/api/sync/subscription", get(sync::sync_subscription_status)) | |
| 564 | - | .route("/api/v1/sync/subscription", get(sync::sync_subscription_status)) | |
| 565 | - | .route("/api/sync/subscription/quote", post(sync::quote_subscription_price)) | |
| 566 | - | .route("/api/v1/sync/subscription/quote", post(sync::quote_subscription_price)) | |
| 567 | - | .route("/api/sync/subscription/checkout", post(sync::create_subscription_checkout)) | |
| 568 | - | .route("/api/v1/sync/subscription/checkout", post(sync::create_subscription_checkout)) | |
| 569 | - | .route("/api/sync/subscription/storage-cap", post(sync::queue_storage_cap_change)) | |
| 570 | - | .route("/api/v1/sync/subscription/storage-cap", post(sync::queue_storage_cap_change)) | |
| 571 | - | .route("/api/sync/devices", post(sync::register_device)) | |
| 572 | - | .route("/api/v1/sync/devices", post(sync::register_device)) | |
| 573 | - | .route("/api/sync/devices", get(sync::list_devices)) | |
| 574 | - | .route("/api/v1/sync/devices", get(sync::list_devices)) | |
| 575 | - | .route("/api/sync/devices/{id}", delete(sync::delete_device)) | |
| 576 | - | .route("/api/v1/sync/devices/{id}", delete(sync::delete_device)) | |
| 577 | - | .route("/api/sync/keys", put(sync::put_sync_key)) | |
| 578 | - | .route("/api/v1/sync/keys", put(sync::put_sync_key)) | |
| 579 | - | .route("/api/sync/keys", get(sync::get_sync_key)) | |
| 580 | - | .route("/api/v1/sync/keys", get(sync::get_sync_key)) | |
| 581 | - | .route("/api/sync/keys/rotate", post(sync::begin_rotation)) | |
| 582 | - | .route("/api/v1/sync/keys/rotate", post(sync::begin_rotation)) | |
| 583 | - | .route("/api/sync/keys/rotate", delete(sync::cancel_rotation)) | |
| 584 | - | .route("/api/v1/sync/keys/rotate", delete(sync::cancel_rotation)) | |
| 585 | - | .route("/api/sync/keys/rotate/entries", post(sync::rotation_entries)) | |
| 586 | - | .route("/api/v1/sync/keys/rotate/entries", post(sync::rotation_entries)) | |
| 587 | - | .route("/api/sync/keys/rotate/batch", post(sync::rotation_batch)) | |
| 588 | - | .route("/api/v1/sync/keys/rotate/batch", post(sync::rotation_batch)) | |
| 589 | - | .route("/api/sync/keys/rotate/complete", post(sync::complete_rotation)) | |
| 590 | - | .route("/api/v1/sync/keys/rotate/complete", post(sync::complete_rotation)) | |
| 591 | - | .route("/api/sync/blobs/upload", post(blobs::blob_upload_url)) | |
| 592 | - | .route("/api/v1/sync/blobs/upload", post(blobs::blob_upload_url)) | |
| 593 | - | .route("/api/sync/blobs/confirm", post(blobs::blob_confirm_upload)) | |
| 594 | - | .route("/api/v1/sync/blobs/confirm", post(blobs::blob_confirm_upload)) | |
| 595 | - | .route("/api/sync/blobs/download", post(blobs::blob_download_url)) | |
| 596 | - | .route("/api/v1/sync/blobs/download", post(blobs::blob_download_url)) | |
| 557 | + | let sync_routes = CsrfRouter::new() | |
| 558 | + | .route("/api/sync/push", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push)) | |
| 559 | + | .route("/api/v1/sync/push", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_push)) | |
| 560 | + | .route("/api/sync/pull", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull)) | |
| 561 | + | .route("/api/v1/sync/pull", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull)) | |
| 562 | + | .route_get("/api/sync/subscribe", get(subscribe::sync_subscribe)) | |
| 563 | + | .route_get("/api/v1/sync/subscribe", get(subscribe::sync_subscribe)) | |
| 564 | + | .route_get("/api/sync/status", get(sync::sync_status)) | |
| 565 | + | .route_get("/api/v1/sync/status", get(sync::sync_status)) | |
| 566 | + | .route_get("/api/sync/account", get(sync::sync_account)) | |
| 567 | + | .route_get("/api/v1/sync/account", get(sync::sync_account)) | |
| 568 | + | .route_get("/api/sync/subscription", get(sync::sync_subscription_status)) | |
| 569 | + | .route_get("/api/v1/sync/subscription", get(sync::sync_subscription_status)) | |
| 570 | + | .route("/api/sync/subscription/quote", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price)) | |
| 571 | + | .route("/api/v1/sync/subscription/quote", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::quote_subscription_price)) | |
| 572 | + | .route("/api/sync/subscription/checkout", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout)) | |
| 573 | + | .route("/api/v1/sync/subscription/checkout", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::create_subscription_checkout)) | |
| 574 | + | .route("/api/sync/subscription/storage-cap", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change)) | |
| 575 | + | .route("/api/v1/sync/subscription/storage-cap", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::queue_storage_cap_change)) | |
| 576 | + | .route("/api/sync/devices", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device)) | |
| 577 | + | .route("/api/v1/sync/devices", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::register_device)) | |
| 578 | + | .route_get("/api/sync/devices", get(sync::list_devices)) | |
| 579 | + | .route_get("/api/v1/sync/devices", get(sync::list_devices)) | |
| 580 | + | .route("/api/sync/devices/{id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device)) | |
| 581 | + | .route("/api/v1/sync/devices/{id}", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::delete_device)) | |
| 582 | + | .route("/api/sync/keys", put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key)) | |
| 583 | + | .route("/api/v1/sync/keys", put_csrf_skip(SYNCKIT_JWT_SKIP, sync::put_sync_key)) | |
| 584 | + | .route_get("/api/sync/keys", get(sync::get_sync_key)) | |
| 585 | + | .route_get("/api/v1/sync/keys", get(sync::get_sync_key)) | |
| 586 | + | .route("/api/sync/keys/rotate", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation)) | |
| 587 | + | .route("/api/v1/sync/keys/rotate", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::begin_rotation)) | |
| 588 | + | .route("/api/sync/keys/rotate", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation)) | |
| 589 | + | .route("/api/v1/sync/keys/rotate", delete_csrf_skip(SYNCKIT_JWT_SKIP, sync::cancel_rotation)) | |
| 590 | + | .route("/api/sync/keys/rotate/entries", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries)) | |
| 591 | + | .route("/api/v1/sync/keys/rotate/entries", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_entries)) | |
| 592 | + | .route("/api/sync/keys/rotate/batch", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch)) | |
| 593 | + | .route("/api/v1/sync/keys/rotate/batch", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::rotation_batch)) | |
| 594 | + | .route("/api/sync/keys/rotate/complete", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation)) | |
| 595 | + | .route("/api/v1/sync/keys/rotate/complete", post_csrf_skip(SYNCKIT_JWT_SKIP, sync::complete_rotation)) | |
| 596 | + | .route("/api/sync/blobs/upload", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url)) | |
| 597 | + | .route("/api/v1/sync/blobs/upload", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_upload_url)) | |
| 598 | + | .route("/api/sync/blobs/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload)) | |
| 599 | + | .route("/api/v1/sync/blobs/confirm", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_confirm_upload)) | |
| 600 | + | .route("/api/sync/blobs/download", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url)) | |
| 601 | + | .route("/api/v1/sync/blobs/download", post_csrf_skip(SYNCKIT_JWT_SKIP, blobs::blob_download_url)) | |
| 597 | 602 | // Per-app rate limit (inner layer runs first): prevents one developer's | |
| 598 | 603 | // app from starving other apps. Extracts app ID from JWT payload. | |
| 599 | 604 | .route_layer(GovernorLayer { | |
| @@ -606,32 +611,32 @@ pub fn synckit_routes() -> Router<AppState> { | |||
| 606 | 611 | }); | |
| 607 | 612 | ||
| 608 | 613 | // App management endpoints use session auth (no extra rate limit beyond global) | |
| 609 | - | let app_routes = Router::new() | |
| 610 | - | .route("/api/sync/apps", post(apps::create_app)) | |
| 611 | - | .route("/api/v1/sync/apps", post(apps::create_app)) | |
| 612 | - | .route("/api/sync/apps", get(apps::list_apps)) | |
| 613 | - | .route("/api/v1/sync/apps", get(apps::list_apps)) | |
| 614 | - | .route("/api/sync/apps/{id}/regenerate-key", post(apps::regenerate_app_key)) | |
| 615 | - | .route("/api/v1/sync/apps/{id}/regenerate-key", post(apps::regenerate_app_key)) | |
| 616 | - | .route("/api/sync/apps/{id}/link", put(apps::update_app_link)) | |
| 617 | - | .route("/api/v1/sync/apps/{id}/link", put(apps::update_app_link)) | |
| 618 | - | .route("/api/sync/apps/{id}/slug", put(apps::update_app_slug)) | |
| 619 | - | .route("/api/v1/sync/apps/{id}/slug", put(apps::update_app_slug)) | |
| 620 | - | .route("/api/sync/apps/{id}", delete(apps::delete_app)) | |
| 621 | - | .route("/api/v1/sync/apps/{id}", delete(apps::delete_app)) | |
| 614 | + | let app_routes = CsrfRouter::new() | |
| 615 | + | .route("/api/sync/apps", post_csrf(apps::create_app)) | |
| 616 | + | .route("/api/v1/sync/apps", post_csrf(apps::create_app)) | |
| 617 | + | .route_get("/api/sync/apps", get(apps::list_apps)) | |
| 618 | + | .route_get("/api/v1/sync/apps", get(apps::list_apps)) | |
| 619 | + | .route("/api/sync/apps/{id}/regenerate-key", post_csrf(apps::regenerate_app_key)) | |
| 620 | + | .route("/api/v1/sync/apps/{id}/regenerate-key", post_csrf(apps::regenerate_app_key)) | |
| 621 | + | .route("/api/sync/apps/{id}/link", put_csrf(apps::update_app_link)) | |
| 622 | + | .route("/api/v1/sync/apps/{id}/link", put_csrf(apps::update_app_link)) | |
| 623 | + | .route("/api/sync/apps/{id}/slug", put_csrf(apps::update_app_slug)) | |
| 624 | + | .route("/api/v1/sync/apps/{id}/slug", put_csrf(apps::update_app_slug)) | |
| 625 | + | .route("/api/sync/apps/{id}", delete_csrf(apps::delete_app)) | |
| 626 | + | .route("/api/v1/sync/apps/{id}", delete_csrf(apps::delete_app)) | |
| 622 | 627 | // Developer billing (session auth, dashboard-driven). | |
| 623 | - | .route("/api/sync/apps/{id}/billing/setup", post(billing::setup)) | |
| 624 | - | .route("/api/v1/sync/apps/{id}/billing/setup", post(billing::setup)) | |
| 625 | - | .route("/api/sync/apps/{id}/billing/activate", post(billing::activate)) | |
| 626 | - | .route("/api/v1/sync/apps/{id}/billing/activate", post(billing::activate)) | |
| 627 | - | .route("/api/sync/apps/{id}/billing", axum::routing::patch(billing::patch)) | |
| 628 | - | .route("/api/v1/sync/apps/{id}/billing", axum::routing::patch(billing::patch)) | |
| 629 | - | .route("/api/sync/apps/{id}/billing", delete(billing::cancel)) | |
| 630 | - | .route("/api/v1/sync/apps/{id}/billing", delete(billing::cancel)) | |
| 631 | - | .route("/api/sync/apps/{id}/billing", get(billing::get)) | |
| 632 | - | .route("/api/v1/sync/apps/{id}/billing", get(billing::get)) | |
| 633 | - | .route("/api/sync/apps/{id}/billing/portal", get(billing::portal)) | |
| 634 | - | .route("/api/v1/sync/apps/{id}/billing/portal", get(billing::portal)); | |
| 628 | + | .route("/api/sync/apps/{id}/billing/setup", post_csrf(billing::setup)) | |
| 629 | + | .route("/api/v1/sync/apps/{id}/billing/setup", post_csrf(billing::setup)) | |
| 630 | + | .route("/api/sync/apps/{id}/billing/activate", post_csrf(billing::activate)) | |
| 631 | + | .route("/api/v1/sync/apps/{id}/billing/activate", post_csrf(billing::activate)) | |
| 632 | + | .route("/api/sync/apps/{id}/billing", patch_csrf(billing::patch)) | |
| 633 | + | .route("/api/v1/sync/apps/{id}/billing", patch_csrf(billing::patch)) | |
| 634 | + | .route("/api/sync/apps/{id}/billing", delete_csrf(billing::cancel)) | |
| 635 | + | .route("/api/v1/sync/apps/{id}/billing", delete_csrf(billing::cancel)) | |
| 636 | + | .route_get("/api/sync/apps/{id}/billing", get(billing::get)) | |
| 637 | + | .route_get("/api/v1/sync/apps/{id}/billing", get(billing::get)) | |
| 638 | + | .route_get("/api/sync/apps/{id}/billing/portal", get(billing::portal)) | |
| 639 | + | .route_get("/api/v1/sync/apps/{id}/billing/portal", get(billing::portal)); | |
| 635 | 640 | ||
| 636 | 641 | auth_routes.merge(sync_routes).merge(app_routes) | |
| 637 | 642 | } |
| @@ -229,6 +229,9 @@ impl TestHarness { | |||
| 229 | 229 | clamav_socket: None, | |
| 230 | 230 | yara_rules_dir: "/nonexistent".to_string(), | |
| 231 | 231 | malwarebazaar_enabled: false, | |
| 232 | + | urlhaus_enabled: false, | |
| 233 | + | abuse_ch_auth_key: None, | |
| 234 | + | metadefender_api_key: None, | |
| 232 | 235 | }; | |
| 233 | 236 | ScanPipeline::new(&scan_config).expect("ScanPipeline::new with no-op config") | |
| 234 | 237 | } |