@dutodev/cli 1.6.0 → 1.7.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/README.md
CHANGED
|
@@ -154,7 +154,7 @@ People get in one of these ways:
|
|
|
154
154
|
| `curl`, Postman, scripts | `curl -u client:password https://backend-<user>.duto.dev/` (`Authorization: Basic`) |
|
|
155
155
|
| An API that already uses `Authorization: Bearer` | Send `X-Duto-Auth: client:password`; the `Bearer` goes through to the app untouched |
|
|
156
156
|
|
|
157
|
-
Without credentials, a browser is sent to the sign-in page and other clients get `401`. Your local app never receives Duto's credentials: the `Authorization: Basic` that matched, `X-Duto-Auth` and the tunnel cookie are stripped before the request is forwarded (other cookies pass through). An `Authorization: Bearer` from your own app passes unchanged.
|
|
157
|
+
Without credentials, a browser is sent to the sign-in page and other clients get `401`. After 10 wrong passwords from one address in 5 minutes, that address is locked out for the rest of the window (the sign-in page answers `429` with `Retry-After`, other clients keep getting `401`). After 100 wrong passwords from all addresses, every password attempt is locked out the same way. A browser that already signed in keeps working. Your local app never receives Duto's credentials: the `Authorization: Basic` that matched, `X-Duto-Auth` and the tunnel cookie are stripped before the request is forwarded (other cookies pass through). An `Authorization: Bearer` from your own app passes unchanged.
|
|
158
158
|
|
|
159
159
|
**Signing out and sessions.** In a browser, signing in sets a cookie that is valid for that tunnel only. To sign out, open `/__duto/logout`: it clears the cookie and returns to the sign-in page. The cookie lasts until the browser closes or the CLI stops. Restarting the CLI signs everyone out, because the key that signs the cookies is new every session. The cookie only counts on requests to the tunnel itself: a page on another origin (another tunnel, another site) that calls the tunnel has to send `X-Duto-Auth` or `Authorization: Basic`. So opening the tunnel from a link on another `*.duto.dev` address (the dashboard or another tunnel) asks for the password again; a link from outside (email, chat) or a typed URL gets straight in.
|
|
160
160
|
|
package/dist/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
statusColor,
|
|
28
28
|
updateLine,
|
|
29
29
|
validateName
|
|
30
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-QVJHHURM.js";
|
|
31
31
|
|
|
32
32
|
// src/ui/brand.ts
|
|
33
33
|
function colorOn(stream = process.stdout) {
|
|
@@ -57,7 +57,7 @@ import { join as join5, relative } from "path";
|
|
|
57
57
|
import cac from "cac";
|
|
58
58
|
|
|
59
59
|
// src/version.ts
|
|
60
|
-
var CLI_VERSION = "1.
|
|
60
|
+
var CLI_VERSION = "1.7.0";
|
|
61
61
|
|
|
62
62
|
// src/api.ts
|
|
63
63
|
var DutoApiError = class extends Error {
|
|
@@ -561,7 +561,7 @@ async function browserLogin(o) {
|
|
|
561
561
|
const start = await api.startLogin({
|
|
562
562
|
deviceId: o.deviceId,
|
|
563
563
|
deviceName: hostname(),
|
|
564
|
-
cliVersion: "1.
|
|
564
|
+
cliVersion: "1.7.0",
|
|
565
565
|
nodeVersion: process.version,
|
|
566
566
|
os: process.platform,
|
|
567
567
|
arch: process.arch
|
|
@@ -1749,9 +1749,12 @@ function cookieIssues(r, ctx) {
|
|
|
1749
1749
|
}
|
|
1750
1750
|
return out;
|
|
1751
1751
|
}
|
|
1752
|
+
function isPublicOrigin(origin, publicHost) {
|
|
1753
|
+
return publicHost !== null && absoluteUrl(origin)?.host === publicHost.toLowerCase();
|
|
1754
|
+
}
|
|
1752
1755
|
function corsIssues(r, ctx) {
|
|
1753
1756
|
const origin = r.reqHeaders.origin;
|
|
1754
|
-
if (!origin || r.status === null ||
|
|
1757
|
+
if (!origin || r.status === null || r.websocket || isPublicOrigin(origin, ctx.publicHost)) return [];
|
|
1755
1758
|
const allowed = r.resHeaders["access-control-allow-origin"];
|
|
1756
1759
|
const ok2 = allowed === "*" || allowed === origin;
|
|
1757
1760
|
const wanted = r.reqHeaders["access-control-request-method"];
|
|
@@ -2172,6 +2175,48 @@ async function isPortListening(port, timeoutMs = 500) {
|
|
|
2172
2175
|
|
|
2173
2176
|
// src/session/auth.ts
|
|
2174
2177
|
import { createHash as createHash3, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
2178
|
+
|
|
2179
|
+
// src/session/attempts.ts
|
|
2180
|
+
var ATTEMPT_WINDOW_MS = 5 * 6e4;
|
|
2181
|
+
var MAX_FAILURES_PER_CLIENT = 10;
|
|
2182
|
+
var MAX_FAILURES_TOTAL = 100;
|
|
2183
|
+
var SWEEP_ABOVE_CLIENTS = 1e4;
|
|
2184
|
+
function msUntilBelow(times, max, now) {
|
|
2185
|
+
if (times.length < max) return 0;
|
|
2186
|
+
return Math.max(0, times[times.length - max] + ATTEMPT_WINDOW_MS - now);
|
|
2187
|
+
}
|
|
2188
|
+
var FailedAttempts = class {
|
|
2189
|
+
constructor(now) {
|
|
2190
|
+
this.now = now;
|
|
2191
|
+
}
|
|
2192
|
+
now;
|
|
2193
|
+
byClient = /* @__PURE__ */ new Map();
|
|
2194
|
+
total = [];
|
|
2195
|
+
lockedFor(client) {
|
|
2196
|
+
const now = this.now();
|
|
2197
|
+
this.total = this.fresh(this.total, now);
|
|
2198
|
+
const mine = this.fresh(this.byClient.get(client) ?? [], now);
|
|
2199
|
+
if (mine.length) this.byClient.set(client, mine);
|
|
2200
|
+
else this.byClient.delete(client);
|
|
2201
|
+
return Math.max(msUntilBelow(mine, MAX_FAILURES_PER_CLIENT, now), msUntilBelow(this.total, MAX_FAILURES_TOTAL, now));
|
|
2202
|
+
}
|
|
2203
|
+
record(client) {
|
|
2204
|
+
const now = this.now();
|
|
2205
|
+
this.total = [...this.fresh(this.total, now), now].slice(-MAX_FAILURES_TOTAL);
|
|
2206
|
+
this.byClient.set(client, [...this.fresh(this.byClient.get(client) ?? [], now), now].slice(-MAX_FAILURES_PER_CLIENT));
|
|
2207
|
+
if (this.byClient.size > SWEEP_ABOVE_CLIENTS) this.sweep(now);
|
|
2208
|
+
}
|
|
2209
|
+
fresh(times, now) {
|
|
2210
|
+
return times.filter((t) => t > now - ATTEMPT_WINDOW_MS);
|
|
2211
|
+
}
|
|
2212
|
+
sweep(now) {
|
|
2213
|
+
for (const [client, times] of this.byClient) {
|
|
2214
|
+
if (!this.fresh(times, now).length) this.byClient.delete(client);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
};
|
|
2218
|
+
|
|
2219
|
+
// src/session/auth.ts
|
|
2175
2220
|
var TUNNEL_COOKIE = "__Host-duto_tunnel";
|
|
2176
2221
|
var SEC_FETCH_SITE_SAME_SITE = "same-site";
|
|
2177
2222
|
var QR_TTL_MS = 5 * 6e4;
|
|
@@ -2186,6 +2231,7 @@ function parseBasic(value) {
|
|
|
2186
2231
|
return m ? splitPair(Buffer.from(m[1], "base64").toString("utf8")) : null;
|
|
2187
2232
|
}
|
|
2188
2233
|
var first2 = (v) => Array.isArray(v) ? v[0] : v;
|
|
2234
|
+
var clientOf2 = (h) => first2(h["cf-connecting-ip"]) ?? "unknown";
|
|
2189
2235
|
var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
|
|
2190
2236
|
function sameHostOrigin(h) {
|
|
2191
2237
|
if (first2(h["sec-fetch-site"])?.toLowerCase() === SEC_FETCH_SITE_SAME_SITE) return false;
|
|
@@ -2207,6 +2253,7 @@ var TunnelAuth = class {
|
|
|
2207
2253
|
now;
|
|
2208
2254
|
secret;
|
|
2209
2255
|
qr = null;
|
|
2256
|
+
failures;
|
|
2210
2257
|
constructor(o) {
|
|
2211
2258
|
this.tunnel = o.tunnel;
|
|
2212
2259
|
this.credentials = o.credentials;
|
|
@@ -2214,6 +2261,7 @@ var TunnelAuth = class {
|
|
|
2214
2261
|
this.now = o.now ?? Date.now;
|
|
2215
2262
|
this.secret = o.secret ?? randomBytes2(32);
|
|
2216
2263
|
this.qrUser = o.credentials[0]?.user ?? "";
|
|
2264
|
+
this.failures = new FailedAttempts(this.now);
|
|
2217
2265
|
}
|
|
2218
2266
|
access(h) {
|
|
2219
2267
|
if (this.allow) {
|
|
@@ -2225,9 +2273,19 @@ var TunnelAuth = class {
|
|
|
2225
2273
|
if (cookieUser && sameHostOrigin(h)) return { kind: "user", user: cookieUser };
|
|
2226
2274
|
const dutoAuthHeader = first2(h["x-duto-auth"]);
|
|
2227
2275
|
const candidate = dutoAuthHeader !== void 0 ? splitPair(dutoAuthHeader) : parseBasic(first2(h.authorization));
|
|
2228
|
-
if (candidate && this.
|
|
2276
|
+
if (candidate && this.attemptPassword(h, candidate.user, candidate.password) === "ok") return { kind: "user", user: candidate.user };
|
|
2229
2277
|
return { kind: "denied" };
|
|
2230
2278
|
}
|
|
2279
|
+
lockedFor(h) {
|
|
2280
|
+
return this.failures.lockedFor(clientOf2(h));
|
|
2281
|
+
}
|
|
2282
|
+
attemptPassword(h, user, password) {
|
|
2283
|
+
const client = clientOf2(h);
|
|
2284
|
+
if (this.failures.lockedFor(client) > 0) return "locked";
|
|
2285
|
+
if (this.checkPassword(user, password)) return "ok";
|
|
2286
|
+
this.failures.record(client);
|
|
2287
|
+
return "wrong";
|
|
2288
|
+
}
|
|
2231
2289
|
checkPassword(user, password) {
|
|
2232
2290
|
let ok2 = false;
|
|
2233
2291
|
for (const c of this.credentials) {
|
|
@@ -2452,6 +2510,10 @@ function safeNext(raw) {
|
|
|
2452
2510
|
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
|
|
2453
2511
|
return /^[\x21-\x7e]+$/.test(raw) ? raw : "/";
|
|
2454
2512
|
}
|
|
2513
|
+
var ERROR_TEXT = {
|
|
2514
|
+
wrong: "Wrong username or password.",
|
|
2515
|
+
locked: "Too many wrong passwords. Wait a few minutes and try again."
|
|
2516
|
+
};
|
|
2455
2517
|
function loginPage(o) {
|
|
2456
2518
|
return pageShell(
|
|
2457
2519
|
"Sign in \xB7 duto",
|
|
@@ -2465,7 +2527,7 @@ function loginPage(o) {
|
|
|
2465
2527
|
<input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
|
|
2466
2528
|
<label for="password">Password</label>
|
|
2467
2529
|
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
2468
|
-
${o.error ? `<p class="error" role="alert"
|
|
2530
|
+
${o.error ? `<p class="error" role="alert">${ERROR_TEXT[o.error]}</p>
|
|
2469
2531
|
` : ""}<button type="submit">Sign in</button>
|
|
2470
2532
|
</form>
|
|
2471
2533
|
<div class="retry">You stay signed in until you close the browser or the tunnel closes.</div>
|
|
@@ -2581,8 +2643,8 @@ function readForm(req, max) {
|
|
|
2581
2643
|
}
|
|
2582
2644
|
async function authRoute(req, res, route, auth) {
|
|
2583
2645
|
const url = new URL(req.url ?? "/", "http://duto");
|
|
2584
|
-
const page = (status, next, error) => {
|
|
2585
|
-
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
2646
|
+
const page = (status, next, error, extra = {}) => {
|
|
2647
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...extra });
|
|
2586
2648
|
res.end(loginPage({ tunnel: route.tunnel, next, error }));
|
|
2587
2649
|
};
|
|
2588
2650
|
const go = (location, cookie) => {
|
|
@@ -2591,7 +2653,7 @@ async function authRoute(req, res, route, auth) {
|
|
|
2591
2653
|
};
|
|
2592
2654
|
const { pathname } = url;
|
|
2593
2655
|
if (pathname === LOGIN_PATH && req.method === "GET") {
|
|
2594
|
-
page(200, safeNext(url.searchParams.get("next")),
|
|
2656
|
+
page(200, safeNext(url.searchParams.get("next")), null);
|
|
2595
2657
|
return true;
|
|
2596
2658
|
}
|
|
2597
2659
|
if (pathname === LOGIN_PATH && req.method === "POST") {
|
|
@@ -2604,8 +2666,10 @@ async function authRoute(req, res, route, auth) {
|
|
|
2604
2666
|
const form = new URLSearchParams(raw);
|
|
2605
2667
|
const user = form.get("username") ?? "";
|
|
2606
2668
|
const next = safeNext(form.get("next"));
|
|
2607
|
-
|
|
2608
|
-
|
|
2669
|
+
const attempt = auth.attemptPassword(req.headers, user, form.get("password") ?? "");
|
|
2670
|
+
if (attempt === "ok") go(next, auth.loginCookie(user));
|
|
2671
|
+
else if (attempt === "locked") page(429, next, "locked", { "retry-after": String(Math.ceil(auth.lockedFor(req.headers) / 1e3)) });
|
|
2672
|
+
else page(401, next, "wrong");
|
|
2609
2673
|
return true;
|
|
2610
2674
|
}
|
|
2611
2675
|
if (pathname === LOGOUT_PATH && req.method === "GET") {
|
|
@@ -3375,7 +3439,7 @@ async function runSession(specs, source, flags) {
|
|
|
3375
3439
|
}
|
|
3376
3440
|
const warnings = credentialWarnings(specs);
|
|
3377
3441
|
if (wantsUi(flags)) {
|
|
3378
|
-
const { runFullscreen } = await import("./fullscreen-
|
|
3442
|
+
const { runFullscreen } = await import("./fullscreen-ZNFUDV2B.js");
|
|
3379
3443
|
const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
|
|
3380
3444
|
await inspector?.close().catch(() => void 0);
|
|
3381
3445
|
process.exit(code);
|
package/package.json
CHANGED
|
File without changes
|