Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/guide/admin/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,14 @@ A row may carry a badge:

- **Keyed per caller** — `keys.useAuth` is on, so entries are stored separately
per authenticated caller.
- **Shared across callers** — `hitpass` is off but `keys.useAuth` is not set,
so one caller's response can be served to another. The server also warns
about this at boot; the badge is here because nobody reads boot logs.
- **Shared across callers** — at least one of the content type's GET routes
resolved to `hitpass: false` without `keys.useAuth`, so one caller's response
can be served to another. It is judged per route, on what each route
actually resolved to, exactly as the boot warning is. So a listed route that
turns `hitpass` off itself is flagged, and a content type whose listed routes
keep the strategy's bypass is not. Hover the badge for the routes at fault.
The server also warns about this at boot; the badge is here because nobody
reads boot logs.

The **Purge** button clears every entry for that content type, and for the
content types related to it when `clearRelatedCache` is on. The counts update
Expand Down
8 changes: 4 additions & 4 deletions docs/guide/caching/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,10 +300,10 @@ tokens carry different permissions.
Cache that without `useAuth` and the first caller to miss decides what everyone
else sees, until the entry expires.

The server logs a warning at boot when it finds a content type with
`hitpass: false` and no `keys.useAuth`, naming the content type. The admin
dashboard shows the same thing as a **Shared across callers** badge, because
nobody reads boot logs.
The server logs a warning at boot when it finds a GET route that resolved to
`hitpass: false` with no `keys.useAuth`, naming the content type and the routes.
The admin dashboard shows the same thing as a **Shared across callers** badge,
because nobody reads boot logs.

:::: code-group

Expand Down
6 changes: 3 additions & 3 deletions docs/guide/recipes/authenticated.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,9 @@ merely useless: `useAuth` adds a component to keys that are never written,
because `hitpass` bypassed them.

::: warning The server tells you when you get this wrong
Strapi logs a warning at boot naming any content type with `hitpass: false` and
no `keys.useAuth`, and the admin dashboard shows it as a **Shared across
callers** badge on that content type's row. If you see either, treat it as a
Strapi logs a warning at boot naming any content type with a GET route that
resolved to `hitpass: false` and no `keys.useAuth`, and the admin dashboard
shows it as a **Shared across callers** badge on that content type's row. If you see either, treat it as a
data-exposure bug rather than a configuration nit.
:::

Expand Down
8 changes: 8 additions & 0 deletions docs/guide/reference/routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,21 @@ endpoint does not make someone an operator entitled to credentials.
"maxAge": 3600000,
"hitpass": true,
"keysAuthIdentity": false,
"unkeyedAuthenticatedRoutes": [],
"routes": ["/api/articles", "/api/articles/:id"],
"relatedContentTypes": ["api::category.category"]
}
]
}
```

`hitpass` and `keysAuthIdentity` describe the content type's own settings.
`unkeyedAuthenticatedRoutes` lists its GET routes that resolved to
`hitpass: false` without `keys.useAuth`. These are the routes the boot warning
names, and the list the dashboard's **Shared across callers** badge reads. A
route's resolved values can differ from its content type's, so the first two
fields do not answer that question on their own.

Counts are derived from the store's own key list rather than tracked
separately, so they cannot drift from reality. That does mean a call costs one
key enumeration — cheap on Redis, but not something to poll aggressively.
Expand Down
114 changes: 114 additions & 0 deletions e2e/tests/shared-across-callers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { test, expect, type Page } from '@playwright/test';

const DASHBOARD = '/admin/settings/rest-cache';
const BADGE = 'Shared across callers';

const rowFor = (page: Page, uid: string) => page.getByRole('row').filter({ hasText: uid });

/** The badge's tooltip text for a row: what an admin reads to act on it. */
async function tooltipFor(page: Page, uid: string) {
await rowFor(page, uid).getByText(BADGE).hover();
const tooltip = page.getByRole('tooltip');
await expect(tooltip).toBeVisible();

return tooltip;
}

interface StatsRow {
uid: string;
hitpass: boolean;
keysAuthIdentity: boolean;
unkeyedAuthenticatedRoutes?: string[];
}

/**
* Serve the real stats with some rows rewritten.
*
* The playground's strategy has no route that shares authenticated entries -
* every spec on this server relies on that - so the cases are staged in the
* response rather than in the configuration. The jest spec
* auth-risk-flag-consistency covers the server producing these values from a
* real strategy; this covers the dashboard reading them.
*/
async function stubStats(page: Page, rewrite: (row: StatsRow) => StatsRow) {
await page.route('**/rest-cache/stats', async (route) => {
const response = await route.fetch();
const body = (await response.json()) as { contentTypes: StatsRow[] };

body.contentTypes = body.contentTypes.map(rewrite);
await route.fulfill({ response, json: body });
});
}

test.describe('"Shared across callers" badge', () => {
/**
* The content type keeps the default hitpass, but a listed route caches
* authenticated responses unkeyed. The server warns about it at boot.
*/
test('flags a content type for a route that shares authenticated entries', async ({
page,
}) => {
await stubStats(page, (row) =>
row.uid === 'api::writer.editor'
? {
...row,
hitpass: true,
keysAuthIdentity: false,
unkeyedAuthenticatedRoutes: ['/api/editors/:id'],
}
: row
);

await page.goto(DASHBOARD);

await expect(rowFor(page, 'api::writer.editor')).toContainText(BADGE);

// This content type's own hitpass is enabled, so "Hitpass is disabled"
// would send the admin to a setting that is already right. The tooltip
// has to name the route that is actually at fault.
const tooltip = await tooltipFor(page, 'api::writer.editor');
await expect(tooltip).toContainText('Route /api/editors/:id caches authenticated requests');
await expect(tooltip).toContainText('keys.useAuth');
await expect(tooltip).not.toContainText('Hitpass is disabled');
});

/**
* hitpass: false on the content type, but every route keeps the strategy's
* bypass, so nothing authenticated is cached. The server does not warn.
*/
test('does not flag a content type whose routes all keep the bypass', async ({ page }) => {
await stubStats(page, (row) =>
row.uid === 'api::page.page'
? { ...row, hitpass: false, keysAuthIdentity: false, unkeyedAuthenticatedRoutes: [] }
: row
);

await page.goto(DASHBOARD);

// Rendered at all, so the negative below is not vacuous.
await expect(rowFor(page, 'api::page.page')).toContainText('/api/pages');
await expect(rowFor(page, 'api::page.page')).not.toContainText(BADGE);
});

test('falls back to the content type settings when the list is absent', async ({ page }) => {
await stubStats(page, (row) => {
const { unkeyedAuthenticatedRoutes: _omitted, ...rest } = row;

if (row.uid === 'api::page.page') {
return { ...rest, hitpass: false, keysAuthIdentity: false };
}

return rest;
});

await page.goto(DASHBOARD);

await expect(rowFor(page, 'api::page.page')).toContainText(BADGE);
await expect(rowFor(page, 'api::article.article')).not.toContainText(BADGE);

// Without the list, the badge was raised from the content type's own
// settings, which this sentence describes exactly.
const tooltip = await tooltipFor(page, 'api::page.page');
await expect(tooltip).toContainText('Hitpass is disabled but keys.useAuth is not set');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,16 @@ const ContentTypeTable = ({ contentTypes, canPurge }: ContentTypeTableProps) =>
// two people authorised for the same route share one entry. The
// server warns about this at boot; surface it where someone will
// actually see it.
const sharesAuthenticatedEntries =
contentType.hitpass === false && !contentType.keysAuthIdentity;
//
// From the server's per-route list, which is what the boot warning
// reads. The content type's own hitpass and keys missed a listed
// route that sets hitpass: false itself, and flagged content types
// whose listed routes all keep the strategy's bypass (#230). The
// old expression stays only for a server that does not send the
// list.
const sharesAuthenticatedEntries = contentType.unkeyedAuthenticatedRoutes
? contentType.unkeyedAuthenticatedRoutes.length > 0
: contentType.hitpass === false && !contentType.keysAuthIdentity;

return (
<Tr key={contentType.uid}>
Expand Down Expand Up @@ -156,11 +164,32 @@ const ContentTypeTable = ({ contentTypes, canPurge }: ContentTypeTableProps) =>
) : null}
{sharesAuthenticatedEntries ? (
<Tooltip
label={formatMessage({
id: getTranslation('contentTypes.authRisk.hint'),
defaultMessage:
"Hitpass is disabled but keys.useAuth is not set, so one caller's response can be served to another.",
})}
label={
// Name the routes when the server says which they
// are. "Hitpass is disabled" is wrong for a content
// type whose own hitpass is enabled and whose
// listed route disables it, and it sends the admin
// to a setting that is already right. The old text
// stays for the fallback, where the badge came
// from exactly those content type settings.
contentType.unkeyedAuthenticatedRoutes
? formatMessage(
{
id: getTranslation('contentTypes.authRisk.routesHint'),
defaultMessage:
"{count, plural, one {Route {routes} caches} other {Routes {routes} cache}} authenticated requests without keys.useAuth, so one caller's response can be served to another.",
},
{
count: contentType.unkeyedAuthenticatedRoutes.length,
routes: contentType.unkeyedAuthenticatedRoutes.join(', '),
}
)
: formatMessage({
id: getTranslation('contentTypes.authRisk.hint'),
defaultMessage:
"Hitpass is disabled but keys.useAuth is not set, so one caller's response can be served to another.",
})
}
>
{/* Themed, not a hardcoded hex: the badge has to stay
legible in dark mode and in every locale. Wrapped for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"contentTypes.authKeyed.hint": "Responses are cached separately for each authenticated caller.",
"contentTypes.authRisk": "Shared across callers",
"contentTypes.authRisk.hint": "Hitpass is disabled but keys.useAuth is not set, so one caller's response can be served to another.",
"contentTypes.authRisk.routesHint": "{count, plural, one {Route {routes} caches} other {Routes {routes} cache}} authenticated requests without keys.useAuth, so one caller's response can be served to another.",
"contentTypes.related": "Also purges {count, plural, one {# related type} other {# related types}}",
"panel.cached.title": "REST Cache",
"panel.cached.body": "Responses for this entry are cached for up to {maxAge} after they are first requested.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"contentTypes.authKeyed.hint": "Les réponses sont mises en cache séparément pour chaque appelant authentifié.",
"contentTypes.authRisk": "Partagé entre les appelants",
"contentTypes.authRisk.hint": "Le hitpass est désactivé mais keys.useAuth n'est pas défini : la réponse d'un appelant peut donc être servie à un autre.",
"contentTypes.authRisk.routesHint": "{count, plural, one {La route {routes} met} other {Les routes {routes} mettent}} en cache des requêtes authentifiées sans keys.useAuth : la réponse d'un appelant peut donc être servie à un autre.",
"contentTypes.related": "Vide également {count, plural, one {# type associé} other {# types associés}}",
"panel.cached.title": "REST Cache",
"panel.cached.body": "Les réponses pour cette entrée sont mises en cache pendant {maxAge} au maximum après leur première requête.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Core } from '@strapi/strapi';
import type { CachePluginHitpass } from '../types/common';
import type { CacheSummary, ContentTypeStats } from '../types/api';
import type { RestCachePluginConfig } from '../types/config';
import { unkeyedAuthenticatedRoutes } from '../utils/config/unkeyedAuthenticatedRoutes';

export type { CacheSummary, ContentTypeStats };

Expand Down Expand Up @@ -63,6 +64,11 @@ export default function createCacheStatsService({
maxAge: conf?.maxAge,
hitpass: (conf?.hitpass as CachePluginHitpass | boolean) !== false,
keysAuthIdentity: conf?.keys?.useAuth === true,
// Per route, on resolved values - the same predicate as the boot
// warning. `hitpass` and `keysAuthIdentity` above describe the
// content type's own settings, which a route can differ from in
// both directions, so they cannot answer this on their own.
unkeyedAuthenticatedRoutes: unkeyedAuthenticatedRoutes(conf?.routes ?? []),
routes: (conf?.routes ?? [])
.filter((route) => route.method === 'GET')
.map((route) => route.path as string),
Expand Down
12 changes: 12 additions & 0 deletions packages/plugin-rest-cache/server/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ export interface ContentTypeStats {
maxAge?: Milliseconds;
hitpass: boolean;
keysAuthIdentity: boolean;
/**
* GET routes that cache authenticated responses under a key that does not
* identify the caller: resolved `hitpass === false` without
* `keys.useAuth`. The same routes the boot warning names.
*
* This, not `hitpass && !keysAuthIdentity`, is what the dashboard's
* "Shared across callers" badge reads. Those two describe the content type,
* and a route's resolved values can differ from its content type's either
* way. Optional only so an admin bundle still renders against a server
* that predates the field; this server always sends it.
*/
unkeyedAuthenticatedRoutes?: string[];
routes: string[];
relatedContentTypes: string[];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Core } from '@strapi/strapi';
import { getRelatedModelsUid } from './getRelatedModelsUid';
import { deepFreeze } from './deepFreeze';
import { shouldLookup } from '../middlewares/shouldLookup';
import { unkeyedAuthenticatedRoutes } from './unkeyedAuthenticatedRoutes';
import {
CachePluginStrategy,
CacheRouteConfig,
Expand Down Expand Up @@ -307,15 +308,11 @@ export const resolveUserStrategy = function (
// on the content type alone both missed that and warned for content types
// whose own hitpass no route used.
// See https://github.com/strapi-community/plugin-rest-cache/issues/230
//
// The dashboard's "Shared across callers" badge uses the same predicate, so
// the two agree on which content types are at risk.
for (const cacheConfig of cacheConfigs) {
const unkeyedPaths = cacheConfig.routes
.filter(
(route) =>
route.method === 'GET' &&
route.hitpass === false &&
route.keys?.useAuth !== true
)
.map((route) => route.path);
const unkeyedPaths = unkeyedAuthenticatedRoutes(cacheConfig.routes);

if (unkeyedPaths.length) {
strapi.log.warn(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { CacheRouteConfig } from '../../types';

/**
* Whether a route caches authenticated responses under a key that does not
* identify the caller.
*
* Judged on the values the route actually resolved to, not on its content
* type's. A listed route can set `hitpass: false` itself under a content type
* that keeps the default, and a content type's `hitpass: false` is not
* inherited by listed routes whose keys do not identify the caller - they keep
* the strategy's bypass as a function (see listedRouteHitpass), which is never
* `=== false`. A check on the content type alone gets both of those wrong.
* See https://github.com/strapi-community/plugin-rest-cache/issues/230
*
* The boot warning and the dashboard's "Shared across callers" badge both
* read this, so the two cannot disagree again.
*
* `useAuth` is tested for truthiness because that is what generateCacheKey
* does, and the question here is whether the key identifies the caller. The
* type says boolean, but a JavaScript config can pass anything - `env()`
* returns strings - and `=== true` flagged a route keyed by `"true"` as
* shared when its entries were already per caller. Nothing coerces useAuth
* at resolution, and nothing should here either: the hitpass, Cache-Control
* and stats checks compare `=== true`, and coercing would change what they
* bypass and emit for those configurations.
*/
export const isUnkeyedAuthenticatedRoute = (route: CacheRouteConfig): boolean =>
route.method === 'GET' && route.hitpass === false && !route.keys?.useAuth;

/** The paths of the routes `isUnkeyedAuthenticatedRoute` holds for. */
export const unkeyedAuthenticatedRoutes = (
routes: readonly CacheRouteConfig[]
): string[] => routes.filter(isUnkeyedAuthenticatedRoute).map((route) => route.path);
Loading
Loading