Skip to content

Commit b755ffe

Browse files
authored
fix: SDK key detection to avoid false positives with third-party identifiers (#345)
The SDK key validation now uses a regex (/^vf_(?:server|client)_/) to require the format vf_server_* or vf_client_* instead of accepting any string starting with vf_. This prevents false positives with third-party service identifiers that happen to start with vf_ (e.g., Stripe identity flow IDs like vf_1PyHgVLpWuMxVFx...). Adds isValidSdkKey() helper function and updates parseSdkKeyFromFlagsConnectionString() to use the stricter validation. Updates all tests to use valid SDK key formats.
1 parent 471e004 commit b755ffe

File tree

12 files changed

+300
-106
lines changed

12 files changed

+300
-106
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@vercel/prepare-flags-definitions": patch
3+
"@vercel/flags-core": patch
4+
---
5+
6+
Fix SDK key detection to avoid false positives with third-party identifiers.
7+
8+
The SDK key validation now uses a regex to require the format `vf_server_*` or `vf_client_*` instead of accepting any string starting with `vf_`. This prevents false positives with third-party service identifiers that happen to start with `vf_` (e.g., Stripe identity flow IDs like `vf_1PyHgVLpWuMxVFx...`).

packages/adapter-vercel/src/index.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ describe('createVercelAdapter', () => {
6969
originalFlagsSecret = process.env.FLAGS_SECRET;
7070
originalFlags = process.env.FLAGS;
7171
process.env.FLAGS_SECRET = 'a'.repeat(32);
72-
process.env.FLAGS = 'vf_test_sdk_key';
72+
process.env.FLAGS = 'vf_server_test_sdk_key';
7373
});
7474

7575
afterAll(() => {
@@ -89,18 +89,18 @@ describe('createVercelAdapter', () => {
8989
expect(amended).toHaveProperty('decide');
9090
expect(amended).toHaveProperty('origin', {
9191
provider: 'vercel',
92-
sdkKey: 'vf_test_sdk_key',
92+
sdkKey: 'vf_server_test_sdk_key',
9393
} satisfies Origin);
9494
});
9595

9696
it('returns origin when created with sdkKey string', () => {
97-
const adapter = createVercelAdapter('vf_my_sdk_key');
97+
const adapter = createVercelAdapter('vf_client_my_sdk_key');
9898

9999
const amended = adapter();
100100
expect(amended).toHaveProperty('decide');
101101
expect(amended).toHaveProperty('origin', {
102102
provider: 'vercel',
103-
sdkKey: 'vf_my_sdk_key',
103+
sdkKey: 'vf_client_my_sdk_key',
104104
} satisfies Origin);
105105
});
106106

@@ -124,7 +124,7 @@ describe('when used with getProviderData', () => {
124124

125125
beforeAll(() => {
126126
originalFlags = process.env.FLAGS;
127-
process.env.FLAGS = 'vf_test_sdk_key';
127+
process.env.FLAGS = 'vf_server_test_sdk_key';
128128
});
129129

130130
afterAll(() => {
@@ -181,7 +181,7 @@ describe('vercelAdapter', () => {
181181
originalFlagsSecret = process.env.FLAGS_SECRET;
182182
originalFlags = process.env.FLAGS;
183183
process.env.FLAGS_SECRET = 'a'.repeat(32);
184-
process.env.FLAGS = 'vf_test_sdk_key';
184+
process.env.FLAGS = 'vf_server_test_sdk_key';
185185

186186
resetDefaultFlagsClient();
187187
resetDefaultVercelAdapter();
@@ -229,16 +229,16 @@ describe('vercelAdapter', () => {
229229
const testFlag = flag({ key: 'test-flag', adapter: vercelAdapter() });
230230
expect(testFlag.origin).toEqual({
231231
provider: 'vercel',
232-
sdkKey: 'vf_test_sdk_key',
232+
sdkKey: 'vf_server_test_sdk_key',
233233
} satisfies Origin);
234234
});
235235

236236
it('sets vercel origin when using adapter created with sdkKey', () => {
237-
const adapter = createVercelAdapter('vf_my_sdk_key');
237+
const adapter = createVercelAdapter('vf_client_my_sdk_key');
238238
const testFlag = flag({ key: 'test-flag', adapter: adapter() });
239239
expect(testFlag.origin).toEqual({
240240
provider: 'vercel',
241-
sdkKey: 'vf_my_sdk_key',
241+
sdkKey: 'vf_client_my_sdk_key',
242242
} satisfies Origin);
243243
});
244244
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# `@vercel/prepare-flags-definitions`
2+
3+
A build-time utility for [Vercel Flags](https://vercel.com/docs/flags/vercel-flags) that fetches flag definitions and bundles them into a synthetic `@vercel/flags-definitions` package inside `node_modules`. This allows `@vercel/flags-core` to access flag definitions instantly at runtime, even when the network is unavailable.
4+
5+
This package is used by the Vercel CLI and other build tools. You typically do not need to install it directly.
6+
7+
## Installation
8+
9+
```bash
10+
npm i @vercel/prepare-flags-definitions
11+
```
12+
13+
## Usage
14+
15+
```ts
16+
import { prepareFlagsDefinitions } from '@vercel/prepare-flags-definitions';
17+
18+
const result = await prepareFlagsDefinitions({
19+
cwd: process.cwd(),
20+
env: process.env,
21+
userAgentSuffix: 'my-build-tool/1.0.0',
22+
});
23+
24+
if (result.created) {
25+
console.log(`Bundled definitions for ${result.sdkKeysCount} SDK keys`);
26+
} else {
27+
console.log(`No definitions created: ${result.reason}`);
28+
}
29+
```
30+
31+
## How It Works
32+
33+
1. Scans environment variables for SDK keys (matching `vf_server_*` or `vf_client_*`)
34+
2. Fetches flag definitions from `flags.vercel.com` for each key
35+
3. Generates an optimized JavaScript module with deduplication and lazy parsing
36+
4. Writes the module to `node_modules/@vercel/flags-definitions/`
37+
38+
At runtime, `@vercel/flags-core` imports this module as a fallback when streaming or polling is unavailable.
39+
40+
## Documentation
41+
42+
- [Embedded Definitions](https://vercel.com/docs/flags/vercel-flags/sdks/core#embedded-definitions)
43+
- [Vercel Flags](https://vercel.com/docs/flags/vercel-flags)

packages/prepare-flags-definitions/src/index.test.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,33 @@ import {
88

99
describe('hashSdkKey', () => {
1010
it('returns a SHA-256 hex digest', () => {
11-
const hash = hashSdkKey('vf_test_key');
11+
const hash = hashSdkKey('vf_server_test_key');
1212
expect(hash).toMatch(/^[a-f0-9]{64}$/);
1313
});
1414

1515
it('returns the same hash for the same input', () => {
16-
expect(hashSdkKey('vf_abc')).toBe(hashSdkKey('vf_abc'));
16+
expect(hashSdkKey('vf_server_abc')).toBe(hashSdkKey('vf_server_abc'));
1717
});
1818

1919
it('returns different hashes for different inputs', () => {
20-
expect(hashSdkKey('vf_abc')).not.toBe(hashSdkKey('vf_xyz'));
20+
expect(hashSdkKey('vf_server_abc')).not.toBe(hashSdkKey('vf_client_xyz'));
2121
});
2222
});
2323

2424
describe('generateDefinitionsModule', () => {
2525
it('generates a valid JS module', () => {
26-
const sdkKeys = ['vf_key1'];
26+
const sdkKeys = ['vf_server_key1'];
2727
const values = [{ flag_a: { value: true } }];
2828
const result = generateDefinitionsModule(sdkKeys, values);
2929

3030
expect(result).toContain('const memo');
3131
expect(result).toContain('export function get(hashedSdkKey)');
3232
expect(result).toContain('export const version');
33-
expect(result).toContain(hashSdkKey('vf_key1'));
33+
expect(result).toContain(hashSdkKey('vf_server_key1'));
3434
});
3535

3636
it('deduplicates identical definitions', () => {
37-
const sdkKeys = ['vf_key1', 'vf_key2'];
37+
const sdkKeys = ['vf_server_key1', 'vf_client_key2'];
3838
const sharedDef = { flag_a: { value: true } };
3939
const values = [sharedDef, sharedDef];
4040
const result = generateDefinitionsModule(sdkKeys, values);
@@ -44,7 +44,7 @@ describe('generateDefinitionsModule', () => {
4444
});
4545

4646
it('keeps separate definitions when values differ', () => {
47-
const sdkKeys = ['vf_key1', 'vf_key2'];
47+
const sdkKeys = ['vf_server_key1', 'vf_client_key2'];
4848
const values = [{ flag_a: { value: true } }, { flag_b: { value: false } }];
4949
const result = generateDefinitionsModule(sdkKeys, values);
5050

@@ -53,12 +53,16 @@ describe('generateDefinitionsModule', () => {
5353
});
5454

5555
it('maps each SDK key hash to the correct definition index', () => {
56-
const sdkKeys = ['vf_key1', 'vf_key2'];
56+
const sdkKeys = ['vf_server_key1', 'vf_client_key2'];
5757
const values = [{ flag_a: true }, { flag_b: false }];
5858
const result = generateDefinitionsModule(sdkKeys, values);
5959

60-
expect(result).toContain(`${JSON.stringify(hashSdkKey('vf_key1'))}: _d0`);
61-
expect(result).toContain(`${JSON.stringify(hashSdkKey('vf_key2'))}: _d1`);
60+
expect(result).toContain(
61+
`${JSON.stringify(hashSdkKey('vf_server_key1'))}: _d0`,
62+
);
63+
expect(result).toContain(
64+
`${JSON.stringify(hashSdkKey('vf_client_key2'))}: _d1`,
65+
);
6266
});
6367

6468
it('handles empty input', () => {
@@ -86,7 +90,7 @@ describe('prepareFlagsDefinitions', () => {
8690

8791
const result = await prepareFlagsDefinitions({
8892
cwd: '/tmp/test-definitions',
89-
env: { FLAGS_SECRET: 'vf_test_key_123' },
93+
env: { FLAGS_SECRET: 'vf_server_test_key_123' },
9094
fetch: mockFetch,
9195
});
9296

@@ -101,7 +105,7 @@ describe('prepareFlagsDefinitions', () => {
101105

102106
await prepareFlagsDefinitions({
103107
cwd: '/tmp/test-ua',
104-
env: { FLAGS_SECRET: 'vf_test_key' },
108+
env: { FLAGS_SECRET: 'vf_server_test_key' },
105109
fetch: mockFetch,
106110
});
107111

@@ -119,7 +123,7 @@ describe('prepareFlagsDefinitions', () => {
119123

120124
await prepareFlagsDefinitions({
121125
cwd: '/tmp/test-ua',
122-
env: { FLAGS_SECRET: 'vf_test_key' },
126+
env: { FLAGS_SECRET: 'vf_client_test_key' },
123127
userAgentSuffix: 'vercel-cli/35.0.0',
124128
fetch: mockFetch,
125129
});
@@ -129,4 +133,56 @@ describe('prepareFlagsDefinitions', () => {
129133
`@vercel/prepare-flags-definitions/${pkgVersion} vercel-cli/35.0.0`,
130134
);
131135
});
136+
137+
it('ignores third-party identifiers that start with vf_ but are not SDK keys', async () => {
138+
const mockFetch = vi.fn();
139+
140+
const result = await prepareFlagsDefinitions({
141+
cwd: '/tmp/test-third-party',
142+
env: {
143+
STRIPE_FLOW_ID: 'vf_1PyHgVLpWuMxVFxAbCdEfGhIjKlMn',
144+
STRIPE_LIVE_ID: 'vf_live_test_12345',
145+
OTHER_SERVICE: 'vf_something_else',
146+
},
147+
fetch: mockFetch,
148+
});
149+
150+
expect(result).toEqual({ created: false, reason: 'no-sdk-keys' });
151+
expect(mockFetch).not.toHaveBeenCalled();
152+
});
153+
154+
it('extracts SDK keys from flags: connection string format', async () => {
155+
const mockFetch = vi.fn().mockResolvedValue({
156+
ok: true,
157+
json: () => Promise.resolve({ flag_a: { value: true } }),
158+
});
159+
160+
const result = await prepareFlagsDefinitions({
161+
cwd: '/tmp/test-flags-format',
162+
env: {
163+
FLAGS_CONNECTION: 'flags:sdkKey=vf_server_my_key&other=value',
164+
},
165+
fetch: mockFetch,
166+
});
167+
168+
expect(result).toEqual({ created: true, sdkKeysCount: 1 });
169+
expect(mockFetch).toHaveBeenCalledTimes(1);
170+
const headers = mockFetch.mock.calls[0]?.[1]?.headers;
171+
expect(headers.authorization).toBe('Bearer vf_server_my_key');
172+
});
173+
174+
it('ignores invalid SDK keys in flags: connection string', async () => {
175+
const mockFetch = vi.fn();
176+
177+
const result = await prepareFlagsDefinitions({
178+
cwd: '/tmp/test-invalid-flags',
179+
env: {
180+
FLAGS_CONNECTION: 'flags:sdkKey=vf_invalid_key&other=value',
181+
},
182+
fetch: mockFetch,
183+
});
184+
185+
expect(result).toEqual({ created: false, reason: 'no-sdk-keys' });
186+
expect(mockFetch).not.toHaveBeenCalled();
187+
});
132188
});

packages/prepare-flags-definitions/src/index.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ export function hashSdkKey(sdkKey: string): string {
3434
return createHash('sha256').update(sdkKey).digest('hex');
3535
}
3636

37+
/**
38+
* Regex to match valid Vercel Flags SDK keys.
39+
* SDK keys must follow the format: vf_server_* or vf_client_*
40+
* This avoids false positives with third-party identifiers that happen
41+
* to start with 'vf_' (e.g., Stripe identity flow IDs like 'vf_1PyH...').
42+
*/
43+
const SDK_KEY_REGEX = /^vf_(?:server|client)_/;
44+
3745
/**
3846
* Generates a JS module with deduplicated, lazily-parsed definitions.
3947
*
@@ -131,16 +139,16 @@ export async function prepareFlagsDefinitions(options: {
131139
output?.debug('vercel-flags: checking env vars for SDK Keys');
132140

133141
// Collect unique SDK keys from environment variables
134-
// Supports both direct SDK keys (vf_ prefix) and flags: format
142+
// Supports both direct SDK keys (vf_server_*/vf_client_*) and flags: format
135143
const sdkKeys = Array.from(
136144
Object.values(env).reduce<Set<string>>((acc, value) => {
137145
if (typeof value === 'string') {
138-
if (value.startsWith('vf_')) {
146+
if (SDK_KEY_REGEX.test(value)) {
139147
acc.add(value);
140148
} else if (value.startsWith('flags:')) {
141149
const params = new URLSearchParams(value.slice('flags:'.length));
142150
const sdkKey = params.get('sdkKey');
143-
if (sdkKey?.startsWith('vf_')) {
151+
if (sdkKey && SDK_KEY_REGEX.test(sdkKey)) {
144152
acc.add(sdkKey);
145153
}
146154
}

packages/vercel-flags-core/src/black-box.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ vi.mock('./lib/report-value', () => ({
2424
internalReportValue: vi.fn(),
2525
}));
2626

27-
const sdkKey = 'vf_fake';
27+
const sdkKey = 'vf_server_fake';
2828
const fetchMock = vi.fn<typeof fetch>();
2929

3030
/**
@@ -80,21 +80,21 @@ function makeBundled(
8080
}
8181

8282
const ingestRequestHeaders = Object.freeze({
83-
Authorization: 'Bearer vf_fake',
83+
Authorization: 'Bearer vf_server_fake',
8484
'Content-Type': 'application/json',
8585
'User-Agent': `VercelFlagsCore/${version}`,
8686
'X-Vercel-Env': 'production',
8787
});
8888

8989
const streamRequestHeaders = Object.freeze({
90-
Authorization: 'Bearer vf_fake',
90+
Authorization: 'Bearer vf_server_fake',
9191
'User-Agent': `VercelFlagsCore/${version}`,
9292
'X-Retry-Attempt': '0',
9393
'X-Vercel-Env': 'production',
9494
});
9595

9696
const datafileRequestHeaders = Object.freeze({
97-
Authorization: 'Bearer vf_fake',
97+
Authorization: 'Bearer vf_server_fake',
9898
'User-Agent': `VercelFlagsCore/${version}`,
9999
'X-Vercel-Env': 'production',
100100
});
@@ -168,7 +168,7 @@ describe('Controller (black-box)', () => {
168168

169169
it('should accept valid SDK key', () => {
170170
expect(() =>
171-
createClient('vf_valid_key', {
171+
createClient('vf_server_valid_key', {
172172
fetch: fetchMock,
173173
stream: false,
174174
polling: false,

0 commit comments

Comments
 (0)