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
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@
"quality:duplication": "node scripts/check-code-health.mjs duplication",
"quality:cycles": "node scripts/check-code-health.mjs cycles",
"quality:dependencies": "node scripts/check-code-health.mjs dependencies",
"test:dependency-security": "node --test scripts/http-cache-security.test.mjs",
"quality:suppressions": "node scripts/check-code-health.mjs suppressions",
"quality:hygiene": "node scripts/check-code-health.mjs hygiene",
"quality": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm --filter web-annotator-extension type-check && pnpm --filter web-annotator-extension test && pnpm quality:unused && pnpm quality:complexity && pnpm quality:duplication && pnpm quality:cycles && pnpm quality:dependencies && pnpm quality:suppressions && pnpm docs:check && pnpm cf:build && pnpm --filter web-annotator-extension build && pnpm quality:hygiene",
"quality": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test:coverage && pnpm --filter web-annotator-extension type-check && pnpm --filter web-annotator-extension test && pnpm quality:unused && pnpm quality:complexity && pnpm quality:duplication && pnpm quality:cycles && pnpm test:dependency-security && pnpm quality:dependencies && pnpm quality:suppressions && pnpm docs:check && pnpm cf:build && pnpm --filter web-annotator-extension build && pnpm quality:hygiene",
"test:guest-pdf": "node --test scripts/qualify-guest-pdf.mjs scripts/qualify-import-signin.mjs",
"test:account-pdf": "node --test scripts/qualify-account-pdf.mjs",
"test:account-notes": "vitest run --config vitest.account-notes.config.ts"
Expand Down Expand Up @@ -137,7 +138,11 @@
"brace-expansion@>=2.0.0 <2.1.7": "2.1.7",
"brace-expansion@>=5.0.0 <5.0.12": "5.0.12",
"undici@>=7.0.0 <7.29.1": "7.29.1",
"devalue@<=5.9.2": "5.9.3"
"devalue@<=5.9.2": "5.9.3",
"http-cache-semantics": "4.3.0"
},
"patchedDependencies": {
"http-cache-semantics@4.3.0": "patches/http-cache-semantics@4.3.0.patch"
}
}
}
20 changes: 20 additions & 0 deletions patches/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# HTTP cache semantics security patch

GitHub's reviewed advisory for GHSA-ch52-4w7c-c8xp currently has no upstream
patched version. The published `http-cache-semantics@4.3.0` source also needs a
behavioral fix: client `max-stale` must not bypass non-storable or revalidation
rules, or shared-cache restrictions for `proxy-revalidate` and `Set-Cookie`.

This workspace pins the existing Astro build dependency to 4.3.0 and applies a
small local patch to those checks. The Reader landing's only affected path is
`landing-astro > astro > http-cache-semantics`; this does not add a runtime
dependency or a vulnerability allowlist.

The lockfile integrity, patch hash, and patched snapshot were synchronized
from the reviewed PostTrainLLM #191 resolution and checked by a subsequent
Fleet Workspace frozen install; no unrelated lock nodes were changed.

`pnpm test:dependency-security` exercises 27 cache behavior cases against the
package resolved through the `landing-astro` Astro install, including policy
serialization and ordinary `max-stale` age and URL bounds. Keep the patch until
the upstream source itself passes the same behavior tests.
15 changes: 15 additions & 0 deletions patches/http-cache-semantics@4.3.0.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
diff --git a/index.js b/index.js
--- a/index.js
+++ b/index.js
@@ -399,1 +399,10 @@
- if (this._rescc['must-revalidate']) {
+ if (
+ this._rescc['must-revalidate'] ||
+ !this.storable() ||
+ this._rescc['no-cache'] ||
+ (this._isShared && (
+ this._rescc['proxy-revalidate'] ||
+ (this._resHeaders['set-cookie'] &&
+ !this._rescc.public && !this._rescc.immutable)
+ ))
+ ) {
14 changes: 10 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion scripts/check-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@

import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = resolve(new URL('..', import.meta.url).pathname);
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
const DOCS = join(ROOT, 'docs');

const CANONICAL_TOP_LEVEL_DIRS = new Set([
Expand Down
91 changes: 91 additions & 0 deletions scripts/http-cache-security.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const consumer = process.env.READER_CACHE_CONSUMER || 'landing-astro';
const consumerRequire = createRequire(path.join(root, consumer, 'package.json'));
const astroRequire = createRequire(consumerRequire.resolve('astro/package.json'));
const CachePolicy = astroRequire('http-cache-semantics');
const request = {
url: 'https://cache-fixture.invalid/image',
method: 'GET',
headers: { host: 'cache-fixture.invalid' },
};
const cookie = { 'set-cookie': 'fictional-fixture=synthetic' };
const scenarios = [
['shared cookie without explicit opt-in', 'max-age=0', cookie, true, 1, false],
[
'shared cookie with stale-while-revalidate',
'max-age=0, stale-while-revalidate=600',
cookie,
true,
1,
false,
],
['shared proxy revalidation', 'max-age=60, proxy-revalidate', {}, true, 61, false],
['response requires revalidation', 'no-cache', {}, true, 1, false],
['response cannot be stored', 'no-store', {}, true, 1, false],
['private response in shared cache', 'private, max-age=60', {}, true, 61, false],
['ordinary expired response', 'max-age=0', {}, true, 1, true],
['ordinary fresh response', 'max-age=60', {}, true, 1, true],
['explicitly public cookie', 'public, max-age=0', cookie, true, 1, true],
['explicitly immutable cookie', 'immutable, max-age=0', cookie, true, 1, true],
['cookie in private cache', 'max-age=0', cookie, false, 1, true],
['proxy directive in private cache', 'max-age=0, proxy-revalidate', {}, false, 1, true],
['must-revalidate remains enforced', 'max-age=0, must-revalidate', {}, true, 1, false],
];

for (const [name, cacheControl, extraHeaders, shared, age, expected] of scenarios) {
for (const serialized of [false, true]) {
test(`${consumer}: ${name}${serialized ? ' after serialization' : ''}`, () => {
let policy = new CachePolicy(
request,
{
status: 200,
headers: { 'cache-control': cacheControl, ...extraHeaders },
},
{ shared }
);
if (serialized) policy = CachePolicy.fromObject(policy.toObject());
policy.now = () => policy._responseTime + age * 1000;
const next = {
...request,
headers: { ...request.headers, 'cache-control': 'max-stale=99999' },
};
assert.equal(policy.satisfiesWithoutRevalidation(next), expected);
assert.equal(Boolean(policy.evaluateRequest(next).response), expected);
});
}
}

test(`${consumer}: max-stale still respects its age bound and request identity`, () => {
const policy = new CachePolicy(request, {
headers: { 'cache-control': 'max-age=10' },
});
policy.now = () => policy._responseTime + 15_000;
assert.equal(
policy.satisfiesWithoutRevalidation({
...request,
headers: { ...request.headers, 'cache-control': 'max-stale=4' },
}),
false
);
assert.equal(
policy.satisfiesWithoutRevalidation({
...request,
headers: { ...request.headers, 'cache-control': 'max-stale=6' },
}),
true
);
assert.equal(
policy.satisfiesWithoutRevalidation({
...request,
url: 'https://cache-fixture.invalid/other',
headers: { ...request.headers, 'cache-control': 'max-stale=99999' },
}),
false
);
});
9 changes: 0 additions & 9 deletions src/components/HomeClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@ import { useAuth } from './AuthProvider';
import { LibraryEmptyOnboarding } from './LibraryEmptyOnboarding';

const Navbar = lazy(() => import('./Navbar').then((m) => ({ default: m.Navbar })));
const ReviewPackBanner = lazy(() =>
import('./ReviewPackBanner').then((m) => ({ default: m.ReviewPackBanner }))
);
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { SegmentedControl } from './ui/segmented-control';
Expand Down Expand Up @@ -872,12 +869,6 @@ export default function HomeClient() {
</div>
</header>

{articles.length > 0 && (
<Suspense fallback={null}>
<ReviewPackBanner />
</Suspense>
)}

{articles.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-800 bg-zinc-950 p-3">
<SegmentedControl
Expand Down
77 changes: 0 additions & 77 deletions src/components/ReviewPackBanner.tsx

This file was deleted.

Loading