unbound-cli 1.20.0 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.20.0",
3
+ "version": "1.20.1",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/auth.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Shared browser-based authentication flow.
3
3
  * Used by both `login` and `setup` commands.
4
4
  */
5
+ const crypto = require('crypto');
5
6
  const http = require('http');
6
7
  const os = require('os');
7
8
  const { URL } = require('url');
@@ -35,6 +36,8 @@ function warnIfNotStored(stored) {
35
36
  */
36
37
  async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {}) {
37
38
  const server = http.createServer();
39
+ // One-time nonce: only the redirect the dashboard was handed can complete this login.
40
+ const state = crypto.randomBytes(16).toString('hex');
38
41
  let callbackResolve;
39
42
  let callbackReject;
40
43
 
@@ -73,7 +76,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {})
73
76
  function respondAndShutdown(res, statusCode, html) {
74
77
  res.on('finish', shutdownServer);
75
78
  res.on('close', shutdownServer);
76
- res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' });
79
+ res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Connection': 'close' });
77
80
  res.end(html);
78
81
  }
79
82
 
@@ -84,6 +87,14 @@ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {})
84
87
  let callbackHandled = false;
85
88
  let stored;
86
89
 
90
+ // Compare byte lengths, not string lengths: a multi-byte candidate would make timingSafeEqual throw.
91
+ function stateMatches(candidate) {
92
+ if (typeof candidate !== 'string') return false;
93
+ const given = Buffer.from(candidate);
94
+ const expected = Buffer.from(state);
95
+ return given.length === expected.length && crypto.timingSafeEqual(given, expected);
96
+ }
97
+
87
98
  server.on('request', (req, res) => {
88
99
  const url = new URL(req.url, `http://localhost`);
89
100
 
@@ -93,6 +104,13 @@ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {})
93
104
  return;
94
105
  }
95
106
 
107
+ // A request without this login's nonce is not the dashboard's redirect: answer it and keep waiting.
108
+ if (!stateMatches(url.searchParams.get('state'))) {
109
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Connection': 'close' });
110
+ res.end(authPages.failurePage('Invalid login callback'));
111
+ return;
112
+ }
113
+
96
114
  if (callbackHandled) {
97
115
  res.writeHead(409, { 'Connection': 'close' });
98
116
  res.end('Callback already processed');
@@ -103,7 +121,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {})
103
121
  const apiKey = url.searchParams.get('api_key');
104
122
  const email = url.searchParams.get('email');
105
123
  const orgName = url.searchParams.get('org');
106
- // The callback is reachable by any local page, so its text is stripped of control characters before display.
124
+ // Nonce-gated, but the reason is still stripped of control characters before display as defence in depth.
107
125
  const reason = (url.searchParams.get('error') || '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200);
108
126
  // `scope` echoes what the dashboard minted; an older dashboard sends none.
109
127
  const grantedScope = url.searchParams.get('scope');
@@ -130,7 +148,8 @@ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {})
130
148
  });
131
149
 
132
150
  const port = server.address().port;
133
- const callbackUrl = `http://localhost:${port}/callback`;
151
+ // 127.0.0.1 literal: the server binds only IPv4, so a `localhost` redirect could land on a squatter at [::1].
152
+ const callbackUrl = `http://127.0.0.1:${port}/callback?state=${state}`;
134
153
  // `machine` names the login token after this host, so a re-login replaces that token instead of adding one.
135
154
  const params = new URLSearchParams({ callback_url: callbackUrl, app_type: 'cli', machine: os.hostname(), ...scope });
136
155
  const authUrl = `${frontendUrl}/automations/api-key-callback?${params.toString()}`;
@@ -64,11 +64,17 @@ function expectConnectionRefused(port) {
64
64
  });
65
65
  }
66
66
 
67
- // Pulls the callback_url the server told the browser to redirect to, then
68
- // appends the success params the /callback handler reads (api_key/email/org).
67
+ // Mirrors the dashboard: keep callback_url's own query (the state nonce) and
68
+ // set the success params the /callback handler reads (api_key/email/org).
69
+ function withCallbackParams(callbackUrl, params) {
70
+ const u = new URL(callbackUrl);
71
+ for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
72
+ return u.toString();
73
+ }
74
+
69
75
  function buildSuccessCallback(authUrl) {
70
76
  const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
71
- return `${callbackUrl}?api_key=sk-test&email=a@b.co&org=Acme`;
77
+ return withCallbackParams(callbackUrl, { api_key: 'sk-test', email: 'a@b.co', org: 'Acme' });
72
78
  }
73
79
 
74
80
  // Monkeypatch config so no real ~/.unbound write happens. Restored in finally.
@@ -149,13 +155,13 @@ test('loginWithBrowser: duplicate /callback cannot overwrite the stored credenti
149
155
 
150
156
  const fakeOpen = async (authUrl) => {
151
157
  const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
152
- await getKeepAlive(agent, `${callbackUrl}?api_key=sk-first&email=a@b.co&org=Acme`);
158
+ await getKeepAlive(agent, withCallbackParams(callbackUrl, { api_key: 'sk-first', email: 'a@b.co', org: 'Acme' }));
153
159
  // Second callback with a different key: teardown is deferred to the
154
160
  // response flush, so the server may still be up — it must answer 409
155
161
  // without persisting; once down, ECONNREFUSED is equally acceptable.
156
162
  // Either way the first credentials must be the only ones stored.
157
163
  try {
158
- const dup = await getKeepAlive(agent, `${callbackUrl}?api_key=sk-attacker&email=x@y.z&org=Evil`);
164
+ const dup = await getKeepAlive(agent, withCallbackParams(callbackUrl, { api_key: 'sk-attacker', email: 'x@y.z', org: 'Evil' }));
159
165
  assert.equal(dup.statusCode, 409, 'late duplicate must be rejected, not processed');
160
166
  } catch (err) {
161
167
  if (err.code !== 'ECONNREFUSED') throw err;
@@ -19,6 +19,13 @@ function get(url) {
19
19
  });
20
20
  }
21
21
 
22
+ // Mirrors the dashboard: `new URL(callback_url)` keeps the state nonce, then the result params are set on it.
23
+ function withCallbackParams(callbackUrl, params) {
24
+ const u = new URL(callbackUrl);
25
+ for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
26
+ return u.toString();
27
+ }
28
+
22
29
  function withStubbedConfig(fn) {
23
30
  const saved = { setApiKey: config.setApiKey, writeConfig: config.writeConfig, readConfig: config.readConfig };
24
31
  config.setApiKey = () => {};
@@ -33,7 +40,7 @@ test('loginWithBrowser: scope flags ride on the auth URL and the echoed scope co
33
40
  const fakeOpen = async (authUrl) => {
34
41
  authParams = new URL(authUrl).searchParams;
35
42
  const callbackUrl = authParams.get('callback_url');
36
- setImmediate(() => get(`${callbackUrl}?api_key=sk-test&email=a@b.co&org=Acme&scope=role%3AAnalytics+Viewer`));
43
+ setImmediate(() => get(withCallbackParams(callbackUrl, { api_key: 'sk-test', email: 'a@b.co', org: 'Acme', scope: 'role:Analytics Viewer' })));
37
44
  };
38
45
 
39
46
  const result = await auth.loginWithBrowser('http://frontend.invalid', {
@@ -51,7 +58,7 @@ test('loginWithBrowser: control characters in the callback error never reach the
51
58
  await withStubbedConfig(async () => {
52
59
  const fakeOpen = async (authUrl) => {
53
60
  const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
54
- setImmediate(() => get(`${callbackUrl}?error=${encodeURIComponent('bad\u001b[31m role\u0007')}`));
61
+ setImmediate(() => get(withCallbackParams(callbackUrl, { error: 'bad\u001b[31m role\u0007' })));
55
62
  };
56
63
  await assert.rejects(
57
64
  () => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen, scope: { role: 'x' } }),
@@ -65,7 +72,7 @@ test('loginWithBrowser: the dashboard\'s error reason is the rejection and shows
65
72
  let page;
66
73
  const fakeOpen = async (authUrl) => {
67
74
  const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
68
- setImmediate(async () => { page = await get(`${callbackUrl}?error=Unknown+role+%22Policy-Auditor%22`); });
75
+ setImmediate(async () => { page = await get(withCallbackParams(callbackUrl, { error: 'Unknown role "Policy-Auditor"' })); });
69
76
  };
70
77
 
71
78
  await assert.rejects(
@@ -77,3 +84,128 @@ test('loginWithBrowser: the dashboard\'s error reason is the rejection and shows
77
84
  assert.match(page.body, /Unknown role "Policy-Auditor"/);
78
85
  });
79
86
  });
87
+
88
+ // WEB-6462: the callback is gated by a per-login `state` nonce and pinned to 127.0.0.1, so a local
89
+ // page or an IPv6 bind-squatter can neither complete the login nor read the dashboard's redirect.
90
+
91
+ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
92
+
93
+ test('loginWithBrowser: callback_url is pinned to 127.0.0.1 and carries a 32-hex state nonce', async () => {
94
+ await withStubbedConfig(async () => {
95
+ let callbackUrl;
96
+ const fakeOpen = async (authUrl) => {
97
+ callbackUrl = new URL(new URL(authUrl).searchParams.get('callback_url'));
98
+ setImmediate(() => get(withCallbackParams(callbackUrl, { api_key: 'sk-test' })));
99
+ };
100
+ await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
101
+ assert.equal(callbackUrl.hostname, '127.0.0.1');
102
+ assert.equal(callbackUrl.pathname, '/callback');
103
+ assert.match(callbackUrl.searchParams.get('state'), /^[0-9a-f]{32}$/);
104
+ });
105
+ });
106
+
107
+ test('loginWithBrowser: a wrong state gets 400 and the real callback still completes with 200', async () => {
108
+ await withStubbedConfig(async () => {
109
+ const storedKeys = [];
110
+ config.setApiKey = (key) => { storedKeys.push(key); };
111
+ let forged;
112
+ let real;
113
+ const fakeOpen = async (authUrl) => {
114
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
115
+ setImmediate(async () => {
116
+ forged = await get(withCallbackParams(callbackUrl, { state: 'f'.repeat(32), api_key: 'sk-attacker' }));
117
+ // The login settles before this response body flushes, so hold the promise and await it below.
118
+ real = get(withCallbackParams(callbackUrl, { api_key: 'sk-real', email: 'a@b.co', org: 'Acme' }));
119
+ });
120
+ };
121
+ const result = await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
122
+ assert.equal(forged.statusCode, 400);
123
+ assert.equal((await real).statusCode, 200, 'the forged request must not consume the one-shot callback');
124
+ assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
125
+ assert.deepEqual(storedKeys, ['sk-real']);
126
+ });
127
+ });
128
+
129
+ test('loginWithBrowser: a callback without state gets 400 and the login still completes', async () => {
130
+ await withStubbedConfig(async () => {
131
+ let bare;
132
+ const fakeOpen = async (authUrl) => {
133
+ const callbackUrl = new URL(new URL(authUrl).searchParams.get('callback_url'));
134
+ const noState = new URL(callbackUrl); noState.searchParams.delete('state'); noState.searchParams.set('api_key', 'sk-attacker');
135
+ setImmediate(async () => {
136
+ bare = await get(noState.toString());
137
+ await get(withCallbackParams(callbackUrl, { api_key: 'sk-real', email: 'a@b.co', org: 'Acme' }));
138
+ });
139
+ };
140
+ const result = await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
141
+ assert.equal(bare.statusCode, 400);
142
+ assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
143
+ });
144
+ });
145
+
146
+ test('loginWithBrowser: a wrong-state error= neither shows its text nor settles the login', async () => {
147
+ await withStubbedConfig(async () => {
148
+ let forged;
149
+ let callbackUrl;
150
+ const fakeOpen = async (authUrl) => {
151
+ callbackUrl = new URL(authUrl).searchParams.get('callback_url');
152
+ setImmediate(async () => {
153
+ forged = await get(withCallbackParams(callbackUrl, { state: '0'.repeat(32), error: 'ATTACKER-TEXT' }));
154
+ });
155
+ };
156
+ let settled = false;
157
+ const login = auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }).finally(() => { settled = true; });
158
+ await sleep(100);
159
+ assert.equal(forged.statusCode, 400);
160
+ assert.doesNotMatch(forged.body, /ATTACKER-TEXT/);
161
+ assert.equal(settled, false, 'a forged error must not reject the login');
162
+ // Finish the login so the server does not outlive the test.
163
+ await get(withCallbackParams(callbackUrl, { api_key: 'sk-real' }));
164
+ await login;
165
+ });
166
+ });
167
+
168
+ test('loginWithBrowser: a 32-char multi-byte state gets 400 instead of crashing the server', async () => {
169
+ await withStubbedConfig(async () => {
170
+ let forged;
171
+ const fakeOpen = async (authUrl) => {
172
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
173
+ setImmediate(async () => {
174
+ // Same JS length as the real nonce, different UTF-8 byte length.
175
+ forged = await get(withCallbackParams(callbackUrl, { state: '\u00e9'.repeat(32), api_key: 'sk-attacker' }));
176
+ await get(withCallbackParams(callbackUrl, { api_key: 'sk-real', email: 'a@b.co', org: 'Acme' }));
177
+ });
178
+ };
179
+ const result = await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
180
+ assert.equal(forged.statusCode, 400);
181
+ assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
182
+ });
183
+ });
184
+
185
+ test('loginWithBrowser: each login mints a different state', async () => {
186
+ await withStubbedConfig(async () => {
187
+ const states = [];
188
+ const fakeOpen = async (authUrl) => {
189
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
190
+ states.push(new URL(callbackUrl).searchParams.get('state'));
191
+ setImmediate(() => get(withCallbackParams(callbackUrl, { api_key: 'sk-test' })));
192
+ };
193
+ await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
194
+ await auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen });
195
+ assert.equal(states.length, 2);
196
+ assert.notEqual(states[0], states[1]);
197
+ });
198
+ });
199
+
200
+ test('loginWithBrowser: error= with the correct state still rejects with the dashboard reason', async () => {
201
+ await withStubbedConfig(async () => {
202
+ const fakeOpen = async (authUrl) => {
203
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
204
+ setImmediate(() => get(withCallbackParams(callbackUrl, { error: 'Role not permitted' })));
205
+ };
206
+ await assert.rejects(
207
+ () => auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
208
+ /Role not permitted/,
209
+ );
210
+ });
211
+ });