@@ -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
371403In [ ` 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
756845the 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