-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathadmin-auth.ts
More file actions
280 lines (264 loc) · 12.9 KB
/
Copy pathadmin-auth.ts
File metadata and controls
280 lines (264 loc) · 12.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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
/**
* Who may reach the administrative surface.
*
* Until this existed, nothing could. Every route that writes policy, mints
* credentials or reads the governance record was open to anyone who could
* reach the port, and `WARDEN_HOST` defaults to `0.0.0.0` so that teammates
* can reach the gateway. The comment above the CORS block said it plainly —
* "the admin API has no authentication, so the browser's origin check was the
* only thing standing in the way" — which is a statement about cross-origin
* pages and not about the employee who simply types the URL.
*
* That is a hole straight through the product's central claim. Warden exists so
* an employee cannot route around the policy. An employee who can reach the
* console's port could:
*
* DELETE /api/policy/rules/r-payroll delete the rule judging them
* POST /api/policy/ratify install a rule of their own
* POST /api/roles create a role, then be exempt
* POST /api/people/:id/key rotate anyone's key and read it back
* GET /install/:employeeId read anyone's key without rotating
* GET /api/audit read every prompt hash and verdict
* GET /api/events watch decisions stream by, live
*
* The guard could be flawless and none of it would matter.
*
* ## What counts as an administrator
*
* Two answers, and the second is the one that does the work.
*
* **A key belonging to an exempt role.** `exemptRoles` already lives inside the
* ratified policy, inside the version hash, because — as `types.ts` puts it —
* "who is exempt" is the most security-relevant sentence in the spec. The
* people it names are the ones who author the rules rather than live under
* them, which is the same set that may edit them. Reusing it means there is no
* second, unaudited notion of "admin" to keep in sync.
*
* **The loopback interface.** A request from the machine the gateway runs on is
* treated as the administrator, because on that machine the API is not the
* weakest way in: anyone with a shell there can edit `data/policies.json`
* directly and skip every check in this file. Refusing them at the HTTP layer
* would buy nothing and would break the console, which is served by this same
* process and holds no credential.
*
* That trust is exactly as strong as the deployment. Where the gateway runs on
* a machine the employee controls, they were already the administrator of their
* own policy file and no HTTP check changes that. Where it runs on a shared
* host that employees can log into, loopback stops meaning "the admin" — set
* `WARDEN_ADMIN_REQUIRE_KEY=1` and every administrative call must present an
* exempt key, from anywhere.
*/
import type { NextFunction, Request, Response } from 'express';
import { browserAllowsLocalTrust } from './browser-trust.js';
import { actorForCredential } from '../policy/people.js';
import { isExempt, loadPolicy } from '../policy/store.js';
import { callerKey, persistentLimiter } from './rate-limit.js';
/** The shape `installToken` produces: 128 bits, hex. */
const INSTALL_TOKEN = /^[0-9a-f]{32}$/;
/**
* Routes an employee is supposed to call.
*
* The list is of what stays open rather than of what is closed, so a route
* added later is protected by default. Getting that the wrong way round is how
* this kind of check rots: every new endpoint would be public until somebody
* remembered to name it.
*/
const EMPLOYEE_PATHS: ReadonlySet<string> = new Set([
'/api/guard/check',
'/api/guard/rewrite',
'/api/guard/appeal',
// Answers "does this gateway know my key", for the key that asked and about
// nobody else. `warden-hook --status` is built on it. It reads a credential
// the caller already holds and returns what its owner already knows, which is
// why it belongs on this side of the line.
'/api/identity',
// The machine saying what it found in its own configuration. It has to be
// employee-callable because the employee's machine is the only thing that can
// see it — the gateway cannot read somebody's home directory. It writes only
// under the id of the key that sent it, so a report is a statement about
// yourself and cannot be one about anybody else.
'/api/devices/report'
]);
/**
* The path as Express will route it, not as it was typed.
*
* **This function is the whole check.** Express matches routes
* case-insensitively and non-strictly unless told otherwise, and this server
* does not tell it otherwise. So `GET /API/audit` reaches the handler
* registered for `/api/audit` — and a case-sensitive `startsWith('/api/')` here
* returned false for it, decided the path was not administrative, and waved it
* through. Measured against a running server before this existed:
* `/api/audit` refused with 403 and `/API/audit` answered 200 with the log.
*
* A guard that normalises differently from the router it guards is not a guard.
* Anything added here has to be checked against what Express actually matches,
* which is why the two normalisations applied below are the two Express
* applies: case-folding, and a tolerated trailing slash. Where a route resolves
* something case-sensitively of its own — the install token does — the check
* for it reads the unfolded path, so that it agrees with that route too.
*/
function withoutTrailingSlash(path: string): string {
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
}
/** Does this path require an administrator? */
export function needsAdmin(rawPath: string): boolean {
const raw = withoutTrailingSlash(rawPath);
const path = raw.toLowerCase();
// Hands out an employee's API key in a shell script, so the URL is itself a
// credential. It cannot simply be closed: the employee runs it from their own
// machine before they have a key, which is what it is for. So the address
// carries the secret — an install token, unguessable and derived from the key
// it delivers — and that form is public. Addressed by employee id, where the
// id is somebody's first name, it stays administrative.
//
// Matched against the path as sent rather than the folded one, which is the
// opposite of the rule below it and for the same reason: agreement with what
// resolves the request. `installToken` mints lower-case hex and
// `findByInstallToken` accepts nothing else, so folding here would classify a
// token this server could never have issued as public and then hand it to a
// route that refuses it. Both answers are safe; only one of them is the same
// answer.
if (path.startsWith('/install/')) return !INSTALL_TOKEN.test(raw.slice('/install/'.length));
if (path === '/install') return true;
if (!path.startsWith('/api/') && path !== '/api') return false;
return !EMPLOYEE_PATHS.has(path);
}
/** Loopback trust, unless the deployment has switched it off. */
const REQUIRE_KEY = process.env['WARDEN_ADMIN_REQUIRE_KEY'] === '1';
/**
* Is this request from the machine the gateway runs on?
*
* Read from the socket, never from a header. `X-Forwarded-For` is written by
* whoever is in front of the server — including, behind no proxy at all, the
* caller — so trusting it here would turn loopback recognition into a header
* an attacker sets. Express only populates `req.ips` when `trust proxy` is
* enabled, and this server deliberately does not enable it.
*
* IPv6-mapped IPv4 (`::ffff:127.0.0.1`) is what a dual-stack listener actually
* reports for a v4 loopback connection, so it has to be matched too — and the
* whole 127/8 block, since `127.0.0.2` is just as local as `127.0.0.1`.
*/
export function isLoopback(req: Request): boolean {
// A proxy in front of the gateway connects to it from the same machine, so
// everything arriving through one reports a loopback socket. That is how a
// tunnel works — `cloudflared`, a Tailscale Funnel, ngrok, nginx — and it
// meant that the moment somebody exposed their gateway, every stranger who
// opened the URL arrived holding the administrator's own trust: policy
// writes, key issuance, the audit log, all of it. The socket was telling the
// truth and answering the wrong question.
//
// These headers are what a proxy adds, and none of them survives a direct
// connection from the machine itself. Their presence cannot grant anything —
// it only ever withdraws loopback trust — so a caller who forges one loses
// access they never had. That is the safe direction, and it is why this is a
// header check rather than a configuration flag: an administrator who puts a
// tunnel in front of the gateway does not have to know to also set an
// environment variable, and the release that forgets to say so does not
// silently open somebody's company.
for (const name of FORWARDED_HEADERS) {
if (req.header(name) !== undefined) return false;
}
const address = req.socket.remoteAddress;
if (!address) return false;
const bare = address.startsWith('::ffff:') ? address.slice(7) : address;
return (bare === '::1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bare)) && browserAllowsLocalTrust(req);
}
/**
* Headers that mean "this request was relayed", whoever relayed it.
*
* Deliberately broad. A header on this list that a direct caller happens to
* send costs them loopback trust and nothing else — they can still send an
* administrator's key — while one missing from it costs somebody their
* company. The two errors are not the same size.
*/
const FORWARDED_HEADERS = [
'x-forwarded-for',
'x-forwarded-proto',
'x-forwarded-host',
'forwarded',
'x-real-ip',
'cf-connecting-ip',
'cf-ray',
'true-client-ip',
'fly-client-ip',
'x-client-ip'
] as const;
/** Does this request carry a key belonging to a role the policy exempts? */
export function hasAdminKey(req: Request): boolean {
const employee = actorForCredential(req.header('authorization'));
if (!employee) return false;
try {
return isExempt(loadPolicy(), employee.role);
} catch {
// A policy that will not load is not a policy that exempts anybody. This is
// the one place where failing closed costs the administrator their own
// console, which is the correct direction: a gateway that cannot read its
// own rules should not be taking instructions about them.
return false;
}
}
/**
* Refuse anyone who is neither.
*
* 403 rather than 401: there is no challenge to issue and no login flow to
* point at, and a `WWW-Authenticate` header would invite a browser password
* box that nothing here would ever accept. The body names the two ways in,
* because the person hitting this is usually the administrator on the wrong
* interface rather than an attacker.
*/
/**
* How many times one address may fail to prove it is an administrator.
*
* The administrative surface is guarded by a key that is also somebody's
* employee key, stored in plaintext in the directory file — so the thing
* standing between a stranger and every policy write on this gateway is that
* they do not know a string. Unlimited guesses against a string is not a lock.
*
* Counted per address rather than per key, because a guesser has no key yet.
* Cleared the moment a request succeeds, so an administrator who mistypes and
* then gets it right pays nothing, and the window is short enough that being
* locked out of your own console is minutes rather than an afternoon.
*/
const adminFailures = persistentLimiter(
15 * 60_000,
10,
process.env['WARDEN_RATE_STATE_PATH'] ?? 'data/admin-attempts.json'
);
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
const who = callerKey(undefined, req.socket.remoteAddress);
if ((!REQUIRE_KEY && isLoopback(req)) || hasAdminKey(req)) {
adminFailures.clear(who);
return next();
}
if (!adminFailures.take(who)) {
res.setHeader('Retry-After', String(adminFailures.retryAfter(who)));
// Deliberately the same shape as any other refusal from here, minus the
// advice: telling a guesser they have been throttled tells them the
// guessing was worth throttling.
return void res.status(429).json({ error: 'too many attempts' });
}
// The onboarding route's whole output is piped into `sh`. A JSON body sent
// there is a syntax error arriving inside a shell, which reads to the
// employee as their machine breaking rather than as a link they should not
// have. The route already answers an unknown employee this way; a refusal
// deserves the same shape.
if (req.path.toLowerCase().startsWith('/install')) {
return void res
.status(403)
.type('text/plain')
.send(
[
'# This install link is not the one to use. Ask your admin for the link',
'# shown in the console, which carries a token rather than your name.',
'exit 1',
''
].join('\n')
);
}
res.status(403).json({
error: 'administrative endpoint',
detail:
'Reach it from the machine running the gateway, or send the API key of a ' +
'person whose role the policy exempts as `Authorization: Bearer <key>`.'
});
}