Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Handle data: URIs more consistently #19

Merged
merged 1 commit into from
Mar 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 33 additions & 15 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,41 @@ const { getNodeRequestOptions } = Request
const FetchError = require('./fetch-error.js')
const AbortError = require('./abort-error.js')

const fetch = (url, opts) => {
// XXX this should really be split up and unit-ized for easier testing
// and better DRY implementation of data/http request aborting
Comment on lines +18 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯

const fetch = async (url, opts) => {
if (/^data:/.test(url)) {
const request = new Request(url, opts)
try {
const split = url.split(',')
const data = Buffer.from(split[1], 'base64')
const type = split[0].match(/^data:(.*);base64$/)[1]
return Promise.resolve(new Response(data, {
headers: {
'Content-Type': type,
'Content-Length': data.length,
},
}))
} catch (er) {
return Promise.reject(new FetchError(`[${request.method}] ${
request.url} invalid URL, ${er.message}`, 'system', er))
}
// delay 1 promise tick so that the consumer can abort right away
return Promise.resolve().then(() => new Promise((resolve, reject) => {
let type, data
try {
const { pathname, search } = new URL(url)
const split = pathname.split(',')
if (split.length < 2) {
throw new Error('invalid data: URI')
}
const mime = split.shift()
const base64 = /;base64$/.test(mime)
type = base64 ? mime.slice(0, -1 * ';base64'.length) : mime
const rawData = decodeURIComponent(split.join(',') + search)
data = base64 ? Buffer.from(rawData, 'base64') : Buffer.from(rawData)
} catch (er) {
return reject(new FetchError(`[${request.method}] ${
request.url} invalid URL, ${er.message}`, 'system', er))
}

const { signal } = request
if (signal && signal.aborted) {
return reject(new AbortError('The user aborted a request.'))
}

const headers = { 'Content-Length': data.length }
if (type) {
headers['Content-Type'] = type
}
return resolve(new Response(data, { headers }))
}))
}

return new Promise((resolve, reject) => {
Expand Down
57 changes: 56 additions & 1 deletion test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1872,12 +1872,67 @@ t.test('data uri', t => {

t.test('reject invalid data uri', t =>
t.rejects(fetch(invalidDataUrl), {
message: 'invalid URL',
message: 'invalid data: URI',
}))

t.test('data uri not base64 encoded', t =>
fetch('data:text/plain,hello, world!').then(r => {
t.equal(r.status, 200)
t.equal(r.headers.get('Content-Type'), 'text/plain')
return r.buffer().then(b => t.equal(b.toString(), 'hello, world!'))
}))

t.test('data uri with no type specified', t =>
fetch('data:,hello,%20world!').then(r => {
t.equal(r.status, 200)
t.equal(r.headers.get('Content-Type'), null)
return r.buffer().then(b => t.equal(b.toString(), 'hello, world!'))
}))

t.test('search included, hash not included', t =>
fetch('data:,hello?with=search#no%20hash').then(r => {
t.equal(r.status, 200)
t.equal(r.headers.get('Content-Type'), null)
return r.buffer().then(b => t.equal(b.toString(), 'hello?with=search'))
}))

t.end()
})

t.test('aborting data uris', t => {
const controllers = [AbortController, AbortController2]
t.plan(controllers.length)
const url = 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=='
controllers.forEach((Controller, idx) => {
t.test(`controller ${idx}`, async t => {
t.test('pre-abort', async t => {
const controller = new Controller()
controller.abort()
t.rejects(fetch(url, { signal: controller.signal }), {
message: 'The user aborted a request.',
})
})

t.test('post-abort', async t => {
const controller = new Controller()
t.rejects(fetch(url, { signal: controller.signal }), {
message: 'The user aborted a request.',
})
controller.abort()
})

t.test('cannot abort after first tick', t => {
const controller = new Controller()
t.resolves(fetch(url, { signal: controller.signal }))
Promise.resolve().then(() => {
controller.abort()
t.end()
})
})
})
})
})

t.test('redirect changes host header', t =>
fetch(`http://127.0.0.1:${local.port}/host-redirect`, {
redirect: 'follow',
Expand Down