Skip to content

Rust off Rails 0.7.0

Choose a tag to compare

@danielcj danielcj released this 17 Oct 03:49
· 98 commits to main since this release

What's new

  • Security fix: development is reachable only from this machine. serve binds 127.0.0.1 in development (APP_BIND) and answers only local and configured host names (APP_ALLOWED_HOSTS), so another device on the network or a DNS-rebinding page can no longer open /dev (where the mailbox shows sign-in links). A new offrails-check [bind] rule enforces it.
  • Signing in replaces the previous session: the old session in that browser is destroyed and its sockets and streams are closed.
  • Emailed links are limited per address, with a cap of 20 emails per address per day for login and reset links.
  • Unverified accounts can't be pre-hijacked: users.email_verified_at stays empty until a link sent to the address is used, and that first use drops every session, API token and pending link set up before it.
  • Less leakage: job errors are stored redacted, Config's Debug prints only settings that can never hold a secret, and presence labels never come from an email address.
  • Samples: optional, tested reference code for the mailer, jobs, sign-in, realtime and a dev dashboard in rust-off-rails/samples. Upgrading offers them per area.

Breaking changes

  • Every app: the development server must bind APP_BIND (default 127.0.0.1 in development) and check Host; offrails-check fails with [bind] until it does. A dev container, tunnel or LAN workflow now needs APP_BIND=0.0.0.0 plus APP_ALLOWED_HOSTS.
  • Apps with accounts: sign-in destroys the old session; reset and login links get per-address and daily limits; apps with signup (or both passwords and login links) add a nullable email_verified_at column, and a user's first reset after the upgrade also revokes their API tokens.
  • Apps with a manual theme toggle: the cookie is renamed from theme to offrails_theme (read the old one for one release).
  • Every app also ports the security fixes: job errors stored redacted, Config's Debug as an allowlist, and (apps with channels) presence labels that don't come from the email.

Upgrading

Do the first entry's App migration (the development bind and Host check) first, then each entry's own App migration below. The procedure, including step 7b that offers the samples, is in upgrade.

Full list of changes

  • Security fix (P0): development was reachable from the LAN and through DNS rebinding. An independent review of generated apps found that serve bound 0.0.0.0 in every environment (cli said so) and nothing checked Host. /dev has no login: the mailbox shows every magic link and reset link, introspection shows the app's state, and /admin/jobs is open in development. So anyone on the same Wi-Fi could open http://<your-ip>:3000/dev/mailbox and sign in as you. A page on evil.example that re-points its own name at 127.0.0.1 (DNS rebinding) reached it too: the browser sends Host: evil.example same-origin, so CORS doesn't apply, and Fetch Metadata says same-origin, so the CsrfLayer lets its POSTs through. Now, in development only (security, new non-negotiable 13):

    • serve binds config.bind: APP_BIND, default 127.0.0.1 in development and 0.0.0.0 in production. A dev container sets APP_BIND=0.0.0.0 (config, deployment).
    • kernel/hosts.rs (whole file in the guide, like Rails' HostAuthorization) answers only localhost, 127.0.0.1, [::1], *.localhost, APP_URL's host and APP_ALLOWED_HOSTS (a leading . allows subdomains). Any other Host gets a 403 page that says how to allow it. It's mounted right inside the security middleware (routing).
    • An unset APP_ENV still means development, but Config::boot_warnings now logs that at every boot. On a server it's a missing setting that mounts /dev.
    • New offrails-check rule [bind], with fixtures. It fails a literal 0.0.0.0 bind in src/ ([0, 0, 0, 0], "0.0.0.0:…", Ipv4Addr::UNSPECIFIED outside config.rs), a config.rs that never reads APP_BIND, and an app with no Host check. A 0.0.0.0 that isn't the server's bind ends its line with // not the server bind.

    Also updated: new (steps 4 and 7 and the verify step), cli, dev-tools, the CHEATSHEET, the app AGENTS.md NEVER list, /offrails:security, and TestApp gains get_with_headers/post_form_with_headers. Changed: the macOS port-collision note in cli. A 127.0.0.1:$PORT bind succeeds while another process holds *:$PORT, because tokio sets SO_REUSEADDR; then localhost over IPv6 may reach the other app. Verified on macOS 2026-10-16, so lsof before starting still matters. The code is from an app generated from these docs, where the five new tests pass: development_refuses_a_rebound_host_name, development_answers_local_and_configured_hosts, production_and_test_answer_any_host, development_binds_loopback_unless_told_otherwise and an_unset_app_env_warns_at_boot, plus hosts.rs's two unit tests. The running server listened on 127.0.0.1 only, refused its LAN IP, answered Host: evil.test with 403 and printed the boot warning.

    App migration (every app; security, do it first):

    • Detect: sh .offrails/tools/offrails-check prints [bind], or grep -rnE '\[0, *0, *0, *0\]|"0\.0\.0\.0' src matches, or grep -c APP_BIND src/config.rs is 0.
    • Converge:
      1. Add bind, allowed_hosts and env_defaulted to Config, with bind_address, parse_allowed_hosts, boot_warnings and their two tests, copied from config. Add the APP_BIND and APP_ALLOWED_HOSTS lines to .env.example.
      2. In main.rs, log boot_warnings() after init_tracing. Every listener binds SocketAddr::new(config.bind, port): the main port, APP_DEV_HTTP_PORT, and kernel::server::run if the app has one.
      3. Copy src/kernel/hosts.rs whole from security, add pub mod hosts;, and mount it just inside security::middleware in routes.rs.
      4. Add the three request tests to security_test.rs, and get_with_headers/post_form_with_headers to TestApp if they're missing.
      5. Anything that runs development in a container (a dev container, a compose override with APP_ENV: development) sets APP_BIND: 0.0.0.0, publishes on 127.0.0.1: only, and lists any non-localhost name in APP_ALLOWED_HOSTS. Production compose and the Dockerfile need no change: they set APP_ENV=production, which binds 0.0.0.0.
      6. A docs/decisions.md line, a tunnel or a LAN workflow that relied on 0.0.0.0 becomes APP_BIND=0.0.0.0 + APP_ALLOWED_HOSTS=<name> in .env. Say so to the human: that network can then read the mailbox.
    • Prove it: the suite on both databases, then with the app running in development: lsof -nP -iTCP:$PORT -sTCP:LISTEN shows 127.0.0.1:$PORT (not *:$PORT); curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.test' $URL/dev prints 403; curl -m 3 http://$(ipconfig getifaddr en0 || hostname -I | cut -d' ' -f1):$PORT/up is refused; starting with APP_ENV unset logs APP_ENV is not set; sh .offrails/tools/offrails-check shows ok [bind].
  • Security: signing in over a session now ends that session and closes its connections (auth, item 5). This came from the same review, applied to the samples' auth. On a shared computer, Ann signs in (password, magic link or reset) while Bob's session is still in the browser. The guide said "replace that session", and an app that only overwrote the cookie left Bob's session row alive, along with any copy of its token and his open sockets and streams, until the next revalidation. Now the sign-in transaction calls session::destroy on the old session, which writes the session-ended signal. App migration (apps with accounts): Detect: the sign-in handlers (sessions::create, login_links::consume, the reset's update) don't take Option<CurrentSession>, or set the new cookie without destroying the old session. Converge: destroy it in the sign-in transaction, before session::start. Prove it: a test signs in as A, opens a stream or socket as A, then signs in as B in the same TestApp: A's connection gets bye revoked, and A's old cookie, set back with set_cookie, is logged out.

  • Security: emailed links are limited per address, with a daily cap (auth). Password reset requests were limited per IP only, so a flood from many IPs could fill one inbox without limit. Login links allowed 10 per 3 minutes per address, which is still 4,800 emails a day. Reset requests now get the per-address limit too (10 per 3 minutes), and both share a cap of 20 emails per address per day (emails/day:{email}, a one-day window on the existing rate limiter). Over the cap it sends nothing and answers 429 like the other limits, which says nothing about whether the address exists. App migration (apps with accounts): Detect: grep -rn 'emails/day' src finds nothing. Converge: add the per-address key to passwords::create and the daily key to both handlers. Prove it: link_requests_are_capped_per_address_per_day: 21 requests for one address from different IPs (APP_TRUSTED_PROXY=true, varied X-Forwarded-For); the 21st sends nothing (drain_jobs, then sent_emails) and gets the 429 form, for a registered and an unregistered address alike.

  • Security: claiming an unverified account (pre-hijacking) (auth). In signup, anyone could register a victim's address with a password they know and set things up on the account, such as an API token. When the owner later got in by a reset link, the reset ended the sessions but not the token, so the attacker kept access. The same goes for an app that offers both a password and a magic link. Now users.email_verified_at stays NULL until something sent to the address is used. The first proof (consuming a reset or a login link, accepting an invite) runs user::claim_unverified in its transaction, and when that claims the account it drops every session, API token, pending link and mail redirect set up before. A link sign-in also clears the password. Magic-link-only and invite-only apps have nothing to claim. The rule comes from the samples' auth-core (claim_unverified_account), where cross-method tests prove it; it is new here as a spec. App migration (ACCOUNTS: signup, or an app with both passwords and login links): Detect: grep -rn email_verified_at migration/src src/models/user.rs finds nothing. Converge: a migration adds the nullable column, backfilled NULL: never mark existing accounts verified, because that would bless an attacker's pre-registered one. A real user's first reset after the upgrade then also revokes their API tokens; tell the human. Add claim_unverified, call it in password_reset::consume and login_link::consume, and drop the pre-claim credentials on true. Magic-link first logins create users verified. Prove it: an_api_token_made_before_the_first_reset_is_revoked (apps with an API) or a_password_set_before_verification_dies_when_the_owner_signs_in_by_link (both methods), plus a second reset that keeps the tokens, on both databases.

  • Security: job errors are stored redacted (jobs, errors-and-logging). From the samples' review: a failure message often quotes the URL that failed, credentials and tokens included (https://deploy:hunter2@api…?token=…). The executor stored it in jobs.errors, which /admin/jobs shows and searches and app jobs show prints. Errors now go through the new kernel::redact::text before they're stored or logged. It redacts URL userinfo, random-looking path segments, secret-named query values and key=value pairs, and long random words. The whole file is in the guide, trimmed from the samples' host and tested on its own. App migration: Detect: grep -rn 'redact::text' src/kernel/jobs finds nothing. Converge: add src/kernel/redact.rs from the guide, and call redact::text on the message in the executor's error push and its job.exception log; existing rows stay as they are (offer the human a one-off task that rewrites errors through it, or let the Pruner age them out). Prove it: job_errors_are_stored_redacted on both databases. Not ported: the samples' search over redacted args. The framework's dashboard shows args unredacted, because secrets never go in args (rule 7), so searching raw args reveals nothing the page doesn't.

  • Security: Config's Debug is an allowlist (config). From the samples' review of their dev dashboard's config page. The guide said to print [redacted] for three named fields, a denylist. The next secret someone adds (a Stripe key, a webhook signing secret) then went out in full on /dev/introspect.json and app introspect. Now Debug prints values only for settings that can never hold a secret, and set/not set for the rest. App migration: Detect: src/config.rs's impl fmt::Debug for Config prints a String or Option<String> field outside the allowlist with &self.<field>. Converge: print set/not set for those. Prove it: debug_shows_no_secret_values (a sentinel in every non-allowlisted string field, none in format!("{c:?}")), then curl -s $URL/dev/introspect.json | grep -c sentinel is 0 with a sentinel in .env.

  • Presence never derives a label from the email (channels). Also from the samples' review. The guide's member_for built presence initials from u.email, and ann.lee@… → "AL" tells a room who's there. Presence now uses a display name the person chose (or its initials), else a neutral Member <n>. App migration (apps with channels): Detect: grep -rn 'initials(&u.email\|initials(&user.email' src. Converge: derive it from the user's own name field, or Member <id> when there is none. Prove it: the presence test asserts no @ and, for a user without a name, Member.

  • Samples: optional, tested reference code (samples). The parts most apps need now exist as code that was built, tested on SQLite and Postgres, given a STRIDE threat model and reviewed adversarially, in a separate public repo, rust-off-rails/samples, tagged per framework release: mailer, dev-mailbox, jobs, jobs-dashboard, auth-core, auth-password, auth-magic-link, auth-totp, realtime and dev-dashboard. Each has a README (Adapting it, Do not change), a CHECKS.md whose every row cites a test, a PROMPT.md and a sample.toml; tools/install.sh there installs one by its // samples: <anchor> comments. The framework stays Markdown and the guides stay the spec. A sample is never required and never vendored automatically, and an agent offers it and never imposes it. New: guides/samples.md (rules, how to adopt one, the anchors, what each sample adds); a Reference implementation (optional) note in mailers, jobs, jobs-dashboard, auth, channels, realtime, dev-tools and errors-and-logging; a head start question with the plan in the interview (default: build from the guides), and in new, the head-start path that takes the mailer, mailbox, jobs and dashboard from the samples (same profile, same rules). Sample anchors in new apps (new, step 13): one-line samples: <anchor> comments in the shared files, each on a line of its own. rustfmt (1.99, checked) leaves a standalone comment in place, but carries a trailing one with its line: pub mod z; // samples: kernel mods moves when the mod lines are sorted. So anchors never trail code, and the step ends with a grep that proves it. The logging init is now a registry with the samples: tracing layers list, added through sample_layers() as an Option: an empty Vec layer silences every log line (found in the samples' host; an_app_without_sample_layers_still_logs, compiled with tracing-subscriber 0.3.23). One caveat, stated in the guide: samples are proven in the samples repo's host/, which is new without the mailer, mailbox and jobs kernel, plus a few pieces they build on (the theme switch, kernel/cookie.rs, kernel/redact.rs, Chrome::stylesheet), so adopting one may mean porting those first. App migration: nothing is required. Detect: every app. Converge: run upgrade step 7b (next entry). Add the anchors from new step 13 only if the human adopts a sample and you install it with install.sh. An app that keeps its own init_tracing keeps it; switch to the registry form only together with a sample that adds a layer.

  • Upgrading to 0.7.0 offers the samples (upgrade, step 7b). This is optional, and the human decides each area. App migration (every app):

    • Detect: the areas the app has: mailer (ls src/kernel/mailer.rs src/mailers/mod.rs), dev mailbox (grep -rln '"/dev/mailbox' src), jobs (ls -d src/kernel/jobs), jobs dashboard (grep -rln '"/admin/jobs' src), accounts (the ACCOUNTS: header line; password_digest, src/models/login_link.rs, totp), realtime (ls -d src/channels src/kernel/channels.rs), and a dev dashboard (grep -rn '"/dev/dashboard' src; usually none).
    • Converge: for each area found, read the sample's README and compare it with what the app has. Then, in the same message as the upgrade plan, tell the human in a sentence or two per area what the sample would add to this app, and what adopting costs. A mailbox with HTML, text and source views and a 320 px phone lint; jobs on the database with a dashboard that groups errors by root cause; accounts with TOTP, recovery codes, magic link + code and an account security page; channels with presence and authorization on every join; a LiveDashboard-style dev dashboard with traces, SQL with N+1 detection, a log tail and a routes explorer. Be enthusiastic where it's genuinely better and plain about the cost (files replaced, tables to reconcile, more code to own). Leave out what the app already does as well. Ask per area: adopt, borrow ideas or skip. Never replace working code without that yes, and in a non-interactive run install nothing: put the offer in the report.
    • If adopting: one slice per sample, after the convergence slices. Use the samples tag that matches .offrails/VERSION, install its dependencies first, and never copy a migration for a table the app already has (reconcile it). Port the sample's tests and its CHECKS.md rows into the feature spec's ## Security table, keep its // SECURITY: markers, and delete the replaced code only once the sample's and the app's tests are green. Then the Definition of Done on SQLite and Postgres.
    • Prove it: docs/decisions.md has YYYY-MM-DD: samples offered on upgrade to 0.7.0: <sample> adopted|borrowed|skipped, …. For each adopted sample, its tests run green in the app's suite on both databases, and sh .offrails/tools/offrails-check passes with its Security rows.
  • Route sweeps skip reading SSE bodies (test-standards §3, new). Agents reported that the "every ROUTES entry is routable" sweep and the access matrix hang forever on streaming routes (/streams/{stream}, /socket/sse, a mailbox events stream): TestApp::get waits for a body that never ends. Streaming routes now go through TestApp::request_head, which is in the TestApp spec now with the reference signature (path, headers) -> (StatusCode, HeaderMap), chosen by a STREAMING list next to the test, plus a check that every Sse<…> handler is on that list. App migration (apps with SSE): Detect: cargo nextest run security_test hangs, or grep -rn 'Sse<' src finds handlers that aren't in a STREAMING list in tests/app/security_test.rs. Converge: add the list and use request_head for those routes. Prove it: the security suite finishes on both databases.

  • The theme cookie is offrails_theme (views). The manual-toggle recipe said theme (light | dark, absent = system), and the samples' kernel pages use offrails_theme=system|light|dark, so an app and its kernel pages could disagree. Now there's one cookie and one switch: offrails_theme, values system|light|dark (anything else means system), not HttpOnly (the script writes it), exempted by exact name from the production HttpOnly backstop, with the server rendering data-theme and color-scheme, and POST /theme without JS taking only a same-site return_to. App migration (apps with a manual theme toggle): Detect: grep -rn '"theme"' src static/app.js finds the cookie name. Converge: rename it to offrails_theme. For one release, also read the old theme cookie when the new one is absent, so nobody's choice resets. Add the exemption and its production test. Prove it: the production cookie test, then run.sh a11y in both schemes with the switch forcing each.

  • Doc gaps from agents, checked: (1) new builds the mailer and jobs into every app. That's deliberate ("one profile", new) and stays, because the second feature of most apps needs one of them. What's new is the head start, which takes that kernel from the samples instead of from prose. (2) The mailers guide assumes jobs. True, and correct for every app new makes. mailers now says so, and says what to do without a queue (the mailer sample alone). (3) rustfmt moves anchor comments. Only trailing ones, and only with their line; anchors stand alone now (samples). (4) Node 22.12 vs 20.19. 20.19 is Vite 8's own engines (^20.19.0 || >=22.12.0, checked in 8.2.2), not the stack's floor. stack and new now say that TS apps need ≥ 22.22, the smokes ≥ 22.12, and Node 24 LTS for both. App migration: none.