-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdecide.js
More file actions
195 lines (176 loc) · 7.9 KB
/
Copy pathdecide.js
File metadata and controls
195 lines (176 loc) · 7.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
'use strict'
// The killswitch decision function. Pure: state in, verdict out. No browser
// APIs, no storage, no network, no clock. Everything security-critical lives
// here so it can be tested exhaustively without a browser, and nothing
// outside this file may re-implement an allow/block rule.
//
// Loaded both as an MV2 background script (attaches to globalThis) and by
// node --test (module.exports).
;(function (root) {
const ALLOW = 'allow'
const BLOCK = 'block'
// Reasons are surfaced on the blocked page and in the log, so the user can
// always tell why something was stopped.
const R = {
PROBE: 'probe',
NOT_READY: 'not-ready',
LOOPBACK: 'loopback',
SPECULATIVE: 'speculative',
UNATTRIBUTED: 'unattributed',
UNMANAGED: 'unmanaged',
NO_PROXY: 'no-proxy',
PROXY_DOWN: 'proxy-down',
// Distinct from proxy-down on purpose. Health is never persisted, so
// every browser start begins here; telling those users their proxy
// failed would be false, and it is the most-seen state in daily use.
NOT_VERIFIED: 'proxy-unverified',
MISROUTED: 'misrouted',
ERROR: 'error',
OK: 'ok'
}
const PROBE_MARKER = 'bkh_probe'
const PROBE_URL = 'https://am.i.mullvad.net/json'
/** @param {string} url @returns {string | null} */
function probeToken (url) {
if (typeof url !== 'string') return null
const m = url.match(/[?&]bkh_probe=([0-9a-f]{16,64})(?:&|$)/)
return m ? m[1] : null
}
// Identity check, not a prefix match: a prefix admits /jsonextra and
// /json/.. , and this is the comparison that decides whether a request may
// skip the gate.
/** @param {string} url @returns {boolean} */
function isProbeUrl (url) {
if (typeof url !== 'string') return false
try {
const u = new URL(url)
return u.origin + u.pathname === PROBE_URL
} catch {
return false
}
}
// Addresses that cannot leave this machine and cannot be redirected
// anywhere by anyone: the literal loopback range, plus the one name for it
// Firefox resolves internally. Deliberately narrower than "private
// address" -- 192.168/16 and friends are real networks with real leak
// potential -- and narrower than RFC 6761, which would also admit
// *.localhost: those names only stay on-machine while
// network.dns.offline-localhost holds its default, and a pref this cannot
// read is not something to hang an exemption on. The URL parser has
// already normalised IPv4 spellings (127.1, octal) and IPv6 forms by the
// time hostname is read.
/** @param {string} url @returns {boolean} */
function isLoopbackUrl (url) {
if (typeof url !== 'string') return false
let host
try {
host = new URL(url).hostname
} catch {
return false
}
if (host.endsWith('.')) host = host.slice(0, -1)
if (host === 'localhost') return true
if (host === '[::1]') return true
const m = host.match(/^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
return !!m && m.slice(1).every(n => Number(n) <= 255)
}
// Whether the proxy Firefox reports having carried a request is the one
// the assignment asks for. A refused SOCKS connection is failed over, and
// that failover ends at a direct connection while
// network.proxy.failover_direct is true -- which answers with a perfectly
// good 200 from the user's own address. Reachability alone therefore
// proves nothing about the proxy; this is what proves it. Firefox echoes
// back the ProxyInfo it was given, so compare hostnames the way hostnames
// compare.
/**
* @param {{ host?: string, port?: number } | null | undefined} via
* @param {ContainerConfig | undefined} c
* @returns {boolean}
*/
function probeTraversed (via, c) {
if (!via || !c || typeof via.host !== 'string') return false
return via.host.toLowerCase() === String(c.ip).toLowerCase() && via.port === (c.port || 1080)
}
// Whether a stored assignment can actually be routed. Both listeners must
// agree on this: a config the gate accepts but proxy.onRequest cannot turn
// into a valid ProxyInfo takes Firefox's invalid-proxy path, which is a
// direct connection.
/** @param {ContainerConfig | undefined} c @returns {boolean} */
function usableProxy (c) {
if (!c) return false
return typeof c.ip === 'string'
&& c.ip !== ''
&& Number.isInteger(c.port)
&& c.port >= 1
&& c.port <= 65535
}
/**
* @param {KillswitchState} state
* @param {GateRequest} req
* @returns {Verdict}
*/
function decide (state, req) {
// 0. The health probe must never be blocked; if it were, a container
// marked down could never be observed recovering and the killswitch
// would latch shut for good. Only a token this session issued and
// still holds counts, and only towards the probe endpoint -- the bare
// marker is forgeable by any web page, and a request carrying an
// unrecognised token gets no special treatment anywhere, so forging
// one cannot open the gate or dodge the proxy.
const token = probeToken(req.url)
if (token && state.probeTokens.has(token) && isProbeUrl(req.url)) {
return v(ALLOW, R.PROBE)
}
// 1. Until storage is hydrated every container would read as unmanaged
// and go direct -- a window that lands exactly on session restore,
// when a pile of tabs reloads at once. Fail closed through it.
if (!state.ready) return v(BLOCK, R.NOT_READY)
// 2. Loopback, once the user has opted in. Sits above the speculative
// and unattributed rules on purpose: whoever issued this request, its
// destination is this machine, and blocking it breaks every sign-in
// flow that hands a token to an app listening on a local port. After
// the ready check, so the toggle is only honoured once it has
// actually been read.
if (state.allowLocal === true && isLoopbackUrl(req.url)) return v(ALLOW, R.LOOPBACK)
// 3. Speculative connections carry unreliable tab information, so their
// cookieStoreId cannot be trusted. Strict mode refuses to route them
// on a guess.
if (req.type === 'speculative' && state.strict) return v(BLOCK, R.SPECULATIVE)
const id = req.cookieStoreId
const known = typeof id === 'string' && id !== ''
const c = known ? state.containers[id] : undefined
// 4. A container we manage. Deliberately checked before the strict-mode
// branches, so relaxing strict can never re-open a container whose
// proxy is down; strict governs only the unattributable cases below.
if (c) {
// Managed but misconfigured -- never fall through to direct.
if (!usableProxy(c)) return v(BLOCK, R.NO_PROXY)
// Anything other than a confirmed-up proxy blocks, including
// 'unknown': a freshly started browser must not pass traffic over an
// unverified proxy.
if (c.health !== 'up') {
if (c.health === 'misrouted') return v(BLOCK, R.MISROUTED)
if (c.health === 'unknown') return v(BLOCK, R.NOT_VERIFIED)
return v(BLOCK, R.PROXY_DOWN)
}
return v(ALLOW, R.OK)
}
// 5. No container identity at all. It cannot be proven that this did not
// originate in a managed container, so strict mode refuses it rather
// than let it take the default route.
if (!known) return state.strict ? v(BLOCK, R.UNATTRIBUTED) : v(ALLOW, R.UNATTRIBUTED)
// 6. A container we do not manage. Not our business -- this is what
// keeps the extension inert for ordinary browsing.
return v(ALLOW, R.UNMANAGED)
}
/** @param {'allow' | 'block'} verdict @param {string} reason @returns {Verdict} */
function v (verdict, reason) {
return { verdict, reason }
}
const api = { decide, probeToken, isProbeUrl, isLoopbackUrl, probeTraversed, usableProxy, R, PROBE_MARKER, PROBE_URL, ALLOW, BLOCK }
if (typeof module !== 'undefined' && module.exports) {
module.exports = api
} else {
Object.assign(root, api)
}
})(globalThis)