Skip to content

Commit 9e7b803

Browse files
committed
fix(core): refuse a Blob token that names no store, at boot
BLOB_READ_WRITE_TOKEN was typed as any non-empty string, so a truncated, stale or wrong-kind value passed validation, satisfied the FILESTORE_DRIVER derivation, and then threw from storeIdFrom on the first request that touched an upload — a 500 on a live board, long after the deploy went green. That is the boot-then-fail-on-first-upload shape this branch exists to remove, reintroduced through the token path, and on a linked project it turns a board that BLOB_STORE_ID alone would have served into a broken one. The shape check the driver already applied now lives in the schema, so it fires at boot next to every other configuration error and `community env:check` reports it. Nothing new is being judged: storeIdFrom rejected exactly these tokens already, and both callers now share one parser so the two cannot drift. Treating an unparseable token as absent whenever a store id is present was the alternative. It loses on two counts: it cannot help a board with only a bad token, where nothing else would ever catch it, and where it does apply it silently ignores a credential the operator typed — while a stale token for the right store and a garbage one are indistinguishable to us, so it would quietly write to a store the operator may not have meant. Four smaller things from the same review. A store id is trimmed before the SDK or url() sees it, so a pasted value with whitespace cannot produce a malformed host or fall to the token path on a mismatched comparison. The intercepted no-credentials message is raised only on the store-id path, since the token path never asked OIDC for anything. del hands the SDK a copy of the auth options rather than the driver's own object. And the sentence the driver matches on is now one exported constant, with a test asserting the installed @vercel/blob still contains it, so a reword upstream fails rather than silently costing a good error message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015WBLHKvRatuUufybYpgER1
1 parent 9c76be4 commit 9e7b803

6 files changed

Lines changed: 179 additions & 14 deletions

File tree

docs/vercel.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,8 @@ Both work, and the board is the same either way. The difference is what
251251
happens on the day you leave.
252252

253253
A **Vercel Blob store** is the cheapest thing to set up: attach one to the
254-
project, and `BLOB_READ_WRITE_TOKEN` appears by itself. That replaces four
254+
project, and `BLOB_STORE_ID` appears by itself — see [how the Blob store
255+
authenticates](#how-the-blob-store-authenticates). That replaces four
255256
values that each had exactly one correct setting and each of which was a
256257
typo away from a board that booted and then failed at the first upload. It
257258
is what the one-click template in `templates/vercel` defaults to, and what
@@ -307,6 +308,15 @@ Two consequences worth knowing:
307308
own machine — `community backup` against a Blob store is the case that
308309
matters — has no OIDC token, so it needs `BLOB_READ_WRITE_TOKEN`. Create
309310
one on the store under **Storage**. The deployment never needs it.
311+
- **A token that names no store is refused at boot**, whether or not
312+
`BLOB_STORE_ID` is set beside it. A read-write token reads
313+
`vercel_blob_rw_<store>_<secret>` and the board takes the store out of the
314+
middle of it, so a truncated, stale or wrong-kind value has no store to
315+
name. The board will not quietly fall back to the store id and write
316+
somewhere the operator did not ask for, and it will not carry the bad value
317+
as far as the first upload: the environment refuses it by name, next to
318+
every other configuration error, and says the store id alone would have
319+
done. Remove the variable or fix it.
310320

311321
Two things here are **reasoned from the SDK's source and not confirmed
312322
against a live deploy**, because nobody has run this yet:
@@ -687,10 +697,17 @@ store over the network:
687697
DATABASE_URL=… # the pooled string
688698
DIRECT_DATABASE_URL=… # the direct string, for the dump
689699
FILESTORE_DRIVER=blob
690-
BLOB_READ_WRITE_TOKEN=… # copy it out of the project's environment settings
700+
BLOB_READ_WRITE_TOKEN=… # create one on the store — see below
691701
community backup --uploads include
692702
```
693703

704+
That token is the one value here you make by hand. On the deployment the
705+
board reaches the store with `BLOB_STORE_ID` and the deployment's OIDC
706+
identity, and a command on your own machine has no such identity — so open
707+
the store under **Storage**, create a read-write token, and use it for the
708+
backup. [How the Blob store authenticates](#how-the-blob-store-authenticates)
709+
has the whole of it.
710+
694711
Copy the bundle somewhere that is none of the four vendors.
695712

696713
### 2. Stand up the destination

packages/core/src/env.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -580,9 +580,49 @@ describe('what the platform publishes, and what the board makes of it', () => {
580580

581581
it('asks for no OIDC token of its own, which a build would not have', () => {
582582
const env = parseEnv({ ...bare, ...INJECTED })
583-
584583
expect(env.FILESTORE_DRIVER).toBe('blob')
585-
expect(Object.keys(env)).not.toContain('VERCEL_OIDC_TOKEN')
584+
585+
let refusal = ''
586+
try {
587+
parseEnv(noStore)
588+
} catch (error) {
589+
refusal = (error as Error).message
590+
}
591+
592+
expect(refusal).toMatch(/BLOB_STORE_ID/)
593+
expect(refusal).not.toMatch(/OIDC_TOKEN/)
594+
})
595+
596+
it('refuses a token that names no store, at boot rather than at the first upload', () => {
597+
expect(() =>
598+
parseEnv({ ...bare, ...INJECTED, BLOB_READ_WRITE_TOKEN: 'vercel_blob_rw_' }),
599+
).toThrow(/BLOB_READ_WRITE_TOKEN.*vercel_blob_rw_<store>_<secret>/s)
600+
})
601+
602+
it('refuses it with no store id either, where nothing else would ever catch it', () => {
603+
expect(() =>
604+
parseEnv({ ...bare, ...INJECTED, BLOB_STORE_ID: undefined, BLOB_READ_WRITE_TOKEN: 'nope' }),
605+
).toThrow(/BLOB_READ_WRITE_TOKEN/)
606+
})
607+
608+
it('says the store id is credential enough, for an operator who can just drop it', () => {
609+
expect(() => parseEnv({ ...bare, ...INJECTED, BLOB_READ_WRITE_TOKEN: 'nope' })).toThrow(
610+
/BLOB_STORE_ID, which is credential enough by itself/,
611+
)
612+
})
613+
614+
it('leaves a good token alone, on or off the platform', () => {
615+
const token = 'vercel_blob_rw_store123_secret'
616+
617+
expect(
618+
parseEnv({ ...bare, ...INJECTED, BLOB_READ_WRITE_TOKEN: token }).BLOB_READ_WRITE_TOKEN,
619+
).toBe(token)
620+
expect(
621+
parseEnv({
622+
NODE_ENV: 'development',
623+
BLOB_READ_WRITE_TOKEN: token,
624+
}).BLOB_READ_WRITE_TOKEN,
625+
).toBe(token)
586626
})
587627

588628
it('names both database candidates when neither is published', () => {

packages/core/src/env.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ const databaseUrl = z.string().refine(isPostgresUrl, {
1818

1919
const secret = z.string().min(32, 'must be at least 32 characters of high-entropy random data')
2020

21+
export function blobStoreIdFromToken(token: string): string | undefined {
22+
const [vendor, product, scope, storeId] = token.split('_')
23+
const shaped = vendor === 'vercel' && product === 'blob' && scope === 'rw'
24+
return shaped && storeId !== undefined && storeId !== '' ? storeId : undefined
25+
}
26+
27+
const blobReadWriteToken = z.string().refine((value) => blobStoreIdFromToken(value) !== undefined, {
28+
message:
29+
'is not a Vercel Blob read-write token. One reads vercel_blob_rw_<store>_<secret>, ' +
30+
'and the board takes the store it writes to out of the middle of it. Copy the value ' +
31+
'the Blob store published, or remove this variable: on Vercel a linked store also ' +
32+
'publishes BLOB_STORE_ID, which is credential enough by itself.',
33+
})
34+
2135
const redisUrl = z.string().refine(isRedisUrl, {
2236
message: 'must be a redis:// or rediss:// connection string',
2337
})
@@ -75,7 +89,7 @@ const envSchema = z
7589
S3_ENDPOINT: z.string().url().optional(),
7690
S3_PUBLIC_BASE_URL: z.string().url().optional(),
7791

78-
BLOB_READ_WRITE_TOKEN: nonEmpty.optional(),
92+
BLOB_READ_WRITE_TOKEN: blobReadWriteToken.optional(),
7993
BLOB_STORE_ID: nonEmpty.optional(),
8094

8195
MAIL_FROM: z.string().email().optional(),

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export { timingSafeEqualString } from './crypto'
2525
export {
2626
assertEnv,
2727
assertRuntimeEnv,
28+
blobStoreIdFromToken,
2829
type Env,
2930
env,
3031
isDemoMode,

packages/drivers/src/files/blob-file-store.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
blobStoreIdFromToken,
23
ConfigurationError,
34
type FileStore,
45
type PutFileOptions,
@@ -54,18 +55,21 @@ function isNotFound(error: unknown): boolean {
5455
return named === 'BlobNotFoundError' || message.includes('The requested blob does not exist')
5556
}
5657

58+
export const BLOB_NO_CREDENTIALS = 'No blob credentials found'
59+
5760
function normalizeStoreId(storeId: string): string {
58-
return storeId.startsWith('store_') ? storeId.slice('store_'.length) : storeId
61+
const trimmed = storeId.trim()
62+
return trimmed.startsWith('store_') ? trimmed.slice('store_'.length) : trimmed
5963
}
6064

6165
function needsCredentials(error: unknown): boolean {
6266
const message = (error as { message?: string } | null)?.message ?? ''
63-
return message.includes('No blob credentials found')
67+
return message.includes(BLOB_NO_CREDENTIALS)
6468
}
6569

6670
function storeIdFrom(token: string): string {
67-
const [vendor, product, scope, storeId] = token.split('_')
68-
if (vendor !== 'vercel' || product !== 'blob' || scope !== 'rw' || !storeId) {
71+
const storeId = blobStoreIdFromToken(token)
72+
if (storeId === undefined) {
6973
throw new ConfigurationError(
7074
'BLOB_READ_WRITE_TOKEN is not a Vercel Blob read-write token. Copy the ' +
7175
'value the Blob store published, which starts with vercel_blob_rw_.',
@@ -84,7 +88,8 @@ export class BlobFileStore implements FileStore {
8488
private loading: Promise<BlobLike> | undefined
8589

8690
constructor(config: BlobFileStoreConfig, blob?: BlobLike) {
87-
this.auth = config.token === undefined ? { storeId: config.storeId } : { token: config.token }
91+
this.auth =
92+
config.token === undefined ? { storeId: config.storeId.trim() } : { token: config.token }
8893
this.storeId =
8994
config.token === undefined ? normalizeStoreId(config.storeId) : storeIdFrom(config.token)
9095
this.load =
@@ -125,7 +130,7 @@ export class BlobFileStore implements FileStore {
125130
try {
126131
return await operation()
127132
} catch (error) {
128-
if (!needsCredentials(error)) throw error
133+
if (!needsCredentials(error) || !('storeId' in this.auth)) throw error
129134
throw new ConfigurationError(
130135
`The Blob store ${this.storeId} was reached with no usable credential. ` +
131136
'BLOB_STORE_ID is set, so the board asked the Vercel SDK to authenticate ' +
@@ -191,7 +196,7 @@ export class BlobFileStore implements FileStore {
191196

192197
const blob = await this.blob()
193198
try {
194-
await this.attempt(() => blob.del(key, this.auth))
199+
await this.attempt(() => blob.del(key, { ...this.auth }))
195200
} catch (error) {
196201
if (isNotFound(error)) return
197202
throw error

packages/testkit/src/blob-file-store.test.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
import { readdirSync, readFileSync } from 'node:fs'
2+
import { createRequire } from 'node:module'
3+
import { dirname, join } from 'node:path'
4+
15
import { describe, expect, it } from 'vitest'
26

3-
import { BlobFileStore, type BlobLike } from '@meith/drivers/files/blob-file-store'
7+
import {
8+
BLOB_NO_CREDENTIALS,
9+
BlobFileStore,
10+
type BlobLike,
11+
} from '@meith/drivers/files/blob-file-store'
412

513
import { fileStoreContract } from './driver-contracts'
614

@@ -11,7 +19,7 @@ const STORE_ID = 'store_store123'
1119
class FakeNoCredentialsError extends Error {
1220
constructor() {
1321
super(
14-
'Vercel Blob: No blob credentials found. Pass a `token` option, set ' +
22+
`Vercel Blob: ${BLOB_NO_CREDENTIALS}. Pass a \`token\` option, set ` +
1523
'BLOB_READ_WRITE_TOKEN, or use `oidcToken` (or `VERCEL_OIDC_TOKEN`) with ' +
1624
'`storeId` or `BLOB_STORE_ID`.',
1725
)
@@ -318,3 +326,83 @@ describe('fromEnv, against what the integration actually publishes', () => {
318326
)
319327
})
320328
})
329+
330+
describe('the sentence the SDK throws, which the driver matches on', () => {
331+
it('is still in the installed @vercel/blob, so a reword cannot pass unnoticed', () => {
332+
const from = createRequire(
333+
new URL('../../drivers/src/files/blob-file-store.ts', import.meta.url),
334+
)
335+
const dist = dirname(from.resolve('@vercel/blob'))
336+
const sources = readdirSync(dist)
337+
.filter((entry) => entry.endsWith('.js'))
338+
.map((entry) => readFileSync(join(dist, entry), 'utf8'))
339+
340+
expect(sources.some((source) => source.includes(BLOB_NO_CREDENTIALS))).toBe(true)
341+
})
342+
343+
it('is matched loosely enough to survive the prefix the SDK puts in front of it', () => {
344+
expect(new FakeNoCredentialsError().message).toContain(BLOB_NO_CREDENTIALS)
345+
})
346+
})
347+
348+
describe('a token that names no store', () => {
349+
const store = () => BlobFileStore.fromEnv({ BLOB_READ_WRITE_TOKEN: 'vercel_blob_rw_' })
350+
351+
it('is refused rather than taken for one', () => {
352+
expect(store).toThrow(/vercel_blob_rw_/)
353+
})
354+
355+
it('is refused even with a store id beside it, because it was typed on purpose', () => {
356+
expect(() =>
357+
BlobFileStore.fromEnv({ BLOB_STORE_ID: STORE_ID, BLOB_READ_WRITE_TOKEN: 'garbage' }),
358+
).toThrow(/vercel_blob_rw_/)
359+
})
360+
})
361+
362+
describe('a store id with whitespace around it', () => {
363+
it('names the same store as the trimmed one', () => {
364+
const padded = new BlobFileStore({ storeId: ` ${STORE_ID} ` }, fakeBlob())
365+
expect(padded.url('a.png')).toBe(
366+
new BlobFileStore({ storeId: STORE_ID }, fakeBlob()).url('a.png'),
367+
)
368+
})
369+
370+
it('is trimmed before the SDK ever sees it', async () => {
371+
const blob = fakeBlob()
372+
const store = new BlobFileStore({ storeId: ` ${STORE_ID} ` }, blob)
373+
374+
await store.delete('a.png')
375+
376+
expect(blob.storeIds).toEqual([STORE_ID])
377+
})
378+
379+
it('still matches a token for the same store, rather than falling to the token path', async () => {
380+
const blob = fakeBlob()
381+
const store = BlobFileStore.fromEnv({
382+
BLOB_STORE_ID: ` ${STORE_ID} `,
383+
BLOB_READ_WRITE_TOKEN: TOKEN,
384+
})
385+
Object.assign(store as unknown as { loading: Promise<BlobLike> }, {
386+
loading: Promise.resolve(blob),
387+
})
388+
389+
await store.delete('a.png')
390+
391+
expect(blob.tokens).toEqual([undefined])
392+
})
393+
})
394+
395+
describe('the OIDC explanation', () => {
396+
it('is not offered on the token path, which never asked OIDC for anything', async () => {
397+
const blob = fakeBlob()
398+
blob.put = () => Promise.reject(new FakeNoCredentialsError())
399+
const store = new BlobFileStore({ token: TOKEN }, blob)
400+
401+
await expect(
402+
store.put('a.png', new TextEncoder().encode('x'), {
403+
contentType: 'image/png',
404+
visibility: 'private',
405+
}),
406+
).rejects.toThrow(BLOB_NO_CREDENTIALS)
407+
})
408+
})

0 commit comments

Comments
 (0)