• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Kikobeats / parse-proxy-uri / 30887646073

04 Aug 2026 07:23AM UTC coverage: 95.455%. First build
30887646073

Pull #25

github

web-flow
Merge d0454846e into 7810e853e
Pull Request #25: fix: harden proxy URI parsing

37 of 41 branches covered (90.24%)

Branch coverage included in aggregate %.

89 of 94 new or added lines in 1 file covered. (94.68%)

152 of 157 relevant lines covered (96.82%)

16.14 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.45
/src/index.js
1
'use strict'
1✔
2

1✔
3
const { isIP } = require('net')
1✔
4

1✔
5
const usernameGetter = Object.getOwnPropertyDescriptor(
1✔
6
  URL.prototype,
1✔
7
  'username'
1✔
8
).get
1✔
9
const passwordGetter = Object.getOwnPropertyDescriptor(
1✔
10
  URL.prototype,
1✔
11
  'password'
1✔
12
).get
1✔
13

1✔
14
class ParseProxyError extends Error {
1✔
15
  constructor (props) {
1✔
16
    super()
24✔
17
    this.name = 'ParseProxyError'
24✔
18
    Object.assign(this, props)
24✔
19
    this.description = this.message
24✔
20
    this.message = `${this.code}, ${this.description}`
24✔
21
  }
24✔
22
}
1✔
23

1✔
24
// Pull the hostname token out of the original URI before WHATWG normalizes it.
1✔
25
// Special schemes rewrite decimal/octal/hex/short IPv4 forms (e.g. 2130706433 →
1✔
26
// 127.0.0.1), which would silently misroute the proxy if we trusted hostname.
1✔
27
const rawHostname = proxy => {
1✔
28
  let authority = proxy.slice(proxy.indexOf('://') + 3)
8✔
29
  const pathIndex = authority.search(/[/?#]/)
8✔
30
  const atIndex = authority.indexOf('@')
8✔
31

8✔
32
  if (atIndex !== -1 && (pathIndex === -1 || atIndex < pathIndex)) {
8!
33
    authority = authority.slice(atIndex + 1)
2✔
34
  }
2✔
35

8✔
36
  if (authority.startsWith('[')) {
8!
NEW
37
    const end = authority.indexOf(']')
×
NEW
38
    return end === -1 ? authority : authority.slice(0, end + 1)
×
NEW
39
  }
×
40

8✔
41
  const hostEnd = authority.search(/[:/?#]/)
8✔
42
  return hostEnd === -1 ? authority : authority.slice(0, hostEnd)
8!
43
}
8✔
44

1✔
45
class ProxyURL extends URL {
1✔
46
  constructor (proxy) {
1✔
47
    // Coerce like URL (String objects, etc.), then require an explicit `://`
40✔
48
    // authority. WHATWG treats `host:port` as a custom scheme (empty host) and
40✔
49
    // `http:8080` as the IPv4 integer host 0.0.31.144.
40✔
50
    proxy = String(proxy)
40✔
51
    if (!proxy.includes('://')) {
40✔
52
      throw new TypeError('Invalid proxy')
5✔
53
    }
5✔
54

35✔
55
    super(proxy)
35✔
56

35✔
57
    if (!this.hostname) {
40✔
58
      throw new TypeError('Invalid proxy')
1✔
59
    }
1✔
60

32✔
61
    // Proxy URIs are authority-only. A path/query/hash usually means reserved
32✔
62
    // characters in userinfo were not percent-encoded, which WHATWG then
32✔
63
    // treats as the start of the path and drops the real host (e.g.
32✔
64
    // http://us/er:pass@proxy.example:8080 → host "us").
32✔
65
    if (
32✔
66
      (this.pathname !== '' && this.pathname !== '/') ||
40✔
67
      this.search !== '' ||
40✔
68
      this.hash !== ''
26✔
69
    ) {
40✔
70
      throw new TypeError('Invalid proxy')
8✔
71
    }
8✔
72

24✔
73
    // Compare after percent-decoding the raw host token: WHATWG decodes
24✔
74
    // sequences like `%2E` before IPv4 parsing, so `127%2E0%2E0%2E1` is a
24✔
75
    // canonical dotted-decimal host, not a rewrite.
24✔
76
    if (isIP(this.hostname) === 4) {
40✔
77
      let raw
8✔
78
      try {
8✔
79
        raw = decodeURIComponent(rawHostname(proxy))
8✔
80
      } catch (_) {
8!
NEW
81
        throw new TypeError('Invalid proxy')
×
NEW
82
      }
×
83
      if (raw !== this.hostname) {
8✔
84
        throw new TypeError('Invalid proxy')
5✔
85
      }
5✔
86
    }
8✔
87

19✔
88
    // Fail fast on malformed percent-escapes so parseProxy still returns
19✔
89
    // INVALID_PROXY instead of a late URIError from the getters below.
19✔
90
    decodeURIComponent(usernameGetter.call(this))
19✔
91
    decodeURIComponent(passwordGetter.call(this))
19✔
92

19✔
93
    // Expose decoded credentials, but always read them from the underlying URL
19✔
94
    // slots. Capturing them once (and freezing the values) desyncs from later
19✔
95
    // href/host mutations: toString() would keep shipping the old userinfo to
19✔
96
    // a new host, and username/password/auth would disagree with href.
19✔
97
    Object.defineProperty(this, 'username', {
19✔
98
      enumerable: true,
19✔
99
      get: () => decodeURIComponent(usernameGetter.call(this))
19✔
100
    })
19✔
101

19✔
102
    Object.defineProperty(this, 'password', {
19✔
103
      enumerable: true,
19✔
104
      get: () => decodeURIComponent(passwordGetter.call(this))
19✔
105
    })
19✔
106

19✔
107
    // Match toString(): omit credentials entirely when both are empty so
19✔
108
    // truthy checks on `auth` do not force a blank Proxy-Authorization.
19✔
109
    Object.defineProperty(this, 'auth', {
19✔
110
      enumerable: true,
19✔
111
      get: () =>
19✔
112
        this.username || this.password
7✔
113
          ? `${this.username}:${this.password}`
7✔
114
          : ''
7✔
115
    })
19✔
116

19✔
117
    Object.defineProperty(this, '__parsed__', {
19✔
118
      enumerable: false,
19✔
119
      writable: false,
19✔
120
      value: true
19✔
121
    })
19✔
122

19✔
123
    Object.defineProperty(this, 'toString', {
19✔
124
      enumerable: false,
19✔
125
      writable: false,
19✔
126
      value: () => {
19✔
127
        // Use the live percent-encoded userinfo so reserved characters round-
16✔
128
        // trip and mutations of host/href stay consistent.
16✔
129
        const encodedUsername = usernameGetter.call(this)
16✔
130
        const encodedPassword = passwordGetter.call(this)
16✔
131
        if (!encodedUsername && !encodedPassword) {
16✔
132
          return `${this.protocol}//${this.host}`
5✔
133
        }
5✔
134
        const userinfo = encodedPassword
11✔
135
          ? `${encodedUsername}:${encodedPassword}`
16✔
136
          : encodedUsername
16✔
137
        return `${this.protocol}//${userinfo}@${this.host}`
16✔
138
      }
16✔
139
    })
19✔
140
  }
40✔
141
}
1✔
142

1✔
143
module.exports = proxy => {
1✔
144
  if (!proxy) return undefined
44✔
145
  if (typeof proxy === 'object' && proxy.__parsed__) return proxy
44✔
146

40✔
147
  try {
40✔
148
    return new ProxyURL(proxy)
40✔
149
  } catch (_) {
44✔
150
    throw new ParseProxyError({
24✔
151
      message: `The value \`${proxy}\` can't be parsed as proxy`,
24✔
152
      code: 'INVALID_PROXY'
24✔
153
    })
24✔
154
  }
24✔
155
}
44✔
156

1✔
157
module.exports.ProxyURL = ProxyURL
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc