Skip to content

Commit 5fc85c0

Browse files
committed
feat!: validate keys and store names
BREAKING CHANGE: Stores and keys are no longer URL-encoded and must follow a new set of naming rules
1 parent 82df6ad commit 5fc85c0

File tree

3 files changed

+121
-47
lines changed

3 files changed

+121
-47
lines changed

src/client.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ export class Client {
4242
}
4343

4444
private async getFinalRequest(storeName: string, key: string, method: string, metadata?: Metadata) {
45-
const encodedKey = encodeURIComponent(key)
4645
const encodedMetadata = encodeMetadata(metadata)
4746

4847
if (this.edgeURL) {
@@ -56,13 +55,13 @@ export class Client {
5655

5756
return {
5857
headers,
59-
url: `${this.edgeURL}/${this.siteID}/${storeName}/${encodedKey}`,
58+
url: `${this.edgeURL}/${this.siteID}/${storeName}/${key}`,
6059
}
6160
}
6261

6362
const apiURL = `${this.apiURL ?? 'https://api.netlify.com'}/api/v1/sites/${
6463
this.siteID
65-
}/blobs/${encodedKey}?context=${storeName}`
64+
}/blobs/${key}?context=${storeName}`
6665
const apiHeaders: Record<string, string> = { authorization: `Bearer ${this.token}` }
6766

6867
if (encodedMetadata) {

src/main.test.ts

Lines changed: 79 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ afterEach(() => {
3232
const deployID = '6527dfab35be400008332a1d'
3333
const siteID = '9a003659-aaaa-0000-aaaa-63d3720d8621'
3434
const key = '54321'
35-
const complexKey = '/artista/canção'
35+
const complexKey = 'artist/song'
3636
const value = 'some value'
3737
const apiToken = 'some token'
3838
const signedURL = 'https://signed.url/123456789'
@@ -64,9 +64,7 @@ describe('get', () => {
6464
.get({
6565
headers: { authorization: `Bearer ${apiToken}` },
6666
response: new Response(JSON.stringify({ url: signedURL })),
67-
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${encodeURIComponent(
68-
complexKey,
69-
)}?context=production`,
67+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${complexKey}?context=production`,
7068
})
7169
.get({
7270
response: new Response(value),
@@ -519,9 +517,7 @@ describe('set', () => {
519517
.put({
520518
headers: { authorization: `Bearer ${apiToken}` },
521519
response: new Response(JSON.stringify({ url: signedURL })),
522-
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${encodeURIComponent(
523-
complexKey,
524-
)}?context=production`,
520+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${complexKey}?context=production`,
525521
})
526522
.put({
527523
body: value,
@@ -601,6 +597,28 @@ describe('set', () => {
601597
expect(mockStore.fulfilled).toBeTruthy()
602598
})
603599

600+
test('Throws when the key fails validation', async () => {
601+
const mockStore = new MockFetch()
602+
603+
globalThis.fetch = mockStore.fetch
604+
605+
const blobs = getStore({
606+
name: 'production',
607+
token: apiToken,
608+
siteID,
609+
})
610+
611+
expect(async () => await blobs.set('kéy', 'value')).rejects.toThrowError(
612+
`Keys can only contain letters, numbers, percentage signs (%), exclamation marks (!), dots (.), asterisks (*), single quotes ('), parentheses (()), dashes (-) and underscores (_) up to a maximum of 800 characters. Keys can also contain forward slashes (/), but must not start with one.`,
613+
)
614+
expect(async () => await blobs.set('/key', 'value')).rejects.toThrowError(
615+
`Keys can only contain letters, numbers, percentage signs (%), exclamation marks (!), dots (.), asterisks (*), single quotes ('), parentheses (()), dashes (-) and underscores (_) up to a maximum of 800 characters. Keys can also contain forward slashes (/), but must not start with one.`,
616+
)
617+
expect(async () => await blobs.set('a'.repeat(801), 'value')).rejects.toThrowError(
618+
`Keys can only contain letters, numbers, percentage signs (%), exclamation marks (!), dots (.), asterisks (*), single quotes ('), parentheses (()), dashes (-) and underscores (_) up to a maximum of 800 characters. Keys can also contain forward slashes (/), but must not start with one.`,
619+
)
620+
})
621+
604622
test('Retries failed operations', async () => {
605623
const mockStore = new MockFetch()
606624
.put({
@@ -668,7 +686,7 @@ describe('set', () => {
668686
body: value,
669687
headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' },
670688
response: new Response(null),
671-
url: `${edgeURL}/${siteID}/production/${encodeURIComponent(complexKey)}`,
689+
url: `${edgeURL}/${siteID}/production/${complexKey}`,
672690
})
673691

674692
globalThis.fetch = mockStore.fetch
@@ -878,9 +896,7 @@ describe('delete', () => {
878896
.delete({
879897
headers: { authorization: `Bearer ${apiToken}` },
880898
response: new Response(JSON.stringify({ url: signedURL })),
881-
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${encodeURIComponent(
882-
complexKey,
883-
)}?context=production`,
899+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${complexKey}?context=production`,
884900
})
885901
.delete({
886902
response: new Response(null),
@@ -1109,6 +1125,35 @@ describe('Deploy scope', () => {
11091125

11101126
expect(mockStore.fulfilled).toBeTruthy()
11111127
})
1128+
1129+
test('Throws if the deploy ID fails validation', async () => {
1130+
const mockToken = 'some-token'
1131+
const mockStore = new MockFetch()
1132+
const longDeployID = 'd'.repeat(80)
1133+
1134+
globalThis.fetch = mockStore.fetch
1135+
1136+
expect(() => getDeployStore({ deployID: 'deploy/ID', siteID, token: apiToken })).toThrowError(
1137+
`'deploy/ID' is not a valid Netlify deploy ID`,
1138+
)
1139+
expect(() => getStore({ deployID: 'deploy/ID', siteID, token: apiToken })).toThrowError(
1140+
`'deploy/ID' is not a valid Netlify deploy ID`,
1141+
)
1142+
expect(() => getStore({ deployID: longDeployID, siteID, token: apiToken })).toThrowError(
1143+
`'${longDeployID}' is not a valid Netlify deploy ID`,
1144+
)
1145+
1146+
const context = {
1147+
deployID: 'uhoh!',
1148+
edgeURL,
1149+
siteID,
1150+
token: mockToken,
1151+
}
1152+
1153+
env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')
1154+
1155+
expect(() => getDeployStore()).toThrowError(`'uhoh!' is not a valid Netlify deploy ID`)
1156+
})
11121157
})
11131158

11141159
describe('Custom `fetch`', () => {
@@ -1176,18 +1221,38 @@ describe(`getStore`, () => {
11761221
)
11771222
})
11781223

1179-
test('Throws when the name of the store starts with the `deploy:` prefix', async () => {
1224+
test('Throws when the name of the store fails validation', async () => {
11801225
const { fetch } = new MockFetch()
11811226

11821227
globalThis.fetch = fetch
11831228

1229+
expect(() =>
1230+
getStore({
1231+
name: 'some/store',
1232+
token: apiToken,
1233+
siteID,
1234+
}),
1235+
).toThrowError(
1236+
`Store name can only contain letters, numbers, percentage signs (%), exclamation marks (!), dots (.), asterisks (*), single quotes ('), parentheses (()), dashes (-) and underscores (_) up to a maximum of 64 characters.`,
1237+
)
1238+
1239+
expect(() =>
1240+
getStore({
1241+
name: 'a'.repeat(70),
1242+
token: apiToken,
1243+
siteID,
1244+
}),
1245+
).toThrowError(
1246+
`Store name can only contain letters, numbers, percentage signs (%), exclamation marks (!), dots (.), asterisks (*), single quotes ('), parentheses (()), dashes (-) and underscores (_) up to a maximum of 64 characters.`,
1247+
)
1248+
11841249
expect(() =>
11851250
getStore({
11861251
name: 'deploy:foo',
11871252
token: apiToken,
11881253
siteID,
11891254
}),
1190-
).toThrowError('Store name cannot start with the string `deploy:`, which is a reserved namespace')
1255+
).toThrowError('Store name cannot start with the string `deploy:`, which is a reserved namespace.')
11911256

11921257
const context = {
11931258
siteID,
@@ -1197,7 +1262,7 @@ describe(`getStore`, () => {
11971262
env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')
11981263

11991264
expect(() => getStore('deploy:foo')).toThrowError(
1200-
'Store name cannot start with the string `deploy:`, which is a reserved namespace',
1265+
'Store name cannot start with the string `deploy:`, which is a reserved namespace.',
12011266
)
12021267
})
12031268

@@ -1216,30 +1281,4 @@ describe(`getStore`, () => {
12161281
'Netlify Blobs could not find a `fetch` client in the global scope. You can either update your runtime to a version that includes `fetch` (like Node.js 18.0.0 or above), or you can supply your own implementation using the `fetch` property.',
12171282
)
12181283
})
1219-
1220-
test('URL-encodes the store name', async () => {
1221-
const mockStore = new MockFetch()
1222-
.get({
1223-
headers: { authorization: `Bearer ${apiToken}` },
1224-
response: new Response(JSON.stringify({ url: signedURL })),
1225-
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=%2Fwhat%3F`,
1226-
})
1227-
.get({
1228-
response: new Response(value),
1229-
url: signedURL,
1230-
})
1231-
1232-
globalThis.fetch = mockStore.fetch
1233-
1234-
const blobs = getStore({
1235-
name: '/what?',
1236-
token: apiToken,
1237-
siteID,
1238-
})
1239-
1240-
const string = await blobs.get(key)
1241-
expect(string).toBe(value)
1242-
1243-
expect(mockStore.fulfilled).toBeTruthy()
1244-
})
12451284
})

src/store.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@ export class Store {
4444
this.client = options.client
4545

4646
if ('deployID' in options) {
47-
this.name = `deploy:${encodeURIComponent(options.deployID)}`
48-
} else if (options?.name.startsWith('deploy:')) {
49-
throw new Error('Store name cannot start with the string `deploy:`, which is a reserved namespace')
47+
Store.validateDeployID(options.deployID)
48+
49+
this.name = `deploy:${options.deployID}`
5050
} else {
51-
this.name = encodeURIComponent(options.name)
51+
Store.validateStoreName(options.name)
52+
53+
this.name = options.name
5254
}
5355
}
5456

@@ -193,6 +195,8 @@ export class Store {
193195
}
194196

195197
async set(key: string, data: BlobInput, { metadata }: SetOptions = {}) {
198+
Store.validateKey(key)
199+
196200
await this.client.makeRequest({
197201
body: data,
198202
key,
@@ -203,6 +207,8 @@ export class Store {
203207
}
204208

205209
async setJSON(key: string, data: unknown, { metadata }: SetOptions = {}) {
210+
Store.validateKey(key)
211+
206212
const payload = JSON.stringify(data)
207213
const headers = {
208214
'content-type': 'application/json',
@@ -217,4 +223,34 @@ export class Store {
217223
storeName: this.name,
218224
})
219225
}
226+
227+
static validateKey(key: string) {
228+
if (key.startsWith('/') || !/^[\w%!.*'()/-]{1,800}$/.test(key)) {
229+
throw new Error(
230+
"Keys can only contain letters, numbers, percentage signs (%), exclamation marks (!), dots (.), asterisks (*), single quotes ('), parentheses (()), dashes (-) and underscores (_) up to a maximum of 800 characters. Keys can also contain forward slashes (/), but must not start with one.",
231+
)
232+
}
233+
}
234+
235+
static validateDeployID(deployID: string) {
236+
// We could be stricter here and require a length of 24 characters, but the
237+
// CLI currently uses a deploy of `0` when running Netlify Dev, since there
238+
// is no actual deploy at that point. Let's go with a more loose validation
239+
// logic here until we update the CLI.
240+
if (!/^\w{1,24}$/.test(deployID)) {
241+
throw new Error(`'${deployID}' is not a valid Netlify deploy ID.`)
242+
}
243+
}
244+
245+
static validateStoreName(name: string) {
246+
if (name.startsWith('deploy:')) {
247+
throw new Error('Store name cannot start with the string `deploy:`, which is a reserved namespace.')
248+
}
249+
250+
if (!/^[\w%!.*'()-]{1,64}$/.test(name)) {
251+
throw new Error(
252+
"Store name can only contain letters, numbers, percentage signs (%), exclamation marks (!), dots (.), asterisks (*), single quotes ('), parentheses (()), dashes (-) and underscores (_) up to a maximum of 64 characters.",
253+
)
254+
}
255+
}
220256
}

0 commit comments

Comments
 (0)