• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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