Skip to content

Commit 1c2fe6f

Browse files
committed
fix: cap expires in and return bad request
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent c863126 commit 1c2fe6f

5 files changed

Lines changed: 96 additions & 5 deletions

File tree

src/http/routes/object/getSignedURL.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MAX_NUMERIC_JWT_EXPIRATION } from '@internal/auth'
12
import { isImageTransformationEnabled } from '@storage/limits'
23
import { ImageRenderer } from '@storage/renderer'
34
import { FastifyInstance } from 'fastify'
@@ -18,7 +19,12 @@ const getSignedURLParamsSchema = {
1819
const getSignedURLBodySchema = {
1920
type: 'object',
2021
properties: {
21-
expiresIn: { type: 'integer', minimum: 1, examples: [60000] },
22+
expiresIn: {
23+
type: 'integer',
24+
minimum: 1,
25+
maximum: MAX_NUMERIC_JWT_EXPIRATION,
26+
examples: [60000],
27+
},
2228
transform: {
2329
type: 'object',
2430
properties: transformationOptionsSchema,

src/http/routes/object/getSignedURLs.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MAX_NUMERIC_JWT_EXPIRATION } from '@internal/auth'
12
import { FastifyInstance, FastifyRequest } from 'fastify'
23
import { FromSchema } from 'json-schema-to-ts'
34
import { createDefaultSchema } from '../../routes-helper'
@@ -14,7 +15,12 @@ const getSignedURLsParamsSchema = {
1415
const getSignedURLsBodySchema = {
1516
type: 'object',
1617
properties: {
17-
expiresIn: { type: 'integer', minimum: 1, examples: [60000] },
18+
expiresIn: {
19+
type: 'integer',
20+
minimum: 1,
21+
maximum: MAX_NUMERIC_JWT_EXPIRATION,
22+
examples: [60000],
23+
},
1824
paths: {
1925
type: 'array',
2026
items: { type: 'string' },

src/internal/auth/jwt.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const JWT_HMAC_ALGOS = ['HS256', 'HS384', 'HS512']
2424
const JWT_RSA_ALGOS = ['RS256', 'RS384', 'RS512']
2525
const JWT_ECC_ALGOS = ['ES256', 'ES384', 'ES512']
2626
const JWT_ED_ALGOS = ['EdDSA']
27+
export const MAX_NUMERIC_JWT_EXPIRATION = Number.MAX_SAFE_INTEGER
2728

2829
export type SignedToken = {
2930
url: string
@@ -231,8 +232,12 @@ export async function signJWT(
231232
): Promise<string> {
232233
const signer = new SignJWT(payload).setIssuedAt()
233234
if (expiresIn) {
234-
const expiresInStr = typeof expiresIn === 'string' ? expiresIn : Math.floor(expiresIn) + 's'
235-
signer.setExpirationTime(expiresInStr)
235+
const expiresInStr = getJWTExpirationTime(expiresIn)
236+
try {
237+
signer.setExpirationTime(expiresInStr)
238+
} catch (e) {
239+
throw ERRORS.InvalidParameter('expiresIn', { error: e as Error })
240+
}
236241
}
237242

238243
if (typeof secret === 'string') {
@@ -246,6 +251,24 @@ export async function signJWT(
246251
}
247252
}
248253

254+
function getJWTExpirationTime(expiresIn: string | number) {
255+
if (typeof expiresIn === 'string') {
256+
return expiresIn
257+
}
258+
259+
if (!Number.isFinite(expiresIn)) {
260+
throw ERRORS.InvalidParameter('expiresIn')
261+
}
262+
263+
const expiresInSeconds = Math.floor(expiresIn)
264+
265+
if (!Number.isSafeInteger(expiresInSeconds) || expiresInSeconds > MAX_NUMERIC_JWT_EXPIRATION) {
266+
throw ERRORS.InvalidParameter('expiresIn')
267+
}
268+
269+
return `${expiresInSeconds}s`
270+
}
271+
249272
/**
250273
* Generate a new random HS512 JWK that can be used for signing JWTs
251274
*/

src/test/jwt.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { JWT_CACHE_NAME } from '@internal/cache'
2+
import { ErrorCode } from '@internal/errors'
23
import { cacheRequestsTotal } from '@internal/monitoring/metrics'
34
import * as crypto from 'crypto'
45
import { SignJWT } from 'jose'
56
import { JwksConfigKey } from '../config'
6-
import { generateHS512JWK, signJWT, verifyJWT, verifyJWTWithCache } from '../internal/auth'
7+
import {
8+
generateHS512JWK,
9+
MAX_NUMERIC_JWT_EXPIRATION,
10+
signJWT,
11+
verifyJWT,
12+
verifyJWTWithCache,
13+
} from '../internal/auth'
714

815
describe('JWT', () => {
916
describe('verifyJWT with JWKS', () => {
@@ -148,6 +155,22 @@ describe('JWT', () => {
148155
)
149156
})
150157

158+
test('it should allow the configured maximum numeric expiration', async () => {
159+
await expect(
160+
signJWT({ sub: 'things' }, hmacPrivateKeyWithoutKid, MAX_NUMERIC_JWT_EXPIRATION)
161+
).resolves.toEqual(expect.any(String))
162+
})
163+
164+
test('it should reject numeric expirations above the configured maximum', async () => {
165+
await expect(
166+
signJWT({ sub: 'things' }, hmacPrivateKeyWithoutKid, MAX_NUMERIC_JWT_EXPIRATION + 1)
167+
).rejects.toMatchObject({
168+
code: ErrorCode.InvalidParameter,
169+
httpStatusCode: 400,
170+
message: 'Invalid Parameter expiresIn',
171+
})
172+
})
173+
151174
test('it should reject if jwt is malformed', async () => {
152175
await expect(verifyJWT('this is not a jwt', 'and this is not a secret')).rejects.toThrow(
153176
'Invalid Compact JWS'

src/test/object.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1830,6 +1830,22 @@ describe('testing generating signed URL', () => {
18301830
})
18311831
expect(response.statusCode).toBe(400)
18321832
})
1833+
1834+
test('rejects oversized expiresIn values for signed URLs before jwt signing', async () => {
1835+
const response = await appInstance.inject({
1836+
method: 'POST',
1837+
url: '/object/sign/bucket2/authenticated/cat.jpg',
1838+
headers: {
1839+
authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`,
1840+
},
1841+
payload: {
1842+
expiresIn: 1e21,
1843+
},
1844+
})
1845+
1846+
expect(response.statusCode).toBe(400)
1847+
expect(JSON.parse(response.body).message).toContain('expiresIn')
1848+
})
18331849
})
18341850

18351851
/**
@@ -2207,6 +2223,23 @@ describe('testing generating signed URLs', () => {
22072223
const result = JSON.parse(response.body)
22082224
expect(result[0].error).toBe('Either the object does not exist or you do not have access to it')
22092225
})
2226+
2227+
test('rejects oversized expiresIn values for batch signed URLs before jwt signing', async () => {
2228+
const response = await appInstance.inject({
2229+
method: 'POST',
2230+
url: '/object/sign/bucket2',
2231+
headers: {
2232+
authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`,
2233+
},
2234+
payload: {
2235+
expiresIn: 1e21,
2236+
paths: ['authenticated/cat.jpg'],
2237+
},
2238+
})
2239+
2240+
expect(response.statusCode).toBe(400)
2241+
expect(JSON.parse(response.body).message).toContain('expiresIn')
2242+
})
22102243
})
22112244

22122245
/**

0 commit comments

Comments
 (0)