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

Patch for v5.6.1 #416

Open
wants to merge 2 commits into
base: origin-v5.6.1-1724817295
Choose a base branch
from
Open
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
7 changes: 6 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ function makeDispatcher (fn) {
throw new InvalidArgumentError('invalid opts.path')
}

url = new URL(opts.path, util.parseOrigin(url))
let path = opts.path
if (!opts.path.startsWith('/')) {
path = `/${path}`
}

url = new URL(util.parseOrigin(url).origin + path)
} else {
if (!opts) {
opts = typeof url === 'object' ? url : {}
Expand Down
3 changes: 2 additions & 1 deletion lib/core/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,8 @@ function processHeader (request, key, val) {
} else if (
request.contentType === null &&
key.length === 12 &&
key.toLowerCase() === 'content-type'
key.toLowerCase() === 'content-type' &&
headerCharRegex.exec(val) === null
) {
request.contentType = val
request.headers += `${key}: ${val}\r\n`
Expand Down
309 changes: 309 additions & 0 deletions lib/core/request.js.orig
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
'use strict'

const {
InvalidArgumentError,
NotSupportedError
} = require('./errors')
const assert = require('assert')
const util = require('./util')

const kHandler = Symbol('handler')

const channels = {}

let extractBody

const nodeVersion = process.versions.node.split('.')
const nodeMajor = Number(nodeVersion[0])
const nodeMinor = Number(nodeVersion[1])

try {
const diagnosticsChannel = require('diagnostics_channel')
channels.create = diagnosticsChannel.channel('undici:request:create')
channels.bodySent = diagnosticsChannel.channel('undici:request:bodySent')
channels.headers = diagnosticsChannel.channel('undici:request:headers')
channels.trailers = diagnosticsChannel.channel('undici:request:trailers')
channels.error = diagnosticsChannel.channel('undici:request:error')
} catch {
channels.create = { hasSubscribers: false }
channels.bodySent = { hasSubscribers: false }
channels.headers = { hasSubscribers: false }
channels.trailers = { hasSubscribers: false }
channels.error = { hasSubscribers: false }
}

class Request {
constructor (origin, {
path,
method,
body,
headers,
query,
idempotent,
blocking,
upgrade,
headersTimeout,
bodyTimeout,
throwOnError
}, handler) {
if (typeof path !== 'string') {
throw new InvalidArgumentError('path must be a string')
} else if (
path[0] !== '/' &&
!(path.startsWith('http://') || path.startsWith('https://')) &&
method !== 'CONNECT'
) {
throw new InvalidArgumentError('path must be an absolute URL or start with a slash')
}

if (typeof method !== 'string') {
throw new InvalidArgumentError('method must be a string')
}

if (upgrade && typeof upgrade !== 'string') {
throw new InvalidArgumentError('upgrade must be a string')
}

if (headersTimeout != null && (!Number.isFinite(headersTimeout) || headersTimeout < 0)) {
throw new InvalidArgumentError('invalid headersTimeout')
}

if (bodyTimeout != null && (!Number.isFinite(bodyTimeout) || bodyTimeout < 0)) {
throw new InvalidArgumentError('invalid bodyTimeout')
}

this.headersTimeout = headersTimeout

this.bodyTimeout = bodyTimeout

this.throwOnError = throwOnError === true

this.method = method

if (body == null) {
this.body = null
} else if (util.isStream(body)) {
this.body = body
} else if (util.isBuffer(body)) {
this.body = body.byteLength ? body : null
} else if (ArrayBuffer.isView(body)) {
this.body = body.buffer.byteLength ? Buffer.from(body.buffer, body.byteOffset, body.byteLength) : null
} else if (body instanceof ArrayBuffer) {
this.body = body.byteLength ? Buffer.from(body) : null
} else if (typeof body === 'string') {
this.body = body.length ? Buffer.from(body) : null
} else if (util.isFormDataLike(body) || util.isIterable(body) || util.isBlobLike(body)) {
this.body = body
} else {
throw new InvalidArgumentError('body must be a string, a Buffer, a Readable stream, an iterable, or an async iterable')
}

this.completed = false

this.aborted = false

this.upgrade = upgrade || null

this.path = query ? util.buildURL(path, query) : path

this.origin = origin

this.idempotent = idempotent == null
? method === 'HEAD' || method === 'GET'
: idempotent

this.blocking = blocking == null ? false : blocking

this.host = null

this.contentLength = null

this.contentType = null

this.headers = ''

if (Array.isArray(headers)) {
if (headers.length % 2 !== 0) {
throw new InvalidArgumentError('headers array must be even')
}
for (let i = 0; i < headers.length; i += 2) {
processHeader(this, headers[i], headers[i + 1])
}
} else if (headers && typeof headers === 'object') {
const keys = Object.keys(headers)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
processHeader(this, key, headers[key])
}
} else if (headers != null) {
throw new InvalidArgumentError('headers must be an object or an array')
}

if (util.isFormDataLike(this.body)) {
if (nodeMajor < 16 || (nodeMajor === 16 && nodeMinor < 5)) {
throw new InvalidArgumentError('Form-Data bodies are only supported in node v16.5 and newer.')
}

if (!extractBody) {
extractBody = require('../fetch/body.js').extractBody
}

const [bodyStream, contentType] = extractBody(body)
if (this.contentType == null) {
this.contentType = contentType
this.headers += `content-type: ${contentType}\r\n`
}
this.body = bodyStream.stream
} else if (util.isBlobLike(body) && this.contentType == null && body.type) {
this.contentType = body.type
this.headers += `content-type: ${body.type}\r\n`
}

util.validateHandler(handler, method, upgrade)

this.servername = util.getServerName(this.host)

this[kHandler] = handler

if (channels.create.hasSubscribers) {
channels.create.publish({ request: this })
}
}

onBodySent (chunk) {
if (this[kHandler].onBodySent) {
try {
this[kHandler].onBodySent(chunk)
} catch (err) {
this.onError(err)
}
}
}

onRequestSent () {
if (channels.bodySent.hasSubscribers) {
channels.bodySent.publish({ request: this })
}
}

onConnect (abort) {
assert(!this.aborted)
assert(!this.completed)

return this[kHandler].onConnect(abort)
}

onHeaders (statusCode, headers, resume, statusText) {
assert(!this.aborted)
assert(!this.completed)

if (channels.headers.hasSubscribers) {
channels.headers.publish({ request: this, response: { statusCode, headers, statusText } })
}

return this[kHandler].onHeaders(statusCode, headers, resume, statusText)
}

onData (chunk) {
assert(!this.aborted)
assert(!this.completed)

return this[kHandler].onData(chunk)
}

onUpgrade (statusCode, headers, socket) {
assert(!this.aborted)
assert(!this.completed)

return this[kHandler].onUpgrade(statusCode, headers, socket)
}

onComplete (trailers) {
assert(!this.aborted)

this.completed = true
if (channels.trailers.hasSubscribers) {
channels.trailers.publish({ request: this, trailers })
}
return this[kHandler].onComplete(trailers)
}

onError (error) {
if (channels.error.hasSubscribers) {
channels.error.publish({ request: this, error })
}

if (this.aborted) {
return
}
this.aborted = true
return this[kHandler].onError(error)
}

addHeader (key, value) {
processHeader(this, key, value)
return this
}
}

function processHeader (request, key, val) {
if (val && typeof val === 'object') {
throw new InvalidArgumentError(`invalid ${key} header`)
} else if (val === undefined) {
return
}

if (
request.host === null &&
key.length === 4 &&
key.toLowerCase() === 'host'
) {
// Consumed by Client
request.host = val
} else if (
request.contentLength === null &&
key.length === 14 &&
key.toLowerCase() === 'content-length'
) {
request.contentLength = parseInt(val, 10)
if (!Number.isFinite(request.contentLength)) {
throw new InvalidArgumentError('invalid content-length header')
}
} else if (
request.contentType === null &&
key.length === 12 &&
key.toLowerCase() === 'content-type'
) {
request.contentType = val
request.headers += `${key}: ${val}\r\n`
} else if (
key.length === 17 &&
key.toLowerCase() === 'transfer-encoding'
) {
throw new InvalidArgumentError('invalid transfer-encoding header')
} else if (
key.length === 10 &&
key.toLowerCase() === 'connection'
) {
throw new InvalidArgumentError('invalid connection header')
} else if (
key.length === 10 &&
key.toLowerCase() === 'keep-alive'
) {
throw new InvalidArgumentError('invalid keep-alive header')
} else if (
key.length === 7 &&
key.toLowerCase() === 'upgrade'
) {
throw new InvalidArgumentError('invalid upgrade header')
} else if (
key.length === 6 &&
key.toLowerCase() === 'expect'
) {
throw new NotSupportedError('expect header not supported')
} else {
request.headers += `${key}: ${val}\r\n`
}
}

module.exports = Request
17 changes: 14 additions & 3 deletions lib/core/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,25 @@ function parseURL (url) {
const port = url.port != null
? url.port
: (url.protocol === 'https:' ? 443 : 80)
const origin = url.origin != null
let origin = url.origin != null
? url.origin
: `${url.protocol}//${url.hostname}:${port}`
const path = url.path != null
let path = url.path != null
? url.path
: `${url.pathname || ''}${url.search || ''}`

url = new URL(path, origin)
if (origin.endsWith('/')) {
origin = origin.substring(0, origin.length - 1)
}

if (path && !path.startsWith('/')) {
path = `/${path}`
}
// new URL(path, origin) is unsafe when `path` contains an absolute URL
// From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
// If first parameter is a relative URL, second param is required, and will be used as the base URL.
// If first parameter is an absolute URL, a given second param will be ignored.
url = new URL(origin + path)
}

return url
Expand Down
32 changes: 32 additions & 0 deletions test/request-crlf.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict'

const { createServer } = require('http')
const { test } = require('tap')
const { request, errors } = require('..')

test('should validate content-type CRLF Injection', (t) => {
t.plan(2)

const server = createServer((req, res) => {
t.fail('should not receive any request')
res.statusCode = 200
res.end('hello')
})

t.teardown(server.close.bind(server))

server.listen(0, async () => {
try {
await request(`http://localhost:${server.address().port}`, {
method: 'GET',
headers: {
'content-type': 'application/json\r\n\r\nGET /foo2 HTTP/1.1'
},
})
t.fail('request should fail')
} catch (e) {
t.type(e, errors.InvalidArgumentError)
t.equal(e.message, 'invalid content-type header')
}
})
})
Loading