Skip to main content

max / makenotwork

ux: field-error highlighting, csrf guards, dead-code + doc cleanup (ultra-fuzz Run 4, A+) Phase 5 (UX Wiring), the final axis. - M3 password field highlighting: the join wizard returned field="password" on a password validation error but the account templates only wired aria-invalid for username/email. Both account.html and wizard_join.html now flag the password input too. - m1 field-attribution dead code: ValidationError carried a `field` / `with_field` attribution mechanism that IntoResponse never read and that had zero call sites, with a doc claiming the join wizard was its "reference wiring" (it isn't -- the wizard highlights its own fields locally). In a server-rendered per-form app the error layer can't know which template to re-render, so a generic path is the wrong abstraction; removed the dead API and corrected the doc rather than build it. (The signup enumeration m1 fix landed in Phase 2.) - M2 csrf empty-token: project.html and purchase.html posted `_csrf="{{ ...unwrap_or_default() }}"` (an empty token -> guaranteed 403 if ever None); switched to the `{% if let Some(token) %}` guard used elsewhere. (The dashboard-import case is a JS header where an empty-string fallback is correct.) - m2 tier_cards panic: guarded the positional `tier_cards[0..3]` indexing behind a length check so it can't become a raw 500 if the tier list ever shrinks. - m3 orphan form: removed templates/wizards/steps/join/pitch.html, which POSTed to /join/step/pitch -- a step the handler doesn't accept (404). Dead wiring. - markdown two-policy doc: documented the deliberate split (Max, 2026-06-23) -- creator markdown stays on render_permissive (external links allowed), distinct from the custom-pages closed-system sanitizer; both XSS-safe. - csrf finalize doc drift: noted the git/sso/embed merged-router carve-out from origin_gate (sound today -- GET-only / PAT-authed) and the rule for future POSTs. Regression test: a signup with a taken email returns the step-2 response (no "already registered" reveal) and creates no account. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-24 00:36 UTC
Commit: 84ddb065d9d855874e8d3494744b3cd08a67d597
Parent: c114130
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 (`![](folder/file.png)`) 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