locadot 2.1.0-beta.3 → 2.1.0-beta.4

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/CHANGELOG.md CHANGED
@@ -33,6 +33,10 @@
33
33
  Senders must run this version too; an older sender answers `<domain>.localhost` with a 404.
34
34
 
35
35
  ### Fixed
36
+ - `--cors` pages that call an API on a bare `localhost:<port>` (e.g. `VITE_SERVER_URL=http://localhost:3100`) now send those
37
+ calls through the page's own origin (`/__locadot/x/http/localhost:3100/…`) like any other cross-origin call. Opened through
38
+ remote access, the API used to be called on the *viewer's* localhost, where it doesn't exist. A peer's pass-through reaches
39
+ the sender's localhost only with localhost access (admin, `hub:localhost on`), and never the proxy's own ports.
36
40
  - The proxy can now tell a user's `LOCADOT_HTTP_PORT`/`LOCADOT_HTTPS_PORT` from the copies the CLI pins on every spawn
37
41
  (`LOCADOT_USER_PORTS`), so saved ports aren't reported as env-overridden.
38
42
  - `--cors` through remote access: a shared `--cors` mapping is now `--cors` on the receiver too (shown in `list` and the
package/README.md CHANGED
@@ -295,6 +295,8 @@ locadot add --host signalsant.localhost --target https://signalsant.com --cors
295
295
  `XMLHttpRequest`, `EventSource`, `WebSocket` and `sendBeacon` calls to other origins through the page's own origin
296
296
  (`/__locadot/x/https/api.signalsant.com/…`). The browser sees a same-origin request, so CORS never applies, and locadot
297
297
  forwards it with `Origin: https://signalsant.com`. Only the page itself can use it: requests from other sites get a 403.
298
+ Calls to a bare `localhost:<port>` (an API or dev server next to the app) go the same way, so the page works when opened
299
+ from another machine through remote access too. There, only admin peers reach the sender's localhost this way.
298
300
  - **Cookies.** The page's cookies are forwarded only to the same site (`api.signalsant.com`), never to third parties.
299
301
  Cookies set by third parties are dropped.
300
302
  - **Mapped domains.** If you also map a domain (`api.signalsant.localhost` → `https://api.signalsant.com --cors`), its URLs in
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.publicOnlyAgents = exports.isPublicHost = exports.isPublicAddress = void 0;
6
+ exports.publicOnlyAgents = exports.isPublicHost = exports.isLoopbackHost = exports.isPublicAddress = void 0;
7
7
  const dns_1 = __importDefault(require("dns"));
8
8
  const http_1 = __importDefault(require("http"));
9
9
  const https_1 = __importDefault(require("https"));
@@ -54,6 +54,17 @@ const isPublicAddress = (address) => {
54
54
  return net_1.default.isIPv6(address) && !blocked.check(address, "ipv6");
55
55
  };
56
56
  exports.isPublicAddress = isPublicAddress;
57
+ /** localhost, *.localhost, 127.0.0.0/8, ::1 or 0.0.0.0: this machine itself. */
58
+ const isLoopbackHost = (host) => {
59
+ const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase().replace(/\.$/, "");
60
+ if (name === "localhost" || name.endsWith(".localhost"))
61
+ return true;
62
+ const v4 = net_1.default.isIPv4(name) ? name : net_1.default.isIPv6(name) ? embeddedV4(name) : undefined;
63
+ if (v4)
64
+ return v4.startsWith("127.") || v4 === "0.0.0.0";
65
+ return name === "::1" || name === "::";
66
+ };
67
+ exports.isLoopbackHost = isLoopbackHost;
57
68
  /** A host the pass-through may call for a tunnel visitor, judged before any DNS lookup. */
58
69
  const isPublicHost = (host) => {
59
70
  const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "").toLowerCase();
package/dist/proxy/hub.js CHANGED
@@ -180,7 +180,7 @@ function classify(req, publicHost, hosts, opts) {
180
180
  if (host && names && self && scheme)
181
181
  names[host] = `${scheme}://${self}`;
182
182
  stripLocadotHeaders(req);
183
- return { kind: "local", port, peer, names, host };
183
+ return { kind: "local", port, peer, names, host, loopback: opts?.blockedPorts ?? [] };
184
184
  }
185
185
  const hostHeader = req.headers["x-locadot-host"];
186
186
  const host = typeof hostHeader === "string" ? hostHeader.trim().toLowerCase() : "";
@@ -194,7 +194,9 @@ function classify(req, publicHost, hosts, opts) {
194
194
  }
195
195
  const names = req.headers["x-locadot-names"] === undefined ? undefined : peerNames(req, visible);
196
196
  stripLocadotHeaders(req);
197
- return { kind: "app", host, peer, names };
197
+ // A --cors page's pass-through calls to localhost:<port> reach this machine only for peers allowed its localhost.
198
+ const loopback = links_1.default.can(peer, "localhost") && opts?.localhost !== false ? opts?.blockedPorts ?? [] : undefined;
199
+ return { kind: "app", host, peer, names, loopback };
198
200
  }
199
201
  /* ---------- /_locadot/v1 API ---------- */
200
202
  const send = (res, status, body) => {
@@ -56,12 +56,16 @@ exports.shimScript = `(() => {
56
56
  if (window.__locadotShim) return;
57
57
  window.__locadotShim = true;
58
58
  const VIA = ${JSON.stringify(VIA)};
59
+ const portOf = (url) => url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80");
59
60
  const route = (input) => {
60
61
  try {
61
62
  const url = new URL(String(input), location.href);
62
63
  if (!/^(https?|wss?):$/.test(url.protocol)) return null;
63
- const local = url.hostname === "localhost" || url.hostname.endsWith(".localhost") || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
64
- if (local || url.host === location.host) return null;
64
+ if (url.hostname.endsWith(".localhost") || url.host === location.host) return null;
65
+ // Bare localhost:<port> (an app's API or dev server) exists only where the app runs, so it goes through the
66
+ // page's origin too; a remote viewer's own localhost doesn't have it. The page's own proxy port stays direct.
67
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
68
+ if (loopback && portOf(url) === portOf(location)) return null;
65
69
  const ws = url.protocol === "ws:" || url.protocol === "wss:";
66
70
  const base = ws ? (location.protocol === "https:" ? "wss://" : "ws://") + location.host : location.origin;
67
71
  return base + VIA + url.protocol.slice(0, -1) + "/" + url.host + url.pathname + url.search + url.hash;
@@ -150,7 +154,7 @@ const rewriteViaResponse = (headers, via, target) => {
150
154
  else {
151
155
  try {
152
156
  const url = new URL(location, `${via.scheme}://${via.host}/`);
153
- if (/^https?:$/.test(url.protocol) && !/(^|\.)localhost$/.test(url.hostname))
157
+ if (/^https?:$/.test(url.protocol) && !url.hostname.endsWith(".localhost"))
154
158
  headers.location = (0, exports.viaPath)(url) + url.hash;
155
159
  }
156
160
  catch { }
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isTls = exports.viaOf = exports.peerNamesOf = exports.peerOriginOf = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
3
+ exports.isTls = exports.viaOf = exports.loopbackOf = exports.peerNamesOf = exports.peerOriginOf = exports.fromRemote = exports.fromTunnel = exports.mappedHost = exports.hostOf = exports.tag = void 0;
4
4
  const tags = new WeakMap();
5
5
  const tag = (req, values) => {
6
6
  tags.set(req, { ...tags.get(req), ...values });
@@ -25,6 +25,8 @@ const peerOriginOf = (req) => tags.get(req)?.peerOrigin;
25
25
  exports.peerOriginOf = peerOriginOf;
26
26
  const peerNamesOf = (req) => tags.get(req)?.peerNames;
27
27
  exports.peerNamesOf = peerNamesOf;
28
+ const loopbackOf = (req) => tags.get(req)?.loopback;
29
+ exports.loopbackOf = loopbackOf;
28
30
  const viaOf = (req) => tags.get(req)?.via;
29
31
  exports.viaOf = viaOf;
30
32
  /** Tunnel visitors are on https even though cloudflared talks plain http to us. */
@@ -20,7 +20,7 @@ const stats_1 = require("./stats");
20
20
  /** Resolves a tunnel's public host to its mapping and tags the request, before any routing. */
21
21
  const resolveHost = (req, ctx, hub) => {
22
22
  if (hub?.kind === "app") {
23
- (0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names });
23
+ (0, request_1.tag)(req, { host: hub.host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names, loopback: hub.loopback });
24
24
  return hub.host;
25
25
  }
26
26
  if (hub?.kind === "dashboard") {
@@ -30,7 +30,7 @@ const resolveHost = (req, ctx, hub) => {
30
30
  if (hub?.kind === "local") {
31
31
  // A port behind a --cors mapping is served as that mapping, cors included.
32
32
  const host = hub.host ?? `localhost:${hub.port}`;
33
- (0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names });
33
+ (0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names, loopback: hub.loopback });
34
34
  return host;
35
35
  }
36
36
  const host = (0, request_1.hostOf)(req);
@@ -41,8 +41,16 @@ const resolveHost = (req, ctx, hub) => {
41
41
  };
42
42
  const dashboardUrl = (req) => `${(0, urls_1.urlFor)("localhost", (0, request_1.isTls)(req))}/`;
43
43
  const passThrough = (req, entry) => Boolean(entry.cors);
44
- /** Same-origin only, and a tunnel visitor may only reach public hosts (see guard.ts). */
45
- const viaAllowed = (req, via) => (0, passthrough_1.isSameOrigin)(req) && (!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host));
44
+ /** A hub peer reaches this machine's localhost:<port> only with localhost access, and never the proxy's own ports. */
45
+ const peerMayReach = (req, via) => {
46
+ if (!(0, request_1.fromRemote)(req) || !(0, guard_1.isLoopbackHost)(via.host))
47
+ return true;
48
+ const allowed = (0, request_1.loopbackOf)(req);
49
+ const port = Number(via.host.match(/:(\d+)$/)?.[1] || (via.scheme === "https" || via.scheme === "wss" ? 443 : 80));
50
+ return Boolean(allowed && !allowed.includes(port));
51
+ };
52
+ /** Same-origin only, a tunnel visitor may only reach public hosts (see guard.ts), and a peer only its share of localhost. */
53
+ const viaAllowed = (req, via) => (0, passthrough_1.isSameOrigin)(req) && (!(0, request_1.fromTunnel)(req) || (0, guard_1.isPublicHost)(via.host)) && peerMayReach(req, via);
46
54
  /**
47
55
  * Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
48
56
  * its apps' origins to the names this machine uses rather than its own.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "2.1.0-beta.3",
3
+ "version": "2.1.0-beta.4",
4
4
  "description": "Secure your local development environment with HTTPS and custom domains like dev.localhost.",
5
5
  "homepage": "https://www.npmjs.com/package/locadot",
6
6
  "main": "dist/index.js",