-
-
Notifications
You must be signed in to change notification settings - Fork 134
fix(security): stop serving and restoring deleted bundle cache #3100
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
8d22873
fix(security): stop serving and restoring deleted bundle cache
riderx 4bacb1f
fix(security): satisfy deleted-cache typecheck
riderx bd8d65e
Revert "test(security): accept bundle.delete trigger on upload soft-d…
riderx 1ea43bc
fix(security): harden deleted bundle cache purge and serve paths
cursoragent 4970807
test(files): mock pg client in bandwidth unit tests for deleted guard
cursoragent 9a8a556
fix(test): align upload-only delete assertion with bundle delete guard
cursoragent 19ba5ff
test(channel_self): retry Kong 502/503 and warm plugin endpoint
cursoragent eaee5d5
fix(files): purge keyed file-read cache entries on bundle delete
cursoragent eeadc47
test(files): mock getDatabaseURL for shared read-only pool lookup
cursoragent 1802f43
test(files): assert checksum passed to purgeFileReadCache on delete
cursoragent f74b339
ci: scope push Run tests concurrency by commit SHA
cursoragent 0dea67a
ci: restore pull_request trigger for Run tests workflow
cursoragent 45b32cb
test(files): clarify keyed cache purge unit test name
cursoragent d05d493
docs(files): note checksum requirement for cache purge
cursoragent 7eff09e
test(files): mock getDatabaseURL in files edge unit tests
cursoragent 769ccaa
fix(files): use primary DB for deleted bundle lookup
cursoragent 7a9604b
fix(files): scope deleted-bundle guard to zip r2 paths
cursoragent 167b93f
test(rate-limit): stabilize device DELETE burst; document global DB gate
cursoragent cee6440
test(rate-limit): clarify burst retry comment
cursoragent 22843a8
docs(files): cross-ref files.ts cache-hit deletion guard
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| import type { Context } from 'hono' | ||
| import { getRuntimeKey } from 'hono/adapter' | ||
| import { cloudlog } from '../utils/logging.ts' | ||
| import { getDatabaseURL, getPgClient } from '../utils/pg.ts' | ||
|
|
||
| export const FILE_READ_TRACKING_QUERY_PARAMS = ['device_id'] as const | ||
| export const DELETED_FILE_CACHE_HEADER = 'x-capgo-file-deleted' | ||
| const DELETED_FILE_MARKER_ORIGIN = 'https://capgo-files-cache.internal' | ||
| const FILE_READ_CACHE_NAME = 'capgo-file-read-cache' | ||
| const FILE_READ_CACHE_ORIGINS = ['https://api.capgo.app'] as const | ||
| const FILE_READ_PATH_PREFIXES = [ | ||
| '/files/read/attachments/', | ||
| '/private/files/read/attachments/', | ||
| '/read/attachments/', | ||
| ] as const | ||
|
|
||
| export function isVersionDeleted(row: { deleted?: boolean | null, deleted_at?: string | Date | null } | null | undefined): boolean { | ||
| if (!row) | ||
| return false | ||
| return row.deleted === true || row.deleted_at != null | ||
| } | ||
|
|
||
| export function getAttachmentFileIdFromReadPath(pathname: string): string | null { | ||
| for (const prefix of FILE_READ_PATH_PREFIXES) { | ||
| if (pathname.startsWith(prefix)) { | ||
| const fileId = pathname.slice(prefix.length) | ||
| return fileId || null | ||
| } | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| export function buildFileReadCacheRequest(request: Request): Request { | ||
| const cacheUrl = new URL(request.url) | ||
| for (const queryParam of FILE_READ_TRACKING_QUERY_PARAMS) { | ||
| cacheUrl.searchParams.delete(queryParam) | ||
| } | ||
| cacheUrl.searchParams.set('range', request.headers.get('range') || '') | ||
| cacheUrl.searchParams.sort() | ||
| return new Request(cacheUrl, request) | ||
| } | ||
|
|
||
| export function buildDeletedFileMarkerRequest(fileId: string): Request { | ||
| return new Request(`${DELETED_FILE_MARKER_ORIGIN}/deleted/${encodeURIComponent(fileId)}`) | ||
| } | ||
|
|
||
| function normalizeSearch(url: URL, ignoredParams: readonly string[] = []): string { | ||
| const searchParams = new URLSearchParams(url.search) | ||
| for (const param of ignoredParams) { | ||
| searchParams.delete(param) | ||
| } | ||
| searchParams.sort() | ||
| const search = searchParams.toString() | ||
| return search ? `?${search}` : '' | ||
| } | ||
|
|
||
| export function buildWorkersFileCacheKey(pathname: string, search = ''): string { | ||
| const url = new URL(`https://capgo-files-cache.internal${pathname}${search}`) | ||
| return `/files-cache${pathname}${normalizeSearch(url, FILE_READ_TRACKING_QUERY_PARAMS)}` | ||
| } | ||
|
|
||
| type CacheLike = Cache & { | ||
| default?: Cache | ||
| open?: (cacheName: string) => Promise<Cache> | ||
| } | ||
|
|
||
| let fileReadCachePromise: Promise<Cache | null> | null = null | ||
|
|
||
| async function resolveFileReadCache(): Promise<Cache | null> { | ||
| if (typeof caches === 'undefined') | ||
| return null | ||
|
|
||
| const cacheStorage = caches as unknown as CacheLike | ||
| if (getRuntimeKey() === 'workerd' && cacheStorage.default) | ||
| return cacheStorage.default | ||
|
|
||
| if (typeof cacheStorage.open === 'function') { | ||
| try { | ||
| return await cacheStorage.open(FILE_READ_CACHE_NAME) | ||
| } | ||
| catch { | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| return null | ||
| } | ||
|
|
||
| export async function getFileReadCache(): Promise<Cache | null> { | ||
| fileReadCachePromise ??= resolveFileReadCache() | ||
| return fileReadCachePromise | ||
| } | ||
|
|
||
| export async function hasDeletedFileMarker(fileId: string): Promise<boolean> { | ||
| const cache = await getFileReadCache() | ||
| if (!cache?.match) | ||
| return false | ||
|
|
||
| try { | ||
| const cached = await cache.match(buildDeletedFileMarkerRequest(fileId)) | ||
| return cached != null | ||
| } | ||
| catch { | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| export async function markFileDeletedInCache(fileId: string): Promise<void> { | ||
| const cache = await getFileReadCache() | ||
| if (!cache?.put) | ||
| return | ||
|
|
||
| await cache.put(buildDeletedFileMarkerRequest(fileId), new Response('deleted', { | ||
| status: 404, | ||
| headers: { | ||
| 'Cache-Control': 'public, max-age=31536000', | ||
| [DELETED_FILE_CACHE_HEADER]: '1', | ||
| }, | ||
| })) | ||
| } | ||
|
|
||
| let sharedDeletedLookupPool: ReturnType<typeof getPgClient> | null = null | ||
| let sharedDeletedLookupPoolUrl: string | null = null | ||
|
|
||
| function getDeletedLookupPgClient(c: Context): ReturnType<typeof getPgClient> { | ||
| const dbUrl = getDatabaseURL(c, false) | ||
| if (!sharedDeletedLookupPool || sharedDeletedLookupPoolUrl !== dbUrl) { | ||
| sharedDeletedLookupPool = getPgClient(c, false) | ||
| sharedDeletedLookupPoolUrl = dbUrl | ||
| } | ||
| return sharedDeletedLookupPool | ||
| } | ||
|
|
||
| function buildFileReadCacheRequestsForPath(fileId: string, checksum?: string | null): Request[] { | ||
| const identityParamSets: Array<Record<string, string>> = [{}] | ||
| if (checksum) | ||
| identityParamSets.push({ key: checksum }) | ||
|
|
||
| return FILE_READ_CACHE_ORIGINS.flatMap(origin => | ||
| FILE_READ_PATH_PREFIXES.flatMap((prefix) => { | ||
| return identityParamSets.map((identityParams) => { | ||
| const url = new URL(`${prefix}${fileId}`, origin) | ||
| for (const [key, value] of Object.entries(identityParams)) | ||
| url.searchParams.set(key, value) | ||
| url.searchParams.set('range', '') | ||
| url.searchParams.sort() | ||
| return new Request(url) | ||
| }) | ||
| }), | ||
| ) | ||
| } | ||
|
|
||
| function buildWorkersFileCacheRequests(fileId: string, checksum?: string | null): Request[] { | ||
| const searchVariants = [''] | ||
| if (checksum) | ||
| searchVariants.push(`?key=${encodeURIComponent(checksum)}`) | ||
|
|
||
| return FILE_READ_PATH_PREFIXES | ||
| .filter(prefix => prefix.startsWith('/files/') || prefix.startsWith('/private/')) | ||
| .flatMap(prefix => | ||
| searchVariants.map(search => | ||
| new Request(`${DELETED_FILE_MARKER_ORIGIN}${buildWorkersFileCacheKey(`${prefix}${fileId}`, search)}`), | ||
|
Check warning on line 162 in supabase/functions/_backend/files/file_read_cache.ts
|
||
| ), | ||
| ) | ||
| } | ||
|
|
||
| // Per-DC cache delete/marker calls are best-effort accelerators. The globally durable | ||
| // deletion gate is isAttachmentVersionDeleted's primary-DB lookup on app_versions.r2_path | ||
| // (deleted/deleted_at), which the serve path checks before returning cached bytes or | ||
| // restoring to R2 (see files.ts cache-hit guard). Workers CachedFiles also short-circuits on the local deleted marker. | ||
| // Checksum is required so key=checksum cache variants are purged alongside path-only entries. | ||
| export async function purgeFileReadCache(fileId: string, checksum?: string | null): Promise<void> { | ||
| await markFileDeletedInCache(fileId) | ||
|
|
||
| const cache = await getFileReadCache() | ||
| if (!cache || typeof cache.delete !== 'function') | ||
| return | ||
|
|
||
| const requests = [ | ||
| ...buildFileReadCacheRequestsForPath(fileId, checksum), | ||
| ...buildWorkersFileCacheRequests(fileId, checksum), | ||
| ] | ||
| await Promise.all(requests.map(request => cache.delete(request).catch(() => false))) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| export async function isAttachmentVersionDeleted(c: Context, fileId: string): Promise<boolean> { | ||
| if (await hasDeletedFileMarker(fileId)) | ||
| return true | ||
|
|
||
| // app_versions.r2_path only tracks bundle zip objects, not arbitrary attachment uploads. | ||
| if (!fileId.endsWith('.zip')) | ||
| return false | ||
|
|
||
| try { | ||
| const pgClient = getDeletedLookupPgClient(c) | ||
| const result = await pgClient.query<{ deleted: boolean | null, deleted_at: string | null }>( | ||
| ` | ||
| SELECT deleted, deleted_at | ||
| FROM public.app_versions | ||
| WHERE r2_path = $1 | ||
| AND (COALESCE(deleted, false) = true OR deleted_at IS NOT NULL) | ||
| LIMIT 1 | ||
| `, | ||
| [fileId], | ||
| ) | ||
| return result.rows.length > 0 | ||
| } | ||
| catch (error) { | ||
| cloudlog({ | ||
| requestId: c.get('requestId'), | ||
| message: 'isAttachmentVersionDeleted lookup failed, failing closed', | ||
| fileId, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }) | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| export const fileReadCacheTestUtils = { | ||
| buildFileReadCacheRequest, | ||
| buildDeletedFileMarkerRequest, | ||
| buildWorkersFileCacheKey, | ||
| getAttachmentFileIdFromReadPath, | ||
| isVersionDeleted, | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.