unbound-cli 1.10.0 → 1.11.0
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 +1 -1
- package/src/auth-pages.js +178 -0
- package/src/auth.js +35 -12
- package/test/auth-server-leak.test.js +36 -0
package/package.json
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML pages served by the local auth-callback server (src/auth.js).
|
|
3
|
+
*
|
|
4
|
+
* Design follows the unbound-fe light-surface language (matching the auth
|
|
5
|
+
* login page): #fafafb scene with a soft purple glow, white card on a gray-200
|
|
6
|
+
* border, #3d3d3d headings, brand purple #7B56FB (logo) accents, Inter-first
|
|
7
|
+
* font stack. Fully self-contained —
|
|
8
|
+
* no external fonts, scripts, or images — so the page renders identically
|
|
9
|
+
* offline and never blocks on a network fetch from a localhost one-shot server.
|
|
10
|
+
*
|
|
11
|
+
* SECURITY: email/org arrive as query params on a loopback URL anyone local
|
|
12
|
+
* can hit, so every interpolated value MUST go through escapeHtml. Never
|
|
13
|
+
* interpolate the api_key into a page.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Minimal HTML entity escape for untrusted interpolated text. */
|
|
17
|
+
function escapeHtml(value) {
|
|
18
|
+
return String(value)
|
|
19
|
+
.replace(/&/g, '&')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>')
|
|
22
|
+
.replace(/"/g, '"')
|
|
23
|
+
.replace(/'/g, ''');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Unbound shield mark (public/Unbound.svg in unbound-fe), inlined verbatim.
|
|
27
|
+
const LOGO_SVG =
|
|
28
|
+
'<svg width="28" height="27" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
|
29
|
+
'<path d="M5.12736 6.88525C6.33833 14.4266 11.9473 18.6338 12.0038 18.6757L9.577 22.0419C9.28788 21.8335 2.47374 16.8456 0.979492 7.53649L5.12639 6.88525H5.12736Z" fill="#7B56FB"/>' +
|
|
30
|
+
'<path d="M18.7655 6.81787L22.8988 7.24132C22.8637 7.59566 21.9496 15.9907 14.6361 21.9394L12.0059 18.6755C17.9322 13.8559 18.7577 6.88796 18.7655 6.81787Z" fill="#7B56FB"/>' +
|
|
31
|
+
'<path d="M12.1878 1.4375C14.6214 1.4375 17.3928 1.8113 20.282 2.91325L18.7654 6.81775C11.5638 4.07068 5.19258 6.85572 5.12833 6.88492L3.42188 3.10697C3.63993 3.00865 7.20178 1.4375 12.1878 1.4375Z" fill="#7B56FB"/>' +
|
|
32
|
+
'</svg>';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shared page shell. `status` slots the icon block, heading, and body copy;
|
|
36
|
+
* `accent` tints the icon ring (brand purple on success, red on failure).
|
|
37
|
+
*/
|
|
38
|
+
function renderPage({ title, icon, heading, body }) {
|
|
39
|
+
return `<!DOCTYPE html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
44
|
+
<title>${title}</title>
|
|
45
|
+
<style>
|
|
46
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
47
|
+
body {
|
|
48
|
+
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
49
|
+
background: #fafafb;
|
|
50
|
+
min-height: 100vh;
|
|
51
|
+
display: flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
justify-content: center;
|
|
54
|
+
padding: 24px;
|
|
55
|
+
/* Soft brand glow behind the card, echoing the light auth/login scene. */
|
|
56
|
+
background-image: radial-gradient(600px 400px at 50% 30%, rgba(123, 86, 251, 0.07), transparent 70%);
|
|
57
|
+
}
|
|
58
|
+
.card {
|
|
59
|
+
background: #ffffff;
|
|
60
|
+
border: 1px solid #e5e7eb;
|
|
61
|
+
border-radius: 16px;
|
|
62
|
+
padding: 44px 40px 36px;
|
|
63
|
+
max-width: 400px;
|
|
64
|
+
width: 100%;
|
|
65
|
+
text-align: center;
|
|
66
|
+
box-shadow: 0 16px 48px rgba(23, 19, 43, 0.08);
|
|
67
|
+
animation: rise 0.45s ease-out both;
|
|
68
|
+
}
|
|
69
|
+
.brand {
|
|
70
|
+
display: inline-flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 8px;
|
|
73
|
+
margin-bottom: 28px;
|
|
74
|
+
}
|
|
75
|
+
.brand span {
|
|
76
|
+
color: #17132B;
|
|
77
|
+
font-size: 17px;
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
letter-spacing: 0.02em;
|
|
80
|
+
}
|
|
81
|
+
.icon {
|
|
82
|
+
display: block;
|
|
83
|
+
width: 64px;
|
|
84
|
+
height: 64px;
|
|
85
|
+
margin: 0 auto 20px;
|
|
86
|
+
}
|
|
87
|
+
.icon circle {
|
|
88
|
+
stroke-dasharray: 166;
|
|
89
|
+
stroke-dashoffset: 166;
|
|
90
|
+
animation: draw 0.6s 0.15s ease-out forwards;
|
|
91
|
+
}
|
|
92
|
+
.icon .mark {
|
|
93
|
+
stroke-dasharray: 48;
|
|
94
|
+
stroke-dashoffset: 48;
|
|
95
|
+
animation: draw 0.35s 0.65s ease-out forwards;
|
|
96
|
+
}
|
|
97
|
+
h1 { color: #3d3d3d; font-size: 20px; font-weight: 600; margin-bottom: 10px; }
|
|
98
|
+
p { color: #6b7280; font-size: 14px; line-height: 1.6; }
|
|
99
|
+
.identity {
|
|
100
|
+
display: inline-flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
gap: 2px;
|
|
103
|
+
margin: 14px 0 6px;
|
|
104
|
+
padding: 9px 18px;
|
|
105
|
+
border: 1px solid #e5e7eb;
|
|
106
|
+
border-radius: 12px;
|
|
107
|
+
background: #fafafb;
|
|
108
|
+
font-size: 13px;
|
|
109
|
+
max-width: 100%;
|
|
110
|
+
overflow-wrap: anywhere;
|
|
111
|
+
}
|
|
112
|
+
.identity b { color: #3d3d3d; font-weight: 500; }
|
|
113
|
+
.identity i { color: #6b7280; font-style: normal; font-size: 12px; }
|
|
114
|
+
@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
|
115
|
+
@keyframes draw { to { stroke-dashoffset: 0; } }
|
|
116
|
+
@media (prefers-reduced-motion: reduce) {
|
|
117
|
+
.card, .icon circle, .icon .mark { animation: none; stroke-dashoffset: 0; }
|
|
118
|
+
}
|
|
119
|
+
</style>
|
|
120
|
+
</head>
|
|
121
|
+
<body>
|
|
122
|
+
<main class="card">
|
|
123
|
+
<div class="brand">${LOGO_SVG}<span>unbound</span></div>
|
|
124
|
+
${icon}
|
|
125
|
+
<h1>${heading}</h1>
|
|
126
|
+
${body}
|
|
127
|
+
</main>
|
|
128
|
+
</body>
|
|
129
|
+
</html>
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Stroke-drawn status icons: circle sweeps in first, then the mark.
|
|
134
|
+
const CHECK_ICON =
|
|
135
|
+
'<svg class="icon" viewBox="0 0 56 56" fill="none" aria-hidden="true">' +
|
|
136
|
+
'<circle cx="28" cy="28" r="26" stroke="#7B56FB" stroke-width="2.5"/>' +
|
|
137
|
+
'<path class="mark" d="M17 29l8 8 14-16" stroke="#7B56FB" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>' +
|
|
138
|
+
'</svg>';
|
|
139
|
+
|
|
140
|
+
const CROSS_ICON =
|
|
141
|
+
'<svg class="icon" viewBox="0 0 56 56" fill="none" aria-hidden="true">' +
|
|
142
|
+
'<circle cx="28" cy="28" r="26" stroke="#EF4444" stroke-width="2.5"/>' +
|
|
143
|
+
'<path class="mark" d="M20 20l16 16M36 20L20 36" stroke="#EF4444" stroke-width="3" stroke-linecap="round"/>' +
|
|
144
|
+
'</svg>';
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Success page shown after the API key lands. `email`/`orgName` are untrusted
|
|
148
|
+
* query-param values — escaped here, and omitted entirely when absent.
|
|
149
|
+
*/
|
|
150
|
+
function successPage({ email, orgName } = {}) {
|
|
151
|
+
// Stacked (email over org) rather than one truncating line — real tenant
|
|
152
|
+
// emails and org names routinely overflow a 400px card side by side.
|
|
153
|
+
const identityParts = [];
|
|
154
|
+
if (email) identityParts.push(`<b>${escapeHtml(email)}</b>`);
|
|
155
|
+
if (orgName) identityParts.push(`<i>${escapeHtml(orgName)}</i>`);
|
|
156
|
+
const identity = identityParts.length
|
|
157
|
+
? `<div class="identity">${identityParts.join('')}</div>`
|
|
158
|
+
: '';
|
|
159
|
+
|
|
160
|
+
return renderPage({
|
|
161
|
+
title: 'Authentication Successful | Unbound',
|
|
162
|
+
icon: CHECK_ICON,
|
|
163
|
+
heading: 'Authentication Successful',
|
|
164
|
+
body: `${identity}<p>You can close this tab and return to the terminal.</p>`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Failure page for a callback that arrived without an api_key. */
|
|
169
|
+
function failurePage() {
|
|
170
|
+
return renderPage({
|
|
171
|
+
title: 'Authentication Failed | Unbound',
|
|
172
|
+
icon: CROSS_ICON,
|
|
173
|
+
heading: 'Authentication Failed',
|
|
174
|
+
body: '<p>You can close this tab and try again from the terminal.</p>',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { successPage, failurePage, escapeHtml };
|
package/src/auth.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const http = require('http');
|
|
6
6
|
const { URL } = require('url');
|
|
7
|
+
const authPages = require('./auth-pages');
|
|
7
8
|
const config = require('./config');
|
|
8
9
|
const output = require('./output');
|
|
9
10
|
const api = require('./api');
|
|
@@ -35,18 +36,38 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
35
36
|
// / timed-out socket so the process exits immediately. closeAllConnections is
|
|
36
37
|
// Node >=18.2 — guard for the package's >=18 engine floor.
|
|
37
38
|
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
39
|
+
// The styled callback pages (auth-pages.js) are multi-KB, so calling this
|
|
40
|
+
// synchronously after res.end() could destroy the socket before the body
|
|
41
|
+
// flushes and truncate the page. Callback responses therefore defer teardown
|
|
42
|
+
// to res 'finish' (body flushed) / 'close' (client vanished mid-write) via
|
|
43
|
+
// respondAndShutdown; the guard keeps the second event and the timeout path
|
|
44
|
+
// from double-closing the server (server.close() on a closed server throws).
|
|
45
|
+
let tornDown = false;
|
|
43
46
|
function shutdownServer() {
|
|
47
|
+
if (tornDown) return;
|
|
48
|
+
tornDown = true;
|
|
44
49
|
server.close();
|
|
45
50
|
if (typeof server.closeAllConnections === 'function') {
|
|
46
51
|
server.closeAllConnections();
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
// Sends a terminal (success/failure) callback response and tears the server
|
|
56
|
+
// down once the body has actually left userspace. Resolution of the login
|
|
57
|
+
// promise stays with the caller — this only owns response + teardown.
|
|
58
|
+
function respondAndShutdown(res, statusCode, html) {
|
|
59
|
+
res.on('finish', shutdownServer);
|
|
60
|
+
res.on('close', shutdownServer);
|
|
61
|
+
res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' });
|
|
62
|
+
res.end(html);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// First /callback wins. Teardown is deferred until the response body
|
|
66
|
+
// flushes, which leaves a window where a second /callback could arrive and
|
|
67
|
+
// overwrite the just-persisted credentials after the login promise has
|
|
68
|
+
// settled — reject duplicates outright instead of processing them.
|
|
69
|
+
let callbackHandled = false;
|
|
70
|
+
|
|
50
71
|
server.on('request', (req, res) => {
|
|
51
72
|
const url = new URL(req.url, `http://localhost`);
|
|
52
73
|
|
|
@@ -56,15 +77,20 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
56
77
|
return;
|
|
57
78
|
}
|
|
58
79
|
|
|
80
|
+
if (callbackHandled) {
|
|
81
|
+
res.writeHead(409, { 'Connection': 'close' });
|
|
82
|
+
res.end('Callback already processed');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
callbackHandled = true;
|
|
86
|
+
|
|
59
87
|
const apiKey = url.searchParams.get('api_key');
|
|
60
88
|
const email = url.searchParams.get('email');
|
|
61
89
|
const orgName = url.searchParams.get('org');
|
|
62
90
|
|
|
63
91
|
if (!apiKey) {
|
|
64
|
-
res
|
|
65
|
-
res.end('<html><body><h2>Authentication failed</h2><p>No API key received. Please try again.</p></body></html>');
|
|
92
|
+
respondAndShutdown(res, 400, authPages.failurePage());
|
|
66
93
|
callbackReject(new Error('No API key received in callback'));
|
|
67
|
-
shutdownServer();
|
|
68
94
|
return;
|
|
69
95
|
}
|
|
70
96
|
|
|
@@ -74,11 +100,8 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
74
100
|
if (orgName) cfg.org_name = orgName;
|
|
75
101
|
config.writeConfig(cfg);
|
|
76
102
|
|
|
77
|
-
res
|
|
78
|
-
res.end('<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>');
|
|
79
|
-
|
|
103
|
+
respondAndShutdown(res, 200, authPages.successPage({ email, orgName }));
|
|
80
104
|
callbackResolve({ email, orgName });
|
|
81
|
-
shutdownServer();
|
|
82
105
|
});
|
|
83
106
|
|
|
84
107
|
await new Promise((resolve, reject) => {
|
|
@@ -134,6 +134,42 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
|
|
|
134
134
|
});
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
test('loginWithBrowser: duplicate /callback cannot overwrite the stored credentials', async () => {
|
|
138
|
+
await withStubbedConfig(async () => {
|
|
139
|
+
const agent = new http.Agent({ keepAlive: true });
|
|
140
|
+
const storedKeys = [];
|
|
141
|
+
config.setApiKey = (key) => { storedKeys.push(key); };
|
|
142
|
+
|
|
143
|
+
const fakeOpen = async (authUrl) => {
|
|
144
|
+
const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
|
|
145
|
+
await getKeepAlive(agent, `${callbackUrl}?api_key=sk-first&email=a@b.co&org=Acme`);
|
|
146
|
+
// Second callback with a different key: teardown is deferred to the
|
|
147
|
+
// response flush, so the server may still be up — it must answer 409
|
|
148
|
+
// without persisting; once down, ECONNREFUSED is equally acceptable.
|
|
149
|
+
// Either way the first credentials must be the only ones stored.
|
|
150
|
+
try {
|
|
151
|
+
const dup = await getKeepAlive(agent, `${callbackUrl}?api_key=sk-attacker&email=x@y.z&org=Evil`);
|
|
152
|
+
assert.equal(dup.statusCode, 409, 'late duplicate must be rejected, not processed');
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err.code !== 'ECONNREFUSED') throw err;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const result = await withTimeout(
|
|
160
|
+
auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
|
|
161
|
+
10_000,
|
|
162
|
+
'loginWithBrowser duplicate-callback path',
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
|
|
166
|
+
assert.deepEqual(storedKeys, ['sk-first'], 'only the first callback may persist credentials');
|
|
167
|
+
} finally {
|
|
168
|
+
agent.destroy();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
137
173
|
test('loginWithBrowser: 404 path also sets Connection: close', async () => {
|
|
138
174
|
await withStubbedConfig(async () => {
|
|
139
175
|
const agent = new http.Agent({ keepAlive: true });
|