Summary
GET /pages/:version/template (PagesController#template) never reads the :version URL token. It renders content from the user's current state at the moment the AJAX request arrives, and stamps the response Cache-Control: private, max-age=604800, immutable. The browser caches by URL for 7 days without revalidation. If the user's app type changes between the page render (which embeds the token) and the deferred template AJAX, the wrong app type's templates are permanently bound to the URL for the correct app type.
Follow-up to #1279 / PR #1283 (which fixed the compiled-file cache-key scoping) and the viewable_tables cache-key fix. This is a narrower race, but produces the identical user-visible symptom: persistently missing master panel tabs / incomplete templates.
Mechanism
- Each full page load embeds
_fpa.state.template_version = '<digest>' (app/views/layouts/_setup_app.html.erb). The digest (ApplicationHelper#template_version -> partial_cache_key(:loaded)) includes the user's app_type_id at page-render time.
- Later, asynchronously,
_fpa.load_template_version fetches GET /pages/<token>/template with cache: true.
PagesController#template ignores params[:id], computes the etag and renders masters/cache_search_results_template from the user's app type at AJAX time, and sets set_browser_cache(max_age: 604_800, immutable: true).
- The browser stores the response keyed by URL, and
immutable prevents any revalidation for a week.
Concrete failure sequence (multi-tab)
- t0: Tab 1 loads
/masters/search in app type A. HTML embeds token V_A. The heavy template AJAX has not fired yet.
- t1: Tab 2 switches app type via the navbar (
/pages/home?use_app_type=B). AppTypeChange saves user.app_type_id = B (shared by all tabs).
- t2: Tab 1's deferred AJAX fires:
GET /pages/V_A/template. Server ignores V_A, sees app type B, returns app B's templates with max-age=604800, immutable. Browser caches B-content under the A URL.
- t3: User switches back to app A. The new page embeds
V_A again (same sign-in, same timestamps -> same digest). Browser serves the cached app B templates without contacting the server. Master panel shows app B's tabs; app A's tabs are missing.
The poisoning is durable within the session:
- Revalidation cannot repair it: the etag in
pages#template is also computed from current state, so when the user is back on app A the server's etag matches what the content should be and returns 304, confirming the browser's wrong body.
immutable means normal reloads never revalidate anyway; only a hard refresh or cache eviction clears it.
The same race can fire without a second tab: clicking the app selector while the first page is still loading its deferred template AJAX, or a logo_link/home redirect that embeds use_app_type and changes app type between page render and AJAX arrival.
Why this was previously hidden
Before #1270 (in 9.46.2 and earlier), partial_cache_key included user.updated_at, and every User save (including the app type switch itself) cleared the whole Rails cache and rotated server_cache_version. Switching A->B->A produced a brand-new URL every time, so a poisoned URL was never requested again. 9.46.3's stable tokens make URL reuse - and therefore permanent poisoning - possible.
Suggested fix
Make the server honor the URL/content contract in PagesController#template:
- Compute the current
template_version digest and compare with params[:id].
- If they match: serve as now, with
private, max-age=604800, immutable.
- If they do not match: serve with
Cache-Control: no-store (or redirect to the current-token URL) so mismatched content can never be cached under a long-lived immutable header.
A system spec should reproduce the race deterministically, e.g. render a page for app A, change the user's app type server-side before triggering the template fetch, and assert that the mismatched response is not cached / the correct tabs are shown after switching back.
Related
Summary
GET /pages/:version/template(PagesController#template) never reads the:versionURL token. It renders content from the user's current state at the moment the AJAX request arrives, and stamps the responseCache-Control: private, max-age=604800, immutable. The browser caches by URL for 7 days without revalidation. If the user's app type changes between the page render (which embeds the token) and the deferred template AJAX, the wrong app type's templates are permanently bound to the URL for the correct app type.Follow-up to #1279 / PR #1283 (which fixed the compiled-file cache-key scoping) and the
viewable_tablescache-key fix. This is a narrower race, but produces the identical user-visible symptom: persistently missing master panel tabs / incomplete templates.Mechanism
_fpa.state.template_version = '<digest>'(app/views/layouts/_setup_app.html.erb). The digest (ApplicationHelper#template_version->partial_cache_key(:loaded)) includes the user's app_type_id at page-render time._fpa.load_template_versionfetchesGET /pages/<token>/templatewithcache: true.PagesController#templateignoresparams[:id], computes the etag and rendersmasters/cache_search_results_templatefrom the user's app type at AJAX time, and setsset_browser_cache(max_age: 604_800, immutable: true).immutableprevents any revalidation for a week.Concrete failure sequence (multi-tab)
/masters/searchin app type A. HTML embeds tokenV_A. The heavy template AJAX has not fired yet./pages/home?use_app_type=B).AppTypeChangesavesuser.app_type_id = B(shared by all tabs).GET /pages/V_A/template. Server ignoresV_A, sees app type B, returns app B's templates withmax-age=604800, immutable. Browser caches B-content under the A URL.V_Aagain (same sign-in, same timestamps -> same digest). Browser serves the cached app B templates without contacting the server. Master panel shows app B's tabs; app A's tabs are missing.The poisoning is durable within the session:
pages#templateis also computed from current state, so when the user is back on app A the server's etag matches what the content should be and returns 304, confirming the browser's wrong body.immutablemeans normal reloads never revalidate anyway; only a hard refresh or cache eviction clears it.The same race can fire without a second tab: clicking the app selector while the first page is still loading its deferred template AJAX, or a
logo_link/home redirect that embedsuse_app_typeand changes app type between page render and AJAX arrival.Why this was previously hidden
Before #1270 (in 9.46.2 and earlier),
partial_cache_keyincludeduser.updated_at, and every User save (including the app type switch itself) cleared the whole Rails cache and rotatedserver_cache_version. Switching A->B->A produced a brand-new URL every time, so a poisoned URL was never requested again. 9.46.3's stable tokens make URL reuse - and therefore permanent poisoning - possible.Suggested fix
Make the server honor the URL/content contract in
PagesController#template:template_versiondigest and compare withparams[:id].private, max-age=604800, immutable.Cache-Control: no-store(or redirect to the current-token URL) so mismatched content can never be cached under a long-lived immutable header.A system spec should reproduce the race deterministically, e.g. render a page for app A, change the user's app type server-side before triggering the template fetch, and assert that the mismatched response is not cached / the correct tabs are shown after switching back.
Related
viewable-tables-cache-scope-1279)