Repository navigation
Rust off Rails 0.7.0
What's new
- Security fix: development is reachable only from this machine.
servebinds127.0.0.1in 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 newoffrails-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_atstays 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'sDebugprints 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(default127.0.0.1in development) and checkHost;offrails-checkfails with[bind]until it does. A dev container, tunnel or LAN workflow now needsAPP_BIND=0.0.0.0plusAPP_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_atcolumn, 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
themetooffrails_theme(read the old one for one release). - Every app also ports the security fixes: job errors stored redacted,
Config'sDebugas 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
servebound0.0.0.0in every environment (cli said so) and nothing checkedHost./devhas no login: the mailbox shows every magic link and reset link, introspection shows the app's state, and/admin/jobsis open in development. So anyone on the same Wi-Fi could openhttp://<your-ip>:3000/dev/mailboxand sign in as you. A page onevil.examplethat re-points its own name at127.0.0.1(DNS rebinding) reached it too: the browser sendsHost: evil.examplesame-origin, so CORS doesn't apply, and Fetch Metadata sayssame-origin, so theCsrfLayerlets its POSTs through. Now, in development only (security, new non-negotiable 13):servebindsconfig.bind:APP_BIND, default127.0.0.1in development and0.0.0.0in production. A dev container setsAPP_BIND=0.0.0.0(config, deployment).kernel/hosts.rs(whole file in the guide, like Rails'HostAuthorization) answers onlylocalhost,127.0.0.1,[::1],*.localhost,APP_URL's host andAPP_ALLOWED_HOSTS(a leading.allows subdomains). Any otherHostgets a 403 page that says how to allow it. It's mounted right inside the security middleware (routing).- An unset
APP_ENVstill means development, butConfig::boot_warningsnow logs that at every boot. On a server it's a missing setting that mounts/dev. - New
offrails-checkrule[bind], with fixtures. It fails a literal0.0.0.0bind insrc/([0, 0, 0, 0],"0.0.0.0:…",Ipv4Addr::UNSPECIFIEDoutsideconfig.rs), aconfig.rsthat never readsAPP_BIND, and an app with no Host check. A0.0.0.0that 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.mdNEVER list,/offrails:security, andTestAppgainsget_with_headers/post_form_with_headers. Changed: the macOS port-collision note in cli. A127.0.0.1:$PORTbind succeeds while another process holds*:$PORT, because tokio setsSO_REUSEADDR; thenlocalhostover IPv6 may reach the other app. Verified on macOS 2026-10-16, solsofbefore 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_otherwiseandan_unset_app_env_warns_at_boot, plushosts.rs's two unit tests. The running server listened on127.0.0.1only, refused its LAN IP, answeredHost: evil.testwith 403 and printed the boot warning.App migration (every app; security, do it first):
- Detect:
sh .offrails/tools/offrails-checkprints[bind], orgrep -rnE '\[0, *0, *0, *0\]|"0\.0\.0\.0' srcmatches, orgrep -c APP_BIND src/config.rsis 0. - Converge:
- Add
bind,allowed_hostsandenv_defaultedtoConfig, withbind_address,parse_allowed_hosts,boot_warningsand their two tests, copied from config. Add theAPP_BINDandAPP_ALLOWED_HOSTSlines to.env.example. - In
main.rs, logboot_warnings()afterinit_tracing. Every listener bindsSocketAddr::new(config.bind, port): the main port,APP_DEV_HTTP_PORT, andkernel::server::runif the app has one. - Copy
src/kernel/hosts.rswhole from security, addpub mod hosts;, and mount it just insidesecurity::middlewareinroutes.rs. - Add the three request tests to
security_test.rs, andget_with_headers/post_form_with_headerstoTestAppif they're missing. - Anything that runs development in a container (a dev container, a compose override with
APP_ENV: development) setsAPP_BIND: 0.0.0.0, publishes on127.0.0.1:only, and lists any non-localhost name inAPP_ALLOWED_HOSTS. Production compose and the Dockerfile need no change: they setAPP_ENV=production, which binds0.0.0.0. - A
docs/decisions.mdline, a tunnel or a LAN workflow that relied on0.0.0.0becomesAPP_BIND=0.0.0.0+APP_ALLOWED_HOSTS=<name>in.env. Say so to the human: that network can then read the mailbox.
- Add
- Prove it: the suite on both databases, then with the app running in development:
lsof -nP -iTCP:$PORT -sTCP:LISTENshows127.0.0.1:$PORT(not*:$PORT);curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.test' $URL/devprints403;curl -m 3 http://$(ipconfig getifaddr en0 || hostname -I | cut -d' ' -f1):$PORT/upis refused; starting withAPP_ENVunset logsAPP_ENV is not set;sh .offrails/tools/offrails-checkshowsok [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::destroyon 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'supdate) don't takeOption<CurrentSession>, or set the new cookie without destroying the old session. Converge: destroy it in the sign-in transaction, beforesession::start. Prove it: a test signs in as A, opens a stream or socket as A, then signs in as B in the sameTestApp: A's connection getsbyerevoked, and A's old cookie, set back withset_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' srcfinds nothing. Converge: add the per-address key topasswords::createand 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, variedX-Forwarded-For); the 21st sends nothing (drain_jobs, thensent_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. Nowusers.email_verified_atstaysNULLuntil something sent to the address is used. The first proof (consuming a reset or a login link, accepting an invite) runsuser::claim_unverifiedin 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.rsfinds nothing. Converge: a migration adds the nullable column, backfilledNULL: 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. Addclaim_unverified, call it inpassword_reset::consumeandlogin_link::consume, and drop the pre-claim credentials ontrue. Magic-link first logins create users verified. Prove it:an_api_token_made_before_the_first_reset_is_revoked(apps with an API) ora_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 injobs.errors, which/admin/jobsshows and searches andapp jobs showprints. Errors now go through the newkernel::redact::textbefore they're stored or logged. It redacts URL userinfo, random-looking path segments, secret-named query values andkey=valuepairs, 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/jobsfinds nothing. Converge: addsrc/kernel/redact.rsfrom the guide, and callredact::texton the message in the executor's error push and itsjob.exceptionlog; existing rows stay as they are (offer the human a one-off task that rewriteserrorsthrough it, or let the Pruner age them out). Prove it:job_errors_are_stored_redactedon 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'sDebugis 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.jsonandapp introspect. NowDebugprints values only for settings that can never hold a secret, andset/not setfor the rest. App migration: Detect:src/config.rs'simpl fmt::Debug for Configprints aStringorOption<String>field outside the allowlist with&self.<field>. Converge: printset/not setfor those. Prove it:debug_shows_no_secret_values(a sentinel in every non-allowlisted string field, none informat!("{c:?}")), thencurl -s $URL/dev/introspect.json | grep -c sentinelis 0 with a sentinel in.env. -
Presence never derives a label from the email (channels). Also from the samples' review. The guide's
member_forbuilt presence initials fromu.email, andann.lee@…→ "AL" tells a room who's there. Presence now uses a display name the person chose (or its initials), else a neutralMember <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, orMember <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,realtimeanddev-dashboard. Each has a README (Adapting it, Do not change), a CHECKS.md whose every row cites a test, a PROMPT.md and asample.toml;tools/install.shthere 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-linesamples: <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 modsmoves when themodlines 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 thesamples: tracing layerslist, added throughsample_layers()as anOption: an emptyVeclayer 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'shost/, which isnewwithout 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 withinstall.sh. An app that keeps its owninit_tracingkeeps 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 (theACCOUNTS: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## Securitytable, 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.mdhasYYYY-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, andsh .offrails/tools/offrails-checkpasses with its Security rows.
- Detect: the areas the app has: mailer (
-
Route sweeps skip reading SSE bodies (test-standards §3, new). Agents reported that the "every
ROUTESentry is routable" sweep and the access matrix hang forever on streaming routes (/streams/{stream},/socket/sse, a mailboxeventsstream):TestApp::getwaits for a body that never ends. Streaming routes now go throughTestApp::request_head, which is in theTestAppspec now with the reference signature(path, headers) -> (StatusCode, HeaderMap), chosen by aSTREAMINGlist next to the test, plus a check that everySse<…>handler is on that list. App migration (apps with SSE): Detect:cargo nextest run security_testhangs, orgrep -rn 'Sse<' srcfinds handlers that aren't in aSTREAMINGlist intests/app/security_test.rs. Converge: add the list and userequest_headfor those routes. Prove it: the security suite finishes on both databases. -
The theme cookie is
offrails_theme(views). The manual-toggle recipe saidtheme(light|dark, absent = system), and the samples' kernel pages useoffrails_theme=system|light|dark, so an app and its kernel pages could disagree. Now there's one cookie and one switch:offrails_theme, valuessystem|light|dark(anything else means system), notHttpOnly(the script writes it), exempted by exact name from the productionHttpOnlybackstop, with the server renderingdata-themeandcolor-scheme, andPOST /themewithout JS taking only a same-sitereturn_to. App migration (apps with a manual theme toggle): Detect:grep -rn '"theme"' src static/app.jsfinds the cookie name. Converge: rename it tooffrails_theme. For one release, also read the oldthemecookie when the new one is absent, so nobody's choice resets. Add the exemption and its production test. Prove it: the production cookie test, thenrun.sh a11yin both schemes with the switch forcing each. -
Doc gaps from agents, checked: (1)
newbuilds 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 appnewmakes. mailers now says so, and says what to do without a queue (themailersample 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.19is Vite 8's ownengines(^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.