Skip to content

Commit

Permalink
feat!: validate keys and store names (#80)
Browse files Browse the repository at this point in the history
  • Loading branch information
eduardoboucas authored Oct 24, 2023
1 parent 68f5818 commit af867f8
Show file tree
Hide file tree
Showing 3 changed files with 117 additions and 47 deletions.
5 changes: 2 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export class Client {
}

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

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

return {
headers,
url: `${this.edgeURL}/${this.siteID}/${storeName}/${encodedKey}`,
url: `${this.edgeURL}/${this.siteID}/${storeName}/${key}`,
}
}

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

if (encodedMetadata) {
Expand Down
115 changes: 75 additions & 40 deletions src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ afterEach(() => {
const deployID = '6527dfab35be400008332a1d'
const siteID = '9a003659-aaaa-0000-aaaa-63d3720d8621'
const key = '54321'
const complexKey = '/artista/canção'
const complexKey = 'artist/song'
const value = 'some value'
const apiToken = 'some token'
const signedURL = 'https://signed.url/123456789'
Expand Down Expand Up @@ -64,9 +64,7 @@ describe('get', () => {
.get({
headers: { authorization: `Bearer ${apiToken}` },
response: new Response(JSON.stringify({ url: signedURL })),
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${encodeURIComponent(
complexKey,
)}?context=production`,
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${complexKey}?context=production`,
})
.get({
response: new Response(value),
Expand Down Expand Up @@ -519,9 +517,7 @@ describe('set', () => {
.put({
headers: { authorization: `Bearer ${apiToken}` },
response: new Response(JSON.stringify({ url: signedURL })),
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${encodeURIComponent(
complexKey,
)}?context=production`,
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${complexKey}?context=production`,
})
.put({
body: value,
Expand Down Expand Up @@ -601,6 +597,24 @@ describe('set', () => {
expect(mockStore.fulfilled).toBeTruthy()
})

test('Throws when the key fails validation', async () => {
const mockStore = new MockFetch()

globalThis.fetch = mockStore.fetch

const blobs = getStore({
name: 'production',
token: apiToken,
siteID,
})

const expectedError = `Keys can only contain letters, numbers, percentage signs (%), exclamation marks (!), dots (.), asterisks (*), single quotes ('), parentheses (()), dashes (-) and underscores (_) up to a maximum of 600 characters. Keys can also contain forward slashes (/), but must not start with one.`

expect(async () => await blobs.set('kéy', 'value')).rejects.toThrowError(expectedError)
expect(async () => await blobs.set('/key', 'value')).rejects.toThrowError(expectedError)
expect(async () => await blobs.set('a'.repeat(801), 'value')).rejects.toThrowError(expectedError)
})

test('Retries failed operations', async () => {
const mockStore = new MockFetch()
.put({
Expand Down Expand Up @@ -668,7 +682,7 @@ describe('set', () => {
body: value,
headers: { authorization: `Bearer ${edgeToken}`, 'cache-control': 'max-age=0, stale-while-revalidate=60' },
response: new Response(null),
url: `${edgeURL}/${siteID}/production/${encodeURIComponent(complexKey)}`,
url: `${edgeURL}/${siteID}/production/${complexKey}`,
})

globalThis.fetch = mockStore.fetch
Expand Down Expand Up @@ -878,9 +892,7 @@ describe('delete', () => {
.delete({
headers: { authorization: `Bearer ${apiToken}` },
response: new Response(JSON.stringify({ url: signedURL })),
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${encodeURIComponent(
complexKey,
)}?context=production`,
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${complexKey}?context=production`,
})
.delete({
response: new Response(null),
Expand Down Expand Up @@ -1109,6 +1121,35 @@ describe('Deploy scope', () => {

expect(mockStore.fulfilled).toBeTruthy()
})

test('Throws if the deploy ID fails validation', async () => {
const mockToken = 'some-token'
const mockStore = new MockFetch()
const longDeployID = 'd'.repeat(80)

globalThis.fetch = mockStore.fetch

expect(() => getDeployStore({ deployID: 'deploy/ID', siteID, token: apiToken })).toThrowError(
`'deploy/ID' is not a valid Netlify deploy ID`,
)
expect(() => getStore({ deployID: 'deploy/ID', siteID, token: apiToken })).toThrowError(
`'deploy/ID' is not a valid Netlify deploy ID`,
)
expect(() => getStore({ deployID: longDeployID, siteID, token: apiToken })).toThrowError(
`'${longDeployID}' is not a valid Netlify deploy ID`,
)

const context = {
deployID: 'uhoh!',
edgeURL,
siteID,
token: mockToken,
}

env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')

expect(() => getDeployStore()).toThrowError(`'uhoh!' is not a valid Netlify deploy ID`)
})
})

describe('Custom `fetch`', () => {
Expand Down Expand Up @@ -1176,18 +1217,38 @@ describe(`getStore`, () => {
)
})

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

globalThis.fetch = fetch

expect(() =>
getStore({
name: 'some/store',
token: apiToken,
siteID,
}),
).toThrowError(
`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.`,
)

expect(() =>
getStore({
name: 'a'.repeat(70),
token: apiToken,
siteID,
}),
).toThrowError(
`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.`,
)

expect(() =>
getStore({
name: 'deploy:foo',
token: apiToken,
siteID,
}),
).toThrowError('Store name cannot start with the string `deploy:`, which is a reserved namespace')
).toThrowError('Store name cannot start with the string `deploy:`, which is a reserved namespace.')

const context = {
siteID,
Expand All @@ -1197,7 +1258,7 @@ describe(`getStore`, () => {
env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')

expect(() => getStore('deploy:foo')).toThrowError(
'Store name cannot start with the string `deploy:`, which is a reserved namespace',
'Store name cannot start with the string `deploy:`, which is a reserved namespace.',
)
})

Expand All @@ -1216,30 +1277,4 @@ describe(`getStore`, () => {
'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.',
)
})

test('URL-encodes the store name', async () => {
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${apiToken}` },
response: new Response(JSON.stringify({ url: signedURL })),
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=%2Fwhat%3F`,
})
.get({
response: new Response(value),
url: signedURL,
})

globalThis.fetch = mockStore.fetch

const blobs = getStore({
name: '/what?',
token: apiToken,
siteID,
})

const string = await blobs.get(key)
expect(string).toBe(value)

expect(mockStore.fulfilled).toBeTruthy()
})
})
44 changes: 40 additions & 4 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ export class Store {
this.client = options.client

if ('deployID' in options) {
this.name = `deploy:${encodeURIComponent(options.deployID)}`
} else if (options?.name.startsWith('deploy:')) {
throw new Error('Store name cannot start with the string `deploy:`, which is a reserved namespace')
Store.validateDeployID(options.deployID)

this.name = `deploy:${options.deployID}`
} else {
this.name = encodeURIComponent(options.name)
Store.validateStoreName(options.name)

this.name = options.name
}
}

Expand Down Expand Up @@ -193,6 +195,8 @@ export class Store {
}

async set(key: string, data: BlobInput, { metadata }: SetOptions = {}) {
Store.validateKey(key)

await this.client.makeRequest({
body: data,
key,
Expand All @@ -203,6 +207,8 @@ export class Store {
}

async setJSON(key: string, data: unknown, { metadata }: SetOptions = {}) {
Store.validateKey(key)

const payload = JSON.stringify(data)
const headers = {
'content-type': 'application/json',
Expand All @@ -217,4 +223,34 @@ export class Store {
storeName: this.name,
})
}

static validateKey(key: string) {
if (key.startsWith('/') || !/^[\w%!.*'()/-]{1,600}$/.test(key)) {
throw new Error(
"Keys can only contain letters, numbers, percentage signs (%), exclamation marks (!), dots (.), asterisks (*), single quotes ('), parentheses (()), dashes (-) and underscores (_) up to a maximum of 600 characters. Keys can also contain forward slashes (/), but must not start with one.",
)
}
}

static validateDeployID(deployID: string) {
// We could be stricter here and require a length of 24 characters, but the
// CLI currently uses a deploy of `0` when running Netlify Dev, since there
// is no actual deploy at that point. Let's go with a more loose validation
// logic here until we update the CLI.
if (!/^\w{1,24}$/.test(deployID)) {
throw new Error(`'${deployID}' is not a valid Netlify deploy ID.`)
}
}

static validateStoreName(name: string) {
if (name.startsWith('deploy:')) {
throw new Error('Store name cannot start with the string `deploy:`, which is a reserved namespace.')
}

if (!/^[\w%!.*'()-]{1,64}$/.test(name)) {
throw new Error(
"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.",
)
}
}
}

0 comments on commit af867f8

Please sign in to comment.