Skip to content

Commit fdb87ba

Browse files
otncusualoma
andauthored
fix(serve-static): correct Range header parsing edge cases (#372)
* fix(serve-static): correct Range header parsing edge cases - `bytes=-N` (suffix range) now returns the last N bytes instead of the first N+1 bytes. - `bytes=0-0` now returns 1 byte instead of the whole file (the old `parseInt(...) || size - 1` treated a valid 0 as falsy). - An unsatisfiable range (start beyond EOF, or start > end) now returns 416 instead of crashing createReadStream with a RangeError. * fix(serve-static): reject non-digit range values and fix empty-file crash - Range segments with trailing garbage (e.g. `bytes=0-1x`) were silently truncated by parseInt into `0-1` instead of being treated as malformed. first-pos/last-pos/suffix-length are now validated as `1*DIGIT`. - A Range request on an empty file fell through to createReadStream with end=-1 and crashed with 500 instead of returning 416. Addresses review feedback from @usualoma on #372. * refactor(serve-static): separate range parsing and resolution --------- Co-authored-by: Taku Amano <taku@taaas.jp>
1 parent 912e3fd commit fdb87ba

2 files changed

Lines changed: 135 additions & 10 deletions

File tree

src/serve-static.ts

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,55 @@ const getStats = (path: string) => {
3535
return stats
3636
}
3737

38+
type ByteRangeSpec =
39+
| { type: 'bounded'; start: number; end: number }
40+
| { type: 'open-ended'; start: number }
41+
| { type: 'suffix'; length: number }
42+
43+
type ByteRange = { start: number; end: number }
44+
45+
const BYTE_RANGE_PATTERN = /^(?:bytes=)?(?!-$)(\d*)-(\d*)$/
46+
47+
const parseByteRange = (range: string): ByteRangeSpec | undefined => {
48+
const match = range.match(BYTE_RANGE_PATTERN)
49+
if (!match) {
50+
return undefined
51+
}
52+
53+
const [, start, end] = match
54+
55+
if (start === '') {
56+
return { type: 'suffix', length: Number(end) }
57+
}
58+
59+
if (end === '') {
60+
return { type: 'open-ended', start: Number(start) }
61+
}
62+
63+
return { type: 'bounded', start: Number(start), end: Number(end) }
64+
}
65+
66+
const resolveByteRange = (spec: ByteRangeSpec, size: number): ByteRange | undefined => {
67+
if (size === 0) {
68+
return undefined
69+
}
70+
71+
if (spec.type === 'suffix') {
72+
if (spec.length === 0) {
73+
return undefined
74+
}
75+
76+
return { start: Math.max(size - spec.length, 0), end: size - 1 }
77+
}
78+
79+
const end = spec.type === 'bounded' ? Math.min(spec.end, size - 1) : size - 1
80+
if (spec.start >= size || spec.start > end) {
81+
return undefined
82+
}
83+
84+
return { start: spec.start, end }
85+
}
86+
3887
type Decoder = (str: string) => string
3988

4089
const tryDecode = (str: string, decoder: Decoder): string => {
@@ -151,20 +200,27 @@ export const serveStatic = <E extends Env = any>(
151200
} else {
152201
c.header('Accept-Ranges', 'bytes')
153202

154-
const parts = range.replace(/bytes=/, '').split('-', 2)
155-
const start = parseInt(parts[0], 10) || 0
156-
let end = parseInt(parts[1], 10) || size - 1
157-
if (size < end - start + 1) {
158-
end = size - 1
203+
// Preserve the existing behavior of serving the whole representation for
204+
// a malformed range.
205+
const rangeSpec: ByteRangeSpec = parseByteRange(range) ?? {
206+
type: 'open-ended',
207+
start: 0,
159208
}
209+
const resolvedRange = resolveByteRange(rangeSpec, size)
160210

161-
const chunksize = end - start + 1
162-
const stream = createReadStream(path, { start, end })
211+
if (!resolvedRange) {
212+
c.header('Content-Range', `bytes */${size}`)
213+
result = c.body(null, 416)
214+
} else {
215+
const { start, end } = resolvedRange
216+
const chunkSize = end - start + 1
217+
const stream = createReadStream(path, { start, end })
163218

164-
c.header('Content-Length', chunksize.toString())
165-
c.header('Content-Range', `bytes ${start}-${end}/${stats.size}`)
219+
c.header('Content-Length', chunkSize.toString())
220+
c.header('Content-Range', `bytes ${start}-${end}/${size}`)
166221

167-
result = c.body(createStreamBody(stream), 206)
222+
result = c.body(createStreamBody(stream), 206)
223+
}
168224
}
169225

170226
await options.onFound?.(path, c)

test/serve-static.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,75 @@ describe('Serve Static Middleware', () => {
185185
expect(res.headers['content-range']).toBe('bytes 0-16/17')
186186
})
187187

188+
it('Should return the last N bytes for a suffix range', async () => {
189+
const res = await request(server).get('/static/plain.txt').set('range', 'bytes=-5')
190+
expect(res.status).toBe(206)
191+
expect(res.headers['content-length']).toBe('5')
192+
expect(res.headers['content-range']).toBe('bytes 12-16/17')
193+
expect(res.text).toBe('n.txt')
194+
})
195+
196+
it('Should return the whole file for a suffix range exceeding the file size', async () => {
197+
const res = await request(server).get('/static/plain.txt').set('range', 'bytes=-100')
198+
expect(res.status).toBe(206)
199+
expect(res.headers['content-range']).toBe('bytes 0-16/17')
200+
expect(res.text).toBe('This is plain.txt')
201+
})
202+
203+
it('Should return exactly 1 byte for range bytes=0-0', async () => {
204+
const res = await request(server).get('/static/plain.txt').set('range', 'bytes=0-0')
205+
expect(res.status).toBe(206)
206+
expect(res.headers['content-length']).toBe('1')
207+
expect(res.headers['content-range']).toBe('bytes 0-0/17')
208+
expect(res.text).toBe('T')
209+
})
210+
211+
it('Should return 416 when the range start is beyond the end of the file', async () => {
212+
const res = await request(server).get('/static/plain.txt').set('range', 'bytes=100-200')
213+
expect(res.status).toBe(416)
214+
expect(res.headers['content-range']).toBe('bytes */17')
215+
})
216+
217+
it('Should return 416 when the range start is beyond the file size, even if the window is small', async () => {
218+
const res = await request(server).get('/static/plain.txt').set('range', 'bytes=20-25')
219+
expect(res.status).toBe(416)
220+
expect(res.headers['content-range']).toBe('bytes */17')
221+
})
222+
223+
it('Should return 416 when the range start is after the range end', async () => {
224+
const res = await request(server).get('/static/plain.txt').set('range', 'bytes=10-5')
225+
expect(res.status).toBe(416)
226+
expect(res.headers['content-range']).toBe('bytes */17')
227+
})
228+
229+
it('Should return 416 for a zero-length suffix range', async () => {
230+
const res = await request(server).get('/static/plain.txt').set('range', 'bytes=-0')
231+
expect(res.status).toBe(416)
232+
expect(res.headers['content-range']).toBe('bytes */17')
233+
})
234+
235+
it.each(['bytes=0-1x', 'bytes=x-1', 'bytes=0-1-2', 'bytes=-5-extra'])(
236+
'Should treat a malformed range as the whole file: %s',
237+
async (range) => {
238+
const res = await request(server).get('/static/plain.txt').set('range', range)
239+
expect(res.status).toBe(206)
240+
expect(res.headers['content-range']).toBe('bytes 0-16/17')
241+
expect(res.text).toBe('This is plain.txt')
242+
}
243+
)
244+
245+
it('Should return 416 instead of crashing for a range request on an empty file', async () => {
246+
const res = await request(server).get('/static/foo..bar.txt').set('range', 'bytes=0-0')
247+
expect(res.status).toBe(416)
248+
expect(res.headers['content-range']).toBe('bytes */0')
249+
})
250+
251+
it('Should return 416 instead of crashing for a malformed range on an empty file', async () => {
252+
const res = await request(server).get('/static/foo..bar.txt').set('range', 'hello')
253+
expect(res.status).toBe(416)
254+
expect(res.headers['content-range']).toBe('bytes */0')
255+
})
256+
188257
it('Should handle the `onNotFound` option', async () => {
189258
const res = await request(server).get('/on-not-found/foo.txt')
190259
expect(res.status).toBe(404)

0 commit comments

Comments
 (0)