1'use strict' 2 3const util = require('../core/util') 4const { kBodyUsed } = require('../core/symbols') 5const assert = require('assert') 6const { InvalidArgumentError } = require('../core/errors') 7const EE = require('events') 8 9const redirectableStatusCodes = [300, 301, 302, 303, 307, 308] 10 11const kBody = Symbol('body') 12 13class BodyAsyncIterable { 14 constructor (body) { 15 this[kBody] = body 16 this[kBodyUsed] = false 17 } 18 19 async * [Symbol.asyncIterator] () { 20 assert(!this[kBodyUsed], 'disturbed') 21 this[kBodyUsed] = true 22 yield * this[kBody] 23 } 24} 25 26class RedirectHandler { 27 constructor (dispatch, maxRedirections, opts, handler) { 28 if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) { 29 throw new InvalidArgumentError('maxRedirections must be a positive number') 30 } 31 32 util.validateHandler(handler, opts.method, opts.upgrade) 33 34 this.dispatch = dispatch 35 this.location = null 36 this.abort = null 37 this.opts = { ...opts, maxRedirections: 0 } // opts must be a copy 38 this.maxRedirections = maxRedirections 39 this.handler = handler 40 this.history = [] 41 42 if (util.isStream(this.opts.body)) { 43 // TODO (fix): Provide some way for the user to cache the file to e.g. /tmp 44 // so that it can be dispatched again? 45 // TODO (fix): Do we need 100-expect support to provide a way to do this properly? 46 if (util.bodyLength(this.opts.body) === 0) { 47 this.opts.body 48 .on('data', function () { 49 assert(false) 50 }) 51 } 52 53 if (typeof this.opts.body.readableDidRead !== 'boolean') { 54 this.opts.body[kBodyUsed] = false 55 EE.prototype.on.call(this.opts.body, 'data', function () { 56 this[kBodyUsed] = true 57 }) 58 } 59 } else if (this.opts.body && typeof this.opts.body.pipeTo === 'function') { 60 // TODO (fix): We can't access ReadableStream internal state 61 // to determine whether or not it has been disturbed. This is just 62 // a workaround. 63 this.opts.body = new BodyAsyncIterable(this.opts.body) 64 } else if ( 65 this.opts.body && 66 typeof this.opts.body !== 'string' && 67 !ArrayBuffer.isView(this.opts.body) && 68 util.isIterable(this.opts.body) 69 ) { 70 // TODO: Should we allow re-using iterable if !this.opts.idempotent 71 // or through some other flag? 72 this.opts.body = new BodyAsyncIterable(this.opts.body) 73 } 74 } 75 76 onConnect (abort) { 77 this.abort = abort 78 this.handler.onConnect(abort, { history: this.history }) 79 } 80 81 onUpgrade (statusCode, headers, socket) { 82 this.handler.onUpgrade(statusCode, headers, socket) 83 } 84 85 onError (error) { 86 this.handler.onError(error) 87 } 88 89 onHeaders (statusCode, headers, resume, statusText) { 90 this.location = this.history.length >= this.maxRedirections || util.isDisturbed(this.opts.body) 91 ? null 92 : parseLocation(statusCode, headers) 93 94 if (this.opts.origin) { 95 this.history.push(new URL(this.opts.path, this.opts.origin)) 96 } 97 98 if (!this.location) { 99 return this.handler.onHeaders(statusCode, headers, resume, statusText) 100 } 101 102 const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin))) 103 const path = search ? `${pathname}${search}` : pathname 104 105 // Remove headers referring to the original URL. 106 // By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers. 107 // https://tools.ietf.org/html/rfc7231#section-6.4 108 this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin) 109 this.opts.path = path 110 this.opts.origin = origin 111 this.opts.maxRedirections = 0 112 this.opts.query = null 113 114 // https://tools.ietf.org/html/rfc7231#section-6.4.4 115 // In case of HTTP 303, always replace method to be either HEAD or GET 116 if (statusCode === 303 && this.opts.method !== 'HEAD') { 117 this.opts.method = 'GET' 118 this.opts.body = null 119 } 120 } 121 122 onData (chunk) { 123 if (this.location) { 124 /* 125 https://tools.ietf.org/html/rfc7231#section-6.4 126 127 TLDR: undici always ignores 3xx response bodies. 128 129 Redirection is used to serve the requested resource from another URL, so it is assumes that 130 no body is generated (and thus can be ignored). Even though generating a body is not prohibited. 131 132 For status 301, 302, 303, 307 and 308 (the latter from RFC 7238), the specs mention that the body usually 133 (which means it's optional and not mandated) contain just an hyperlink to the value of 134 the Location response header, so the body can be ignored safely. 135 136 For status 300, which is "Multiple Choices", the spec mentions both generating a Location 137 response header AND a response body with the other possible location to follow. 138 Since the spec explicitily chooses not to specify a format for such body and leave it to 139 servers and browsers implementors, we ignore the body as there is no specified way to eventually parse it. 140 */ 141 } else { 142 return this.handler.onData(chunk) 143 } 144 } 145 146 onComplete (trailers) { 147 if (this.location) { 148 /* 149 https://tools.ietf.org/html/rfc7231#section-6.4 150 151 TLDR: undici always ignores 3xx response trailers as they are not expected in case of redirections 152 and neither are useful if present. 153 154 See comment on onData method above for more detailed informations. 155 */ 156 157 this.location = null 158 this.abort = null 159 160 this.dispatch(this.opts, this) 161 } else { 162 this.handler.onComplete(trailers) 163 } 164 } 165 166 onBodySent (chunk) { 167 if (this.handler.onBodySent) { 168 this.handler.onBodySent(chunk) 169 } 170 } 171} 172 173function parseLocation (statusCode, headers) { 174 if (redirectableStatusCodes.indexOf(statusCode) === -1) { 175 return null 176 } 177 178 for (let i = 0; i < headers.length; i += 2) { 179 if (headers[i].toString().toLowerCase() === 'location') { 180 return headers[i + 1] 181 } 182 } 183} 184 185// https://tools.ietf.org/html/rfc7231#section-6.4.4 186function shouldRemoveHeader (header, removeContent, unknownOrigin) { 187 if (header.length === 4) { 188 return util.headerNameToString(header) === 'host' 189 } 190 if (removeContent && util.headerNameToString(header).startsWith('content-')) { 191 return true 192 } 193 if (unknownOrigin && (header.length === 13 || header.length === 6 || header.length === 19)) { 194 const name = util.headerNameToString(header) 195 return name === 'authorization' || name === 'cookie' || name === 'proxy-authorization' 196 } 197 return false 198} 199 200// https://tools.ietf.org/html/rfc7231#section-6.4 201function cleanRequestHeaders (headers, removeContent, unknownOrigin) { 202 const ret = [] 203 if (Array.isArray(headers)) { 204 for (let i = 0; i < headers.length; i += 2) { 205 if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin)) { 206 ret.push(headers[i], headers[i + 1]) 207 } 208 } 209 } else if (headers && typeof headers === 'object') { 210 for (const key of Object.keys(headers)) { 211 if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) { 212 ret.push(key, headers[key]) 213 } 214 } 215 } else { 216 assert(headers == null, 'headers must be an object or an array') 217 } 218 return ret 219} 220 221module.exports = RedirectHandler 222