max / makenotwork
11 files changed,
+120 insertions,
-27 deletions
| @@ -0,0 +1,18 @@ | |||
| 1 | + | -- An SSH key fingerprint must map to exactly one identity. The original table | |
| 2 | + | -- only enforced UNIQUE (user_id, fingerprint), so the same public key could be | |
| 3 | + | -- registered to multiple accounts. Because SSH public keys are non-secret (they | |
| 4 | + | -- are published, and MNW itself renders them into authorized_keys), an attacker | |
| 5 | + | -- could register a victim's key on their own account; the CLI SSH identity | |
| 6 | + | -- lookup then returns >1 row and fails closed, breaking the victim's CLI auth | |
| 7 | + | -- (Run 15 Auth C1). Add a global UNIQUE (fingerprint). | |
| 8 | + | ||
| 9 | + | -- Dedup first: if any fingerprint is registered to more than one account, keep | |
| 10 | + | -- the earliest registration (the legitimate owner; a cross-account duplicate is | |
| 11 | + | -- always added later) and drop the rest, so the constraint can be added. | |
| 12 | + | DELETE FROM ssh_keys a | |
| 13 | + | USING ssh_keys b | |
| 14 | + | WHERE a.fingerprint = b.fingerprint | |
| 15 | + | AND (a.created_at, a.id) > (b.created_at, b.id); | |
| 16 | + | ||
| 17 | + | ALTER TABLE ssh_keys | |
| 18 | + | ADD CONSTRAINT ssh_keys_fingerprint_key UNIQUE (fingerprint); |
| @@ -320,14 +320,20 @@ pub async fn get_public_projects_with_item_counts( | |||
| 320 | 320 | Ok(projects) | |
| 321 | 321 | } | |
| 322 | 322 | ||
| 323 | - | /// Fetch a public project by its URL slug. Returns `None` if not found or not public. | |
| 323 | + | /// Fetch a public project by its URL slug. Returns `None` if not found, not | |
| 324 | + | /// public, or owned by a sandbox account. Sandbox accounts are hidden from all | |
| 325 | + | /// public surfaces (discover, item pages, user pages); this join closes the gap | |
| 326 | + | /// where their `/p/{slug}`, RSS feeds, and blog pages still rendered publicly. | |
| 324 | 327 | #[tracing::instrument(skip_all)] | |
| 325 | 328 | pub async fn get_public_project_by_slug( | |
| 326 | 329 | pool: &PgPool, | |
| 327 | 330 | slug: &Slug, | |
| 328 | 331 | ) -> Result<Option<DbProject>> { | |
| 329 | 332 | let project = sqlx::query_as::<_, DbProject>( | |
| 330 | - | "SELECT * FROM projects WHERE slug = $1 AND is_public = true ORDER BY created_at ASC LIMIT 1", | |
| 333 | + | "SELECT p.* FROM projects p \ | |
| 334 | + | JOIN users u ON u.id = p.user_id \ | |
| 335 | + | WHERE p.slug = $1 AND p.is_public = true AND u.is_sandbox = false \ | |
| 336 | + | ORDER BY p.created_at ASC LIMIT 1", | |
| 331 | 337 | ) | |
| 332 | 338 | .bind(slug) | |
| 333 | 339 | .fetch_optional(pool) |
| @@ -107,6 +107,8 @@ pub async fn lookup_user_by_fingerprint( | |||
| 107 | 107 | FROM ssh_keys sk | |
| 108 | 108 | JOIN users u ON u.id = sk.user_id | |
| 109 | 109 | WHERE sk.fingerprint = $1 | |
| 110 | + | ORDER BY sk.created_at ASC | |
| 111 | + | LIMIT 1 | |
| 110 | 112 | "#, | |
| 111 | 113 | ) | |
| 112 | 114 | .bind(fingerprint) |
| @@ -55,6 +55,13 @@ pub fn parse_csv(bytes: &[u8], mapping: &ColumnMapping) -> Result<ImportPayload> | |||
| 55 | 55 | let parts: Vec<&str> = s.splitn(2, '@').collect(); | |
| 56 | 56 | parts.len() == 2 | |
| 57 | 57 | && !parts[0].is_empty() | |
| 58 | + | // Reject local parts starting with a spreadsheet formula | |
| 59 | + | // trigger (=, +, -, @). Such addresses aren't valid anyway, | |
| 60 | + | // and rejecting them keeps the stored email — which is what a | |
| 61 | + | // later CSV export re-emits — from carrying a live formula | |
| 62 | + | // (the email column is used for sending, so it can't be | |
| 63 | + | // quote-prefixed like the other sanitized fields). | |
| 64 | + | && !parts[0].starts_with(['=', '+', '-', '@']) | |
| 58 | 65 | && parts[1].contains('.') | |
| 59 | 66 | && !parts[1].starts_with('.') | |
| 60 | 67 | && !parts[1].ends_with('.') |
| @@ -96,6 +96,15 @@ pub(super) async fn create_promo_code( | |||
| 96 | 96 | if code.is_empty() || code.len() > 50 { | |
| 97 | 97 | return Err(AppError::BadRequest("Code must be 1-50 characters".to_string())); | |
| 98 | 98 | } | |
| 99 | + | // Restrict to an unambiguous coupon-code charset. Besides being what | |
| 100 | + | // users expect to type, this keeps HTML metacharacters out of the | |
| 101 | + | // code so it can never carry markup into the redemptions modal | |
| 102 | + | // (defense-in-depth alongside escaping at the JS sink). | |
| 103 | + | if !code.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { | |
| 104 | + | return Err(AppError::BadRequest( | |
| 105 | + | "Code may only contain letters, numbers, hyphens, and underscores".to_string(), | |
| 106 | + | )); | |
| 107 | + | } | |
| 99 | 108 | code | |
| 100 | 109 | } | |
| 101 | 110 | }; |
| @@ -81,14 +81,21 @@ pub(super) async fn add_key( | |||
| 81 | 81 | .await | |
| 82 | 82 | .map_err(|e| { | |
| 83 | 83 | // Check for unique constraint violation (duplicate fingerprint) | |
| 84 | - | if let AppError::Database(ref db_err) = e | |
| 85 | - | && db_err | |
| 86 | - | .to_string() | |
| 87 | - | .contains("ssh_keys_user_id_fingerprint_key") | |
| 88 | - | { | |
| 89 | - | return AppError::validation( | |
| 90 | - | "This SSH key is already registered to your account".to_string(), | |
| 91 | - | ); | |
| 84 | + | if let AppError::Database(ref db_err) = e { | |
| 85 | + | let msg = db_err.to_string(); | |
| 86 | + | if msg.contains("ssh_keys_user_id_fingerprint_key") { | |
| 87 | + | return AppError::validation( | |
| 88 | + | "This SSH key is already registered to your account".to_string(), | |
| 89 | + | ); | |
| 90 | + | } | |
| 91 | + | // Global uniqueness: the key is registered to a different account. | |
| 92 | + | // A fingerprint maps to exactly one identity, so we reject rather | |
| 93 | + | // than let a duplicate break the owner's CLI SSH auth. | |
| 94 | + | if msg.contains("ssh_keys_fingerprint_key") { | |
| 95 | + | return AppError::validation( | |
| 96 | + | "This SSH key is already registered to another account.".to_string(), | |
| 97 | + | ); | |
| 98 | + | } | |
| 92 | 99 | } | |
| 93 | 100 | e | |
| 94 | 101 | })?; |
| @@ -277,6 +277,12 @@ async fn personal_feed( | |||
| 277 | 277 | .await? | |
| 278 | 278 | .ok_or(AppError::NotFound)?; | |
| 279 | 279 | ||
| 280 | + | // Sandbox accounts are hidden from every public feed surface (the other four | |
| 281 | + | // feed handlers reject them); keep this one consistent. | |
| 282 | + | if db_user.is_sandbox { | |
| 283 | + | return Err(AppError::NotFound); | |
| 284 | + | } | |
| 285 | + | ||
| 280 | 286 | // Enforce key rotation: a signature valid for a stale version is a revoked | |
| 281 | 287 | // URL (the user hit "Regenerate feed URL"). The HMAC above proves the `v` | |
| 282 | 288 | // wasn't forged; this proves it's still current. |
| @@ -508,12 +508,34 @@ async fn collect_health(state: &AppState) -> HealthData { | |||
| 508 | 508 | } | |
| 509 | 509 | ||
| 510 | 510 | /// Render the system health dashboard with database, storage, and service status. | |
| 511 | + | /// | |
| 512 | + | /// The detailed dashboard — infra config, DB pool internals, business counts, | |
| 513 | + | /// Stripe mode, S3 bucket/region — is admin-only. Anonymous and non-admin callers | |
| 514 | + | /// get a minimal, reachable status page (HTTP 200) so uptime monitors (PoM polls | |
| 515 | + | /// this URL) stay green without the operational-intel disclosure. `/api/health` | |
| 516 | + | /// remains the minimal JSON probe. | |
| 511 | 517 | #[tracing::instrument(skip_all, name = "health::health")] | |
| 512 | 518 | pub(super) async fn health( | |
| 513 | 519 | State(state): State<AppState>, | |
| 514 | 520 | session: Session, | |
| 515 | - | ) -> Result<impl IntoResponse> { | |
| 521 | + | crate::auth::MaybeUserVerified(maybe_user): crate::auth::MaybeUserVerified, | |
| 522 | + | ) -> Result<axum::response::Response> { | |
| 516 | 523 | let data = collect_health(&state).await; | |
| 524 | + | ||
| 525 | + | let is_admin = maybe_user.as_ref().is_some_and(|u| u.is_admin); | |
| 526 | + | if !is_admin { | |
| 527 | + | // `label()` is a controlled &'static str from OverallStatus — no user input. | |
| 528 | + | let body = axum::response::Html(format!( | |
| 529 | + | "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\ | |
| 530 | + | <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\ | |
| 531 | + | <title>MakeNotWork status</title></head>\ | |
| 532 | + | <body style=\"font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem\">\ | |
| 533 | + | <h1>MakeNotWork</h1><p>{}</p></body></html>", | |
| 534 | + | data.overall.label() | |
| 535 | + | )); | |
| 536 | + | return Ok(body.into_response()); | |
| 537 | + | } | |
| 538 | + | ||
| 517 | 539 | let now = chrono::Utc::now(); | |
| 518 | 540 | ||
| 519 | 541 | let pool_utilization = if data.db_pool_max > 0 { | |
| @@ -623,7 +645,8 @@ pub(super) async fn health( | |||
| 623 | 645 | pom_routes_ok: data.pom_routes_ok, | |
| 624 | 646 | pom_routes_failed: data.pom_routes_failed, | |
| 625 | 647 | privacy_jobs: format_privacy_jobs(&data.privacy_jobs, now), | |
| 626 | - | }) | |
| 648 | + | } | |
| 649 | + | .into_response()) | |
| 627 | 650 | } | |
| 628 | 651 | ||
| 629 | 652 | // ============================================================================ |
| @@ -78,7 +78,11 @@ pub(super) async fn postmark_inbound( | |||
| 78 | 78 | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 79 | 79 | } | |
| 80 | 80 | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 81 | - | Ok(Some(u)) if u.email_verified => u, | |
| 81 | + | Ok(Some(u)) if u.email_verified && !u.is_suspended() => u, | |
| 82 | + | Ok(Some(u)) if u.is_suspended() => { | |
| 83 | + | tracing::info!(email = %sender_email, "inbound: sender is suspended"); | |
| 84 | + | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 85 | + | } | |
| 82 | 86 | Ok(Some(_)) => { | |
| 83 | 87 | tracing::info!(email = %sender_email, "inbound: sender email not verified"); | |
| 84 | 88 | return HandlerOutcome::Terminal(StatusCode::OK); |
| @@ -39,7 +39,7 @@ function showPromoRedemptions(codeId, codeLabel) { | |||
| 39 | 39 | content.style.padding = '2rem'; | |
| 40 | 40 | content.innerHTML = | |
| 41 | 41 | '<div class="modal-header" style="margin-bottom: 1rem;">' | |
| 42 | - | + '<h2>Redemptions: <code>' + codeLabel + '</code></h2>' | |
| 42 | + | + '<h2>Redemptions: <code>' + escapeHtml(codeLabel) + '</code></h2>' | |
| 43 | 43 | + '<button type="button" class="modal-close" aria-label="Dismiss"' | |
| 44 | 44 | + ' onclick="document.getElementById(\'promo-redemptions-modal\').remove()">×</button>' | |
| 45 | 45 | + '</div>' |
| @@ -272,33 +272,44 @@ async fn discount_code_project_scoped() { | |||
| 272 | 272 | "Project-scoped code should appear in listing"); | |
| 273 | 273 | } | |
| 274 | 274 | ||
| 275 | - | // UX-1: free-text rendered into a JS-string context (inline onclick) must be | |
| 276 | - | // escaped. A promo code is only length-validated, so it can contain a quote; it | |
| 277 | - | // must land in an escaped data-* attribute with the copy handler reading dataset, | |
| 278 | - | // never interpolated raw into a writeText('...') literal (which a quote breaks). | |
| 275 | + | // UX-1: a promo code must never reach a JS-string context unescaped. Two layers | |
| 276 | + | // now enforce this: (1) custom codes are charset-restricted at creation to | |
| 277 | + | // [A-Z0-9_-], so a quote or HTML metacharacter can't enter in the first place; | |
| 278 | + | // (2) the code still renders into an escaped data-* attribute read by the copy | |
| 279 | + | // handler, never interpolated raw into a writeText('...') literal. | |
| 279 | 280 | #[tokio::test] | |
| 280 | - | async fn promo_code_with_quote_is_not_in_js_string_context() { | |
| 281 | + | async fn promo_code_with_quote_is_rejected_and_valid_code_is_not_in_js_string_context() { | |
| 281 | 282 | let mut h = TestHarness::new().await; | |
| 282 | 283 | let setup = h.create_creator_with_item("xssseller", "digital", 1000).await; | |
| 283 | 284 | h.publish_project_and_item(&setup.project_id, &setup.item_id).await; | |
| 284 | 285 | ||
| 285 | - | // An HTMX create returns the rendered promo_codes_list.html partial (otherwise | |
| 286 | - | // the route returns JSON); htmx_post_form also injects the CSRF token. | |
| 287 | - | let resp = h | |
| 286 | + | // Layer 1: a code containing a quote is rejected by the charset validator. | |
| 287 | + | let rejected = h | |
| 288 | 288 | .client | |
| 289 | 289 | .htmx_post_form( | |
| 290 | 290 | "/api/promo-codes", | |
| 291 | 291 | "code=AB'CD&code_purpose=discount&discount_type=percentage&discount_value=25", | |
| 292 | 292 | ) | |
| 293 | 293 | .await; | |
| 294 | + | assert!( | |
| 295 | + | rejected.status.is_client_error(), | |
| 296 | + | "quoted code must be rejected, got {}: {}", | |
| 297 | + | rejected.status, rejected.text | |
| 298 | + | ); | |
| 299 | + | ||
| 300 | + | // Layer 2: a valid code still lands in an escaped data-* attribute, and the | |
| 301 | + | // copy handler reads dataset rather than interpolating the code. | |
| 302 | + | let resp = h | |
| 303 | + | .client | |
| 304 | + | .htmx_post_form( | |
| 305 | + | "/api/promo-codes", | |
| 306 | + | "code=AB-CD&code_purpose=discount&discount_type=percentage&discount_value=25", | |
| 307 | + | ) | |
| 308 | + | .await; | |
| 294 | 309 | assert!(resp.status.is_success(), "create failed: {} {}", resp.status, resp.text); | |
| 295 | 310 | ||
| 296 | 311 | let html = &resp.text; | |
| 297 | - | // Code is uppercased to AB'CD. The raw quote must never appear unescaped | |
| 298 | - | // (Askama escapes it in both the cell text and the data attribute). | |
| 299 | - | assert!(!html.contains("AB'CD"), "raw quoted code must not appear unescaped: {html}"); | |
| 300 | - | // The copy handler reads the data attribute instead of interpolating the code. | |
| 301 | 312 | assert!(html.contains("writeText(this.dataset.code)"), "copy must read dataset: {html}"); | |
| 302 | 313 | assert!(!html.contains("writeText('"), "code must not be interpolated into a JS string literal"); | |
| 303 | - | assert!(html.contains("data-code="), "escaped code must live in a data-code attr: {html}"); | |
| 314 | + | assert!(html.contains("data-code="), "code must live in a data-code attr: {html}"); | |
| 304 | 315 | } |