Skip to content

Commit 441e7f4

Browse files
Fix GitHub login broken by conditional CSRF tag (#2859)
We're trying to dramatically increase what the CDN caches. We're now fixing a problem we found while trying to do this. The CDN-caching change (commit 9f8e5ae2) made csrf_meta_tags conditional on logged_in?, on the documented assumption that the only rails-ujs "method:" links live on logged-in pages. However, that assumption was wrong: the login page's "Log in with GitHub" button is a `link_to ... method: 'post'`, and rails-ujs reads the CSRF token from the meta tag. With the tag gone for anonymous users, the POST to /auth/github carried no token, OmniAuth rejected it with ActionController::InvalidAuthenticityToken, and login 404'd via /auth/failure. This commit fixes the problem via the plan's own Section 9.1 opt-in escape hatch: * Gate the layout tag on `logged_in? || content_for?(:needs_csrf_meta)`. * Have the login page (sessions/new) opt in. This is scoped to the login *page*, not the header's "Login" link (a plain GET link_to), so every other anonymous page stays cookie-free and CDN-cacheable; the never-cached login page is the sole, documented exception. Also correct a subtle gotcha in the documented snippet: the block form `content_for(:needs_csrf_meta) { true }` does NOT work -- content_for captures the block's rendered output, and a block returning bare `true` outputs nothing, so content_for? stays false. The working form is the non-block `content_for :needs_csrf_meta, 'true'`. Fixed in code and docs. Add a regression test that reproduces the production failure: it asserts the anonymous login page emits the CSRF meta tag, run WITH forgery protection enabled (it is disabled by default in the test env, which is why the original change shipped without catching this). Also add script/verify_cdn_caching.sh, the staging/production verification harness used throughout this work (anonymous cacheable + no cookie, session and remember-me cookies bypass the cache, JSON still caches; --verbose dumps full headers). It exits non-zero on any failure, doubling as the periodic synthetic monitor recommended in Section 9.4. Signed-off-by: David A. Wheeler <dwheeler@dwheeler.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 27bb73a commit 441e7f4

5 files changed

Lines changed: 372 additions & 19 deletions

File tree

app/views/layouts/application.html.erb

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@
88
Only emit the CSRF token when it can actually be used. Emitting it calls
99
form_authenticity_token, which writes session[:_csrf_token] and forces a
1010
_BadgeApp_session cookie -- defeating CDN caching for anonymous visitors.
11-
Anonymous read-only pages need no token: form_with embeds its own hidden
12-
authenticity_token (so login/signup/reset forms still work), and the only
13-
JavaScript consumer of this meta tag (jQuery UJS "method:" links such as
14-
logout and user-delete) appears only on logged-in pages. See
15-
docs/cdn-cache-not-logged-in.md.
11+
Most anonymous read-only pages need no token: form_with embeds its own
12+
hidden authenticity_token (so login/signup/reset forms still work).
13+
The exception is an anonymous page with a rails-ujs "method:" link, which
14+
reads the token from this meta tag -- e.g. the login page's "Log in with
15+
GitHub" button (a link_to ... method: 'post'). Such a page must opt in
16+
explicitly with `content_for :needs_csrf_meta, 'true'` (the non-block form;
17+
a block returning bare `true` outputs nothing, so content_for? stays
18+
false), keeping the safe, cacheable default (no token) for other pages. See
19+
docs/cdn-cache-not-logged-in.md (Change 1 and Section 9.1).
1620
-%>
17-
<% if logged_in? %><%= csrf_meta_tags %><% end %>
21+
<% if logged_in? || content_for?(:needs_csrf_meta) %><%= csrf_meta_tags %><% end %>
1822
<%= yield :special_head_values %>
1923
<%# The following are the same for a given locale, cache for speed -%>
2024
<% cache_frozen locale do # do not cache csrf_meta_tags -%>

app/views/sessions/new.html.erb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
<%# The "Log in with GitHub" button below is a rails-ujs link_to ... method:
2+
'post'; rails-ujs reads the CSRF token from the <meta name="csrf-token">
3+
tag, which the layout omits for anonymous users (CDN caching, Change 1).
4+
Opt back in so the token is present and the POST to /auth/github passes
5+
CSRF. This page is never CDN-cached, so emitting the tag here is harmless.
6+
See docs/cdn-cache-not-logged-in.md Section 9.1. %>
7+
<%# Use the non-block form: a content_for block captures the block's *output*,
8+
and a block returning a bare `true` outputs nothing, so content_for? would
9+
stay false. A non-empty string value makes content_for? true. %>
10+
<% content_for :needs_csrf_meta, 'true' %>
111
<% content_for :special_head_values do %>
212
<%# same-origin (must NOT be no-referrer):
313
Cross-origin requests (e.g. the OmniAuth redirect to github.com) carry no

docs/cdn-cache-not-logged-in.md

Lines changed: 119 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -354,18 +354,50 @@ with a guarded version:
354354
Only emit the CSRF token when it can actually be used. Emitting it calls
355355
form_authenticity_token, which writes session[:_csrf_token] and forces a
356356
_BadgeApp_session cookie -- defeating CDN caching for anonymous visitors.
357-
Anonymous read-only pages need no token: form_with embeds its own hidden
358-
authenticity_token (so login/signup/reset forms still work), and the only
359-
JavaScript consumer of this meta tag (jQuery UJS "method:" links such as
360-
logout and user-delete) appears only on logged-in pages. See
361-
docs/cdn-cache-not-logged-in.md.
357+
Most anonymous read-only pages need no token: form_with embeds its own
358+
hidden authenticity_token (so login/signup/reset forms still work).
359+
The exception is an anonymous page with a rails-ujs "method:" link, which
360+
reads the token from this meta tag -- e.g. the login page's "Log in with
361+
GitHub" button (a link_to ... method: 'post'). Such a page must opt in
362+
explicitly with `content_for :needs_csrf_meta, 'true'` (the non-block form;
363+
a block returning bare `true` outputs nothing, so content_for? stays
364+
false), keeping the safe, cacheable default (no token) for other pages. See
365+
docs/cdn-cache-not-logged-in.md (Change 1 and Section 9.1).
362366
-%>
363-
<% if logged_in? %><%= csrf_meta_tags %><% end %>
367+
<% if logged_in? || content_for?(:needs_csrf_meta) %><%= csrf_meta_tags %><% end %>
364368
```
365369

366370
`logged_in?` is the existing `SessionsHelper` predicate
367371
(`@session_user_id.present?`) and is available in views.
368372

373+
> **The login page is a real anonymous UJS consumer — do not skip the opt-in.**
374+
> An earlier draft of this plan claimed UJS `method:` links "appear only on
375+
> logged-in pages." That is **wrong**: `app/views/sessions/new.html.erb`
376+
> renders the "Log in with GitHub" button as `link_to … method: 'post'`, and
377+
> rails-ujs reads the token from this meta tag. Without the opt-in, the
378+
> anonymous login page has no meta tag, the generated POST to `/auth/github`
379+
> carries no token, and OmniAuth rejects it
380+
> (`ActionController::InvalidAuthenticityToken``/auth/failure` → 404). The
381+
> login page therefore sets `content_for :needs_csrf_meta, 'true'` (see the
382+
> per-page opt-in note below). The header's "Login"/"Sign up" entries are
383+
> ordinary GET `link_to`s, so they do **not** trigger this and every other
384+
> anonymous page stays cookie-free and cacheable. A regression test
385+
> (`test/integration/cdn_caching_test.rb`, "anonymous login page emits the CSRF
386+
> meta tag for GitHub OAuth") locks this in **with forgery protection enabled**
387+
> — without that flag the bug is invisible in the test environment (Section 6).
388+
389+
**Per-page opt-in — use the non-block `content_for`.** On a page that needs the
390+
token, set the flag at the top of its template:
391+
392+
```erb
393+
<% content_for :needs_csrf_meta, 'true' %>
394+
```
395+
396+
Use the **non-block** form with a non-empty string value. The block form
397+
`content_for(:needs_csrf_meta) { true }` does **not** work: `content_for`
398+
captures the block's rendered *output*, and a block returning a bare `true`
399+
outputs nothing, so `content_for?` stays false and the tag is never emitted.
400+
369401
### Change 2: Cache anonymous `projects#show` HTML
370402

371403
In [`app/controllers/projects_controller.rb`](../app/controllers/projects_controller.rb),
@@ -464,6 +496,14 @@ cookies.
464496

465497
#### Option A: Fastly Web UI Request Setting (recommended)
466498

499+
This is the configuration actually used in production. Because it lives in the
500+
Fastly service (not in this repository), it is **not** under the project's
501+
normal version control — so the exact steps are recorded here. It is a
502+
load-bearing security control (Section 9.4): without it, a logged-in or
503+
remember-me user can be served a cached anonymous page.
504+
505+
The setting in summary:
506+
467507
* **Name:** `Bypass cache for personalized requests`
468508
* **Action:** `Pass`
469509
* **Condition** — Apply if:
@@ -472,6 +512,55 @@ cookies.
472512
req.http.Cookie ~ "(_BadgeApp_session|remember_token|user_id)=" && req.url.path !~ "\.(css|js|png|gif|jpg|jpeg|svg|json|csv|txt|ico|woff2?|map)$" && req.url.path !~ "/(badge|baseline)$"
473513
```
474514

515+
**Step-by-step in the Fastly web UI:**
516+
517+
1. **Identify the service.** The Fastly service is named by the `FASTLY_SERVICE_ID`
518+
environment variable on the corresponding Heroku app
519+
(`heroku config -a <app> | grep FASTLY_SERVICE_ID`). Match that ID in the
520+
dashboard. Do staging first, then production.
521+
2. **Open and clone the active version.** At
522+
[manage.fastly.com](https://manage.fastly.com), open the service, then click
523+
**Edit configuration → Clone version N to edit**. Fastly will not let you
524+
edit the live version directly; cloning creates an editable draft, and
525+
nothing goes live until you Activate (step 6).
526+
3. **Create the request setting.** In the left sidebar click **Settings** (the
527+
gear). In the **Request Settings** section click **Create a request
528+
setting**, then set **Name** = `Bypass cache for personalized requests` and
529+
**Action** = **Pass**. Leave the other fields at their defaults.
530+
4. **Attach the condition.** In the same form, under **Conditions** / **Request
531+
condition**, click **Create a new condition**. Set **Name** =
532+
`Personalized page request` and paste the **Apply if** expression above
533+
verbatim (it is valid VCL; if the UI rejects it, the usual cause is a
534+
smart-quote introduced by copy/paste — make sure the quotes are plain `"`).
535+
Save the condition, then save the request setting.
536+
5. **(Optional) Cookie-stripping for hit rate.** The `unset req.http.Cookie`
537+
optimization in Option B (which lets guests carrying only unrelated cookies,
538+
e.g. analytics, still share one cached object) is **not** expressible as a
539+
simple Request Setting. It is *not required for correctness* — Option A above
540+
is sufficient and safe on its own. If you want the hit-rate improvement, add
541+
it via a small custom VCL snippet (Option B) instead.
542+
6. **Activate.** Click **Activate** on the draft version to make it live (a few
543+
seconds to propagate).
544+
545+
> **If your UI has no "Pass" action on Request Settings:** the equivalent is a
546+
> **Cache Setting** with **Action: Pass** plus a **Cache condition** using the
547+
> same expression.
548+
549+
**Companion Rails kill switch.** The Fastly rule and the Rails kill switch
550+
(`BADGEAPP_CACHE_SHOW_PROJECT`, Change 2) are independent. Caching HTML only
551+
actually happens when *both* are enabled: the Fastly bypass rule is active *and*
552+
`BADGEAPP_CACHE_SHOW_PROJECT` is not `false`. If show pages return
553+
`private, no-store` with no `Surrogate-Control` even after the Rails deploy,
554+
check `heroku config -a <app> | grep BADGEAPP_CACHE_SHOW_PROJECT` — set it to
555+
`true` (or unset it; the default is on) and restart. Conversely, setting it to
556+
`false` is the instant rollback for HTML caching, leaving this Fastly rule
557+
harmlessly in place.
558+
559+
**Verify after activating** with `script/verify_cdn_caching.sh -v <base-url>`
560+
(Section 8): a request carrying `_BadgeApp_session` or `remember_token` must
561+
return a non-`HIT` `X-Cache` even when an anonymous cached object exists, while
562+
an anonymous request gets `MISS` then `HIT`.
563+
475564
#### Option B: Custom VCL snippet (placement: `recv`)
476565

477566
```vcl
@@ -755,6 +844,13 @@ The rendered show page is the canonical section URL
755844
(`/en/projects/:id/:section`); `/en/projects/:id` only redirects to it, so
756845
the tests below target the section URL directly.
757846

847+
[`script/verify_cdn_caching.sh`](../script/verify_cdn_caching.sh) automates all
848+
of the checks in this section (run it as
849+
`script/verify_cdn_caching.sh -v https://staging.bestpractices.dev 1`); it
850+
exits non-zero on any failure, so it also serves as the periodic synthetic
851+
monitor recommended in Section 9.4. The raw `curl` recipes below remain useful
852+
for ad-hoc inspection.
853+
758854
### Anonymous request is cached
759855

760856
```bash
@@ -817,18 +913,28 @@ which our change omits for anonymous users.
817913
cookie" test iterates over a list of anonymous pages; adding UJS/AJAX to
818914
any of them flips the meta tag back on and trips the test. Keep that list
819915
current as anonymous pages are added.
820-
* **Do better (opt-in escape hatch).** If a specific anonymous page ever
821-
legitimately needs the token, it should opt in explicitly rather than
822-
forcing it globally. For example, gate the layout on login *or* an
823-
explicit request:
916+
* **Opt-in escape hatch (implemented).** A specific anonymous page that
917+
legitimately needs the token opts in explicitly rather than forcing it
918+
globally. The layout is gated on login *or* an explicit request:
824919

825920
```erb
826921
<% if logged_in? || content_for?(:needs_csrf_meta) %><%= csrf_meta_tags %><% end %>
827922
```
828923

829-
and have that one page set `content_for(:needs_csrf_meta) { true }`. This
830-
keeps the default (no token, cacheable) safe while making the exception
831-
loud and local.
924+
and the page sets the flag at the top of its template:
925+
926+
```erb
927+
<% content_for :needs_csrf_meta, 'true' %>
928+
```
929+
930+
This is **already in use** by the login page
931+
(`app/views/sessions/new.html.erb`), whose "Log in with GitHub" button is a
932+
rails-ujs `link_to … method: 'post'` that needs the token (see the warning
933+
under Change 1). Use the **non-block** form with a non-empty string:
934+
`content_for(:needs_csrf_meta) { true }` does **not** work because
935+
`content_for` captures the block's *output*, and a block returning a bare
936+
`true` outputs nothing, leaving `content_for?` false. The default (no token,
937+
cacheable) stays safe for every page that does not opt in.
832938

833939
### 9.2 A persistent flash reaches an anonymous user
834940

0 commit comments

Comments
 (0)