max / makenotwork
10 files changed,
+82 insertions,
-44 deletions
| @@ -582,9 +582,16 @@ where | |||
| 582 | 582 | /// static-file services, and add GET-only routes. | |
| 583 | 583 | /// | |
| 584 | 584 | /// The posture-independent [`origin_gate`] is layered on here so it wraps | |
| 585 | - | /// every route the `CsrfRouter` registered (the one non-skippable CSRF | |
| 586 | - | /// check covering all postures at once); safe methods pass through, so the | |
| 587 | - | /// later-added GET/static routes are unaffected. | |
| 585 | + | /// every route registered through THIS `CsrfRouter` (the one non-skippable | |
| 586 | + | /// CSRF check covering all postures at once); safe methods pass through, so | |
| 587 | + | /// the later-added GET/static routes are unaffected. | |
| 588 | + | /// | |
| 589 | + | /// Carve-out (ultra-fuzz Run 4): routers merged into the app OUTSIDE the | |
| 590 | + | /// `CsrfRouter` tree (git smart-HTTP, SSO, embed) are not wrapped by this | |
| 591 | + | /// `origin_gate`. That is sound today because those surfaces are GET-only or | |
| 592 | + | /// authenticated by a PAT/bearer rather than a session cookie (so they are | |
| 593 | + | /// legitimately CSRF-exempt) — but any future cookie-authed POST added to one | |
| 594 | + | /// of them must route through a `CsrfRouter`, not be merged raw. | |
| 588 | 595 | pub fn finalize(self) -> Router<S> { | |
| 589 | 596 | self.0.layer(from_fn(origin_gate)) | |
| 590 | 597 | } |
| @@ -12,27 +12,24 @@ use axum::{ | |||
| 12 | 12 | #[derive(Clone)] | |
| 13 | 13 | pub struct ApiErrorMessage(pub String); | |
| 14 | 14 | ||
| 15 | - | /// Payload carried by `AppError::Validation`. | |
| 15 | + | /// Payload carried by `AppError::Validation`. `summary` is the user-visible | |
| 16 | + | /// banner message. | |
| 16 | 17 | /// | |
| 17 | - | /// The `summary` is the user-visible banner. `field`, when set, names the form | |
| 18 | - | /// input the error belongs to so a re-rendered form can mark that input invalid | |
| 19 | - | /// (`aria-invalid`) and focus it, instead of only showing a top-level banner. | |
| 20 | - | /// The join wizard's account step is the reference wiring of this path; other | |
| 21 | - | /// non-HTMX form re-renders should follow it. | |
| 18 | + | /// Field-level attribution (marking a specific input `aria-invalid`) is NOT done | |
| 19 | + | /// here: in a server-rendered, per-form app the error layer doesn't know which | |
| 20 | + | /// template to re-render, so highlighting is wired per form at the handler that | |
| 21 | + | /// owns the template (see the join wizard's account step, which marks its own | |
| 22 | + | /// fields). An earlier `field`/`with_field` mechanism on this type was never read | |
| 23 | + | /// by `IntoResponse` and had no call sites; it was removed rather than left as | |
| 24 | + | /// dead code that implied a generic path that didn't exist (ultra-fuzz Run 4 m1). | |
| 22 | 25 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 23 | 26 | pub struct ValidationError { | |
| 24 | 27 | pub summary: String, | |
| 25 | - | pub field: Option<String>, | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 28 | 30 | impl ValidationError { | |
| 29 | 31 | pub fn new(summary: impl Into<String>) -> Self { | |
| 30 | - | Self { summary: summary.into(), field: None } | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | /// A validation error attributed to a specific form field. | |
| 34 | - | pub fn with_field(field: impl Into<String>, summary: impl Into<String>) -> Self { | |
| 35 | - | Self { summary: summary.into(), field: Some(field.into()) } | |
| 32 | + | Self { summary: summary.into() } | |
| 36 | 33 | } | |
| 37 | 34 | } | |
| 38 | 35 |
| @@ -2,6 +2,17 @@ | |||
| 2 | 2 | //! | |
| 3 | 3 | //! Wraps `docengine` with media URL rewriting so relative image/video | |
| 4 | 4 | //! references in markdown (``) resolve to CDN URLs. | |
| 5 | + | //! | |
| 6 | + | //! Sanitization policy (deliberate, ultra-fuzz Run 4 decision 2026-06-23): creator | |
| 7 | + | //! markdown (blog posts, issues, project sections) is rendered via | |
| 8 | + | //! `docengine::render_permissive`, which runs `ammonia` (strips script / | |
| 9 | + | //! event-handlers / dangerous URL schemes) but PERMITS ordinary external | |
| 10 | + | //! `http(s)`/`mailto` links — creators legitimately link out from prose. This is | |
| 11 | + | //! intentionally MORE permissive than the custom-pages surface, which uses a | |
| 12 | + | //! closed-system sanitizer restricting URLs to on-platform targets. Both paths are | |
| 13 | + | //! XSS-safe; they differ only in whether external links are allowed, and that | |
| 14 | + | //! difference is by design (long-form prose vs. a templated page builder). Keep the | |
| 15 | + | //! two policies distinct rather than collapsing them. | |
| 5 | 16 | ||
| 6 | 17 | use crate::db::UserId; | |
| 7 | 18 |
| @@ -206,7 +206,7 @@ | |||
| 206 | 206 | <div class="tier-active-badge">Subscribed</div> | |
| 207 | 207 | {% else if session_user.is_some() %} | |
| 208 | 208 | <form method="post" action="/stripe/subscribe/{{ tier.id }}"> | |
| 209 | - | <input type="hidden" name="_csrf" value="{{ csrf_token.as_deref().unwrap_or_default() }}"> | |
| 209 | + | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 210 | 210 | <details class="promo-details"> | |
| 211 | 211 | <summary>Have a promo code?</summary> | |
| 212 | 212 | <input type="text" name="promo_code" placeholder="e.g. TRIAL14" class="promo-input"> |
| @@ -83,7 +83,7 @@ | |||
| 83 | 83 | Stripe only allows one open checkout at a time. Cancel the previous attempt to start a new one. | |
| 84 | 84 | </p> | |
| 85 | 85 | <form action="/stripe/checkout/{{ item.id }}/cancel-pending" method="POST"> | |
| 86 | - | <input type="hidden" name="_csrf" value="{{ csrf_token.as_deref().unwrap_or_default() }}"> | |
| 86 | + | {% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %} | |
| 87 | 87 | <button class="btn-secondary" type="submit">Cancel previous checkout</button> | |
| 88 | 88 | </form> | |
| 89 | 89 | </div> |
| @@ -262,10 +262,15 @@ | |||
| 262 | 262 | <label for="preferred-tier">Which tier fits?</label> | |
| 263 | 263 | <select id="preferred-tier" name="preferred_tier"> | |
| 264 | 264 | <option value="">Not sure yet</option> | |
| 265 | + | {# Guard the positional indexing: Askama `[N]` panics (raw 500) if the | |
| 266 | + | card list ever shrinks. Safe today (four fixed tiers); the length | |
| 267 | + | check keeps it from becoming a 500 if tiers go data-driven. #} | |
| 268 | + | {% if tier_cards.len() >= 4 %} | |
| 265 | 269 | <option value="basic">Basic (${{ tier_cards[0].standard_monthly }}/mo): text, blogs, newsletters</option> | |
| 266 | 270 | <option value="small_files">Small Files (${{ tier_cards[1].standard_monthly }}/mo): audio, software, plugins</option> | |
| 267 | 271 | <option value="big_files">Big Files (${{ tier_cards[2].standard_monthly }}/mo): video, games, large software</option> | |
| 268 | 272 | <option value="everything">Everything (${{ tier_cards[3].standard_monthly }}/mo): all features, current and future</option> | |
| 273 | + | {% endif %} | |
| 269 | 274 | </select> | |
| 270 | 275 | </div> | |
| 271 | 276 | <div class="form-group"> |
| @@ -35,7 +35,8 @@ | |||
| 35 | 35 | <div class="form-group"> | |
| 36 | 36 | <label for="wiz-password">Password</label> | |
| 37 | 37 | <input type="password" id="wiz-password" name="password" required | |
| 38 | - | placeholder="--------" minlength="8" autocomplete="new-password"> | |
| 38 | + | placeholder="--------" minlength="8" autocomplete="new-password" | |
| 39 | + | {% if error_field.as_deref() == Some("password") %}aria-invalid="true"{% endif %}> | |
| 39 | 40 | <div class="hint">Minimum 8 characters</div> | |
| 40 | 41 | </div> | |
| 41 | 42 |
| @@ -1,24 +0,0 @@ | |||
| 1 | - | {% include "wizards/partials/step_nav.html" %} | |
| 2 | - | ||
| 3 | - | <div class="wizard-step"> | |
| 4 | - | <h2 class="subtitle-h2">Apply as Creator</h2> | |
| 5 | - | <p class="step-description">Tell us what you make. Most applications are approved within a few days.</p> | |
| 6 | - | ||
| 7 | - | <form hx-post="/join/step/pitch" | |
| 8 | - | hx-target="#wizard-step" hx-swap="innerHTML"> | |
| 9 | - | <div class="form-group"> | |
| 10 | - | <label for="wiz-pitch">What do you make?</label> | |
| 11 | - | <textarea id="wiz-pitch" name="pitch" rows="6" | |
| 12 | - | placeholder="Briefly describe your work. A link to your portfolio, channel, or existing storefront is helpful." | |
| 13 | - | minlength="20" maxlength="500"></textarea> | |
| 14 | - | <div class="hint">20-500 characters</div> | |
| 15 | - | </div> | |
| 16 | - | ||
| 17 | - | <div class="wizard-actions"> | |
| 18 | - | <button type="button" class="btn-secondary" | |
| 19 | - | hx-get="/join/step/stripe" | |
| 20 | - | hx-target="#wizard-step" hx-swap="innerHTML">Skip</button> | |
| 21 | - | <button type="submit" class="btn-primary">Submit</button> | |
| 22 | - | </div> | |
| 23 | - | </form> | |
| 24 | - | </div> |
| @@ -56,7 +56,8 @@ | |||
| 56 | 56 | <div class="form-group"> | |
| 57 | 57 | <label for="wiz-password">Password</label> | |
| 58 | 58 | <input type="password" id="wiz-password" name="password" required | |
| 59 | - | placeholder="--------" minlength="8" autocomplete="new-password"> | |
| 59 | + | placeholder="--------" minlength="8" autocomplete="new-password" | |
| 60 | + | {% if error_field.as_deref() == Some("password") %}aria-invalid="true"{% endif %}> | |
| 60 | 61 | <div class="hint">Minimum 8 characters</div> | |
| 61 | 62 | </div> | |
| 62 | 63 |
| @@ -30,6 +30,46 @@ async fn signup_login_logout_flow() { | |||
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | #[tokio::test] | |
| 33 | + | async fn signup_with_taken_email_does_not_reveal_or_create() { | |
| 34 | + | // m1 (ultra-fuzz Run 4): a signup attempt whose EMAIL is already registered | |
| 35 | + | // must not return "this email is already registered" (an account-existence | |
| 36 | + | // oracle for a private identifier). It returns the same step-2 response a | |
| 37 | + | // fresh signup returns and creates no second account; the real owner is | |
| 38 | + | // reached out-of-band by the "account exists" email. | |
| 39 | + | let mut h = TestHarness::new().await; | |
| 40 | + | h.signup("owner", "owner@example.com", "password123").await; | |
| 41 | + | h.client.post_form("/logout", "").await; | |
| 42 | + | ||
| 43 | + | // A new username, but the same (taken) email. | |
| 44 | + | h.client.fetch_csrf_token().await; | |
| 45 | + | let body = "username=intruder&email=owner@example.com&password=password123"; | |
| 46 | + | let resp = h.client.post_form("/join/step/account", &body).await; | |
| 47 | + | ||
| 48 | + | assert!(resp.status.is_success(), "taken-email signup should not error: {}", resp.status); | |
| 49 | + | let lower = resp.text.to_lowercase(); | |
| 50 | + | assert!( | |
| 51 | + | !lower.contains("already registered") && !lower.contains("already exists"), | |
| 52 | + | "response must not reveal the email is registered: {}", | |
| 53 | + | resp.text | |
| 54 | + | ); | |
| 55 | + | ||
| 56 | + | // No second account created for the probed email. | |
| 57 | + | let count: i64 = sqlx::query_scalar( | |
| 58 | + | "SELECT COUNT(*) FROM users WHERE email = 'owner@example.com'", | |
| 59 | + | ) | |
| 60 | + | .fetch_one(&h.db) | |
| 61 | + | .await | |
| 62 | + | .unwrap(); | |
| 63 | + | assert_eq!(count, 1, "no duplicate account may be created for a taken email"); | |
| 64 | + | // And the probed username was never registered. | |
| 65 | + | let intruder: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'intruder'") | |
| 66 | + | .fetch_one(&h.db) | |
| 67 | + | .await | |
| 68 | + | .unwrap(); | |
| 69 | + | assert_eq!(intruder, 0, "no account should be created on the taken-email path"); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[tokio::test] | |
| 33 | 73 | async fn login_with_existing_account() { | |
| 34 | 74 | let mut h = TestHarness::new().await; | |
| 35 | 75 |