@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
 
@@ -15,7 +15,7 @@ import {
15
15
  tunnelColor,
16
16
  updateLine,
17
17
  validateName
18
- } from "./chunk-DUC4RSBW.js";
18
+ } from "./chunk-QVJHHURM.js";
19
19
 
20
20
  // src/ui/fullscreen.tsx
21
21
  import { render } from "ink";
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  statusColor,
28
28
  updateLine,
29
29
  validateName
30
- } from "./chunk-DUC4RSBW.js";
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.6.0";
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.6.0",
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 || ctx.publicHost && origin.toLowerCase() === `https://${ctx.publicHost}`) return [];
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.checkPassword(candidate.user, candidate.password)) return { kind: "user", user: candidate.user };
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">Wrong username or password.</p>
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")), false);
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
- if (auth.checkPassword(user, form.get("password") ?? "")) go(next, auth.loginCookie(user));
2608
- else page(401, next, true);
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-MSVJF43W.js");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Expose a local port at https://<name>-<user>.duto.dev with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
File without changes