Skip to content

Commit 3c99e8a

Browse files
authored
Merge pull request #89 from hypercerts-org/fix/preview-cache-and-debug-endpoints
2 parents c8daf95 + d44493b commit 3c99e8a

8 files changed

Lines changed: 116 additions & 100 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'ePDS': patch
3+
---
4+
5+
Fix two preview-route cache bugs and remove long-stale debug endpoints.
6+
7+
**Affects:** Client app developers, Operators
8+
9+
**Client app developers:**
10+
11+
- Preview-route fetch failures no longer poison the shared client-metadata cache. Previously, a failed preview fetch for a `client_id` with a valid 10-minute entry would overwrite that entry with a 60-second branding-less fallback, silently dropping `branding.css` on real OAuth flows for up to a minute. The in-memory cache is now only written by real-flow resolution.
12+
- The auth-service HTML preview pages (`/preview/login`, `/preview/login-otp`, `/preview/choose-handle`, `/preview/choose-handle-picker`, `/preview/recovery`, `/preview/recovery-otp`, and the `/preview` index) now send `Cache-Control: no-store`. Without it, a browser refresh could serve a cached page and never ask the server for fresh `branding.css`, breaking the advertised "edit `branding.css`, refresh the preview page" workflow.
13+
- `/preview/validate` now flags `branding.css` whose escaped size exceeds the 32 KB injection limit as an error, instead of reporting `ok` and letting the developer discover later that their CSS was silently dropped on real OAuth flows. Byte counts now match `getClientCss()`'s measurement (escaped UTF-8).
14+
15+
**Operators:**
16+
17+
- Removed `/_internal/debug-grants` and `/_internal/debug-recent-accounts`. These were added as temporary HYPER-270 debugging endpoints with a code comment marking them for removal before PR #21 shipped (v0.2.2); they survived through v0.2.2, v0.3.0, v0.4.0, and the pending v0.5.0. The matching env var `EPDS_DEBUG_GRANTS` is no longer read.

packages/auth-service/src/routes/preview.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ function queryString(req: Request, name: string): string | undefined {
9595

9696
function sendHtml(res: Response, html: string): void {
9797
res.setHeader('Content-Type', 'text/html; charset=utf-8')
98+
// Preview flows server-side bypass the client-metadata cache, but that
99+
// only helps if the browser actually asks for the page. Without
100+
// no-store, heuristic caching (RFC 9111 §4.2.2) lets the browser serve
101+
// the previous HTML on refresh, so fresh branding.css never reaches
102+
// the client. The JSON endpoints already set this header; HTML was
103+
// the missing piece.
104+
res.setHeader('Cache-Control', 'no-store')
98105
res.send(html)
99106
}
100107

packages/pds-core/src/index.ts

Lines changed: 0 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -759,93 +759,6 @@ async function main() {
759759
}
760760
})
761761

762-
// TEMPORARY debug endpoint for HYPER-270 investigation.
763-
//
764-
// Dumps all authorized_client and account_device rows for a given DID so
765-
// we can observe which grants were (or weren't) persisted when a user
766-
// clicks Authorize on the upstream consent UI for an untrusted client.
767-
//
768-
// Gated on EPDS_DEBUG_GRANTS=1 in addition to the usual
769-
// EPDS_INTERNAL_SECRET check so it only comes online in environments
770-
// where we've explicitly enabled it. Remove this endpoint and its env
771-
// var before merging PR #21.
772-
if (process.env.EPDS_DEBUG_GRANTS === '1') {
773-
pds.app.get('/_internal/debug-grants', async (req, res) => {
774-
if (!verifyInternalSecret(req.headers['x-internal-secret'])) {
775-
res.status(401).json({ error: 'Unauthorized' })
776-
return
777-
}
778-
const sub = ((req.query.sub as string) || '').trim()
779-
if (!sub) {
780-
res.status(400).json({ error: 'Missing sub' })
781-
return
782-
}
783-
try {
784-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @atproto/pds accountManager internals not exported
785-
const am = pds.ctx.accountManager as any
786-
const db = am.db
787-
788-
const authorizedClients = await db.db
789-
.selectFrom('authorized_client')
790-
.select(['did', 'clientId', 'createdAt', 'updatedAt', 'data'])
791-
.where('did', '=', sub)
792-
.execute()
793-
794-
const accountDevices = await db.db
795-
.selectFrom('account_device')
796-
.select(['did', 'deviceId', 'createdAt', 'updatedAt'])
797-
.where('did', '=', sub)
798-
.execute()
799-
800-
res.json({ sub, authorizedClients, accountDevices })
801-
} catch (err) {
802-
logger.error({ err, sub }, 'debug-grants query failed')
803-
res.status(500).json({ error: 'query_failed', message: String(err) })
804-
}
805-
})
806-
807-
// Also expose a "recent accounts" lookup so we can find the sub of the
808-
// e2e test user without knowing its DID up front.
809-
pds.app.get('/_internal/debug-recent-accounts', async (req, res) => {
810-
if (!verifyInternalSecret(req.headers['x-internal-secret'])) {
811-
res.status(401).json({ error: 'Unauthorized' })
812-
return
813-
}
814-
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 5))
815-
try {
816-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
817-
const am = pds.ctx.accountManager as any
818-
const db = am.db
819-
820-
// Join actor with account to pull the email too, so we can
821-
// cross-reference e2e scenario test emails (e.g. the
822-
// "approved-untrusted-<ts>@example.com" pattern) against the
823-
// DID in the debug-grants lookup.
824-
const rows = await db.db
825-
.selectFrom('actor')
826-
.leftJoin('account', 'account.did', 'actor.did')
827-
.select([
828-
'actor.did as did',
829-
'actor.handle as handle',
830-
'actor.createdAt as createdAt',
831-
'account.email as email',
832-
])
833-
.orderBy('actor.createdAt', 'desc')
834-
.limit(limit)
835-
.execute()
836-
837-
res.json({ rows })
838-
} catch (err) {
839-
logger.error({ err }, 'debug-recent-accounts query failed')
840-
res.status(500).json({ error: 'query_failed', message: String(err) })
841-
}
842-
})
843-
844-
logger.warn(
845-
'EPDS_DEBUG_GRANTS=1: /_internal/debug-grants and /_internal/debug-recent-accounts endpoints are enabled (HYPER-270 debugging)',
846-
)
847-
}
848-
849762
// Protected internal endpoint for auth service to reset the inactivity timer
850763
// on a pending PAR request_uri. Called when the user loads the handle selection
851764
// page so the request doesn't expire while they are choosing a handle.

packages/shared/src/__tests__/client-metadata.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,30 @@ describe('resolveClientMetadata', () => {
8383
expect(first).toEqual(second)
8484
expect(first.client_name).toBe('Cached App')
8585
})
86+
87+
it('noCache:true + fetch failure does not poison a valid cache entry', async () => {
88+
// Regression test for a bug where preview flows that failed to
89+
// fetch client metadata would overwrite a real flow's 10-minute
90+
// cache entry with a 60-second branding-less fallback, silently
91+
// dropping CSS on real OAuth flows for up to a minute.
92+
const url = 'https://127.0.0.1/client-metadata.json' // NOSONAR — testing SSRF guard
93+
_seedClientMetadataCacheForTest(url, {
94+
client_name: 'Seeded App',
95+
branding: { css: 'body { background: red; }' },
96+
})
97+
// safeFetch rejects 127.0.0.1 as a non-unicast host, so this fetch
98+
// fails deterministically. The preview-style call returns the
99+
// fallback metadata to its caller (so the preview page still
100+
// renders), but must NOT clobber the seeded cache entry.
101+
const previewResult = await resolveClientMetadata(url, { noCache: true })
102+
expect(previewResult.client_name).toBe('127.0.0.1')
103+
expect(previewResult.branding).toBeUndefined()
104+
// A subsequent real-flow call (noCache defaulted to false) must
105+
// still see the original seeded entry — not a poisoned fallback.
106+
const realFlowResult = await resolveClientMetadata(url)
107+
expect(realFlowResult.client_name).toBe('Seeded App')
108+
expect(realFlowResult.branding?.css).toBe('body { background: red; }')
109+
})
86110
})
87111

88112
describe('resolveClientName', () => {

packages/shared/src/__tests__/preview-validation.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,32 @@ describe('validateClientMetadataForPreview', () => {
8383
expect(byId['trusted-client'].severity).toBe('ok')
8484
})
8585

86+
it('flags branding.css as error when escaped size exceeds the 32 KB limit', async () => {
87+
// getClientCss measures *escaped* bytes against MAX_CSS_BYTES and
88+
// silently returns null above it. The validator must mirror that
89+
// check, not a raw-byte check — otherwise a developer whose CSS is
90+
// just under the raw limit but over it after escapeCss() still sees
91+
// "ok" here and gets their CSS silently dropped on real flows.
92+
//
93+
// Fixture construction: exactly MAX_CSS_BYTES (32 768) raw bytes,
94+
// composed entirely of `</style>` occurrences that escapeCss()
95+
// rewrites to `\u003c/style>` (+5 bytes each). Raw = 4096 × 8 =
96+
// 32 768 (at the limit, passes a naive raw check); escaped =
97+
// 4096 × 13 = 53 248 (over).
98+
const url = 'https://heavy.example/client-metadata.json'
99+
const bigCss = '</style>'.repeat(4096)
100+
expect(bigCss.length).toBe(32_768)
101+
mockFetchOnce({
102+
client_id: url,
103+
redirect_uris: ['https://heavy.example/cb'],
104+
branding: { css: bigCss },
105+
})
106+
const result = await validateClientMetadataForPreview(url, [url])
107+
const byId = Object.fromEntries(result.checks.map((c) => [c.id, c]))
108+
expect(byId['branding-css'].severity).toBe('error')
109+
expect(byId['branding-css'].detail).toContain('exceeds')
110+
})
111+
86112
it('warns (not errors) when optional branding fields are missing', async () => {
87113
const url = 'https://plain.example/client-metadata.json'
88114
mockFetchOnce({

packages/shared/src/client-metadata.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export async function resolveClientMetadata(
146146
{ clientId, status: res.status },
147147
'Client metadata fetch returned non-OK status; using fallback',
148148
)
149-
return fallback(clientId)
149+
return fallback(clientId, options.noCache === true)
150150
}
151151

152152
const metadata = (await res.json()) as ClientMetadata
@@ -167,18 +167,26 @@ export async function resolveClientMetadata(
167167
{ err, clientId },
168168
'Client metadata fetch failed; using fallback',
169169
)
170-
return fallback(clientId)
170+
return fallback(clientId, options.noCache === true)
171171
}
172172
}
173173

174-
function fallback(clientId: string): ClientMetadata {
174+
function fallback(clientId: string, skipCache: boolean): ClientMetadata {
175175
const name = extractDomain(clientId)
176176
const metadata = { client_name: name || undefined }
177-
// Cache failures briefly (1 minute) to avoid hammering
178-
cache.set(clientId, {
179-
metadata,
180-
expiresAt: Date.now() + 60_000,
181-
})
177+
// Cache failures briefly (1 minute) to avoid hammering — but only
178+
// when called from a real flow. `noCache:true` callers (preview
179+
// flows) skip the cache read and skip writing this degraded
180+
// fallback entry; a successful fetch further up still writes to the
181+
// cache, per the `noCache` JSDoc. Without this guard, a failing
182+
// preview fetch would overwrite a valid 10-minute entry with a
183+
// branding-less 60-second one, silently dropping CSS on real flows.
184+
if (!skipCache) {
185+
cache.set(clientId, {
186+
metadata,
187+
expiresAt: Date.now() + 60_000,
188+
})
189+
}
182190
return metadata
183191
}
184192

@@ -202,7 +210,7 @@ export function escapeCss(css: string): string {
202210
}
203211

204212
/** Maximum allowed size for injected CSS. Values above this are dropped. */
205-
const MAX_CSS_BYTES = 32_768 // 32 KB
213+
export const MAX_CSS_BYTES = 32_768 // 32 KB
206214

207215
/**
208216
* Returns escaped CSS for injection if the client is trusted, or null.

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export {
4040
resolveClientMetadata,
4141
resolveClientName,
4242
escapeCss,
43+
MAX_CSS_BYTES,
4344
getClientCss,
4445
clearClientMetadataCache,
4546
getClientMetadataCacheStatus,

packages/shared/src/preview-validation.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
* fetch, then a flat list of content checks.
1212
*/
1313

14-
import type { ClientMetadata } from './client-metadata.js'
14+
import {
15+
escapeCss,
16+
MAX_CSS_BYTES,
17+
type ClientMetadata,
18+
} from './client-metadata.js'
1519
import { escapeHtml } from './html.js'
1620
import { makeSafeFetch } from './safe-fetch.js'
1721

@@ -332,14 +336,30 @@ function checkBrandingCss(metadata: ClientMetadata): PreviewCheck {
332336
const labelHtml = `${code('branding.css')} present`
333337

334338
if (typeof cssString === 'string' && cssString.trim().length > 0) {
335-
const bytes = new TextEncoder().encode(cssString).byteLength
339+
// Mirror getClientCss's size check: it measures the escaped form
340+
// (each `</style>` → `\u003c/style>`, +5 bytes) against
341+
// MAX_CSS_BYTES and silently returns null when it's over.
342+
// Reporting the raw byte count here would tell devs their CSS is
343+
// fine up to 32 KB raw when in fact it gets dropped on real flows.
344+
const escaped = escapeCss(cssString)
345+
const bytes = Buffer.byteLength(escaped, 'utf8')
346+
if (bytes > MAX_CSS_BYTES) {
347+
return {
348+
id: 'branding-css',
349+
label,
350+
severity: 'error',
351+
detail: `${bytes.toLocaleString()} bytes (escaped) exceeds the ${MAX_CSS_BYTES.toLocaleString()}-byte limit. getClientCss() will silently drop it on real OAuth flows.`,
352+
labelHtml,
353+
detailHtml: `${bytes.toLocaleString()} bytes (escaped) exceeds the ${MAX_CSS_BYTES.toLocaleString()}-byte limit. ${code('getClientCss()')} will silently drop it on real OAuth flows.`,
354+
}
355+
}
336356
return {
337357
id: 'branding-css',
338358
label,
339359
severity: 'ok',
340-
detail: `${bytes.toLocaleString()} bytes. Injected into /preview/consent (pds-core) and the auth-service pages when the client is trusted.`,
360+
detail: `${bytes.toLocaleString()} bytes (escaped). Injected into /preview/consent (pds-core) and the auth-service pages when the client is trusted.`,
341361
labelHtml,
342-
detailHtml: `${bytes.toLocaleString()} bytes. Injected into ${code('/preview/consent')} (pds-core) and the auth-service pages when the client is trusted.`,
362+
detailHtml: `${bytes.toLocaleString()} bytes (escaped). Injected into ${code('/preview/consent')} (pds-core) and the auth-service pages when the client is trusted.`,
343363
}
344364
}
345365
if (cssString !== undefined) {

0 commit comments

Comments
 (0)