locadot 2.1.0-beta.2 → 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,8 +33,19 @@
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.
42
+ - `--cors` through remote access: a shared `--cors` mapping is now `--cors` on the receiver too (shown in `list` and the
43
+ dashboard, kept in step by `remote:sync` and `remote:update`). The sender used to rewrite its apps' origins in pages to its
44
+ *own* local URLs, which don't exist on the receiver. Now the receiver sends the names it uses (`X-Locadot-Names`), so a
45
+ page's API calls go to the receiver's name for that mapping (e.g. `api.alice.localhost` after a clash), on the receiver's
46
+ port. The upstream still sees the calling page's real origin. `<port>.<domain>.localhost` gets the CORS handling of a
47
+ `--cors` mapping on that port. The sender keeps names only for mappings the peer can see, and only as `*.localhost` URLs.
48
+ Both machines need this version for the rewriting; with an older receiver the sender behaves as before.
38
49
 
39
50
  ## 2.1.0-beta.0 (2026-09-27)
40
51
 
package/README.md CHANGED
@@ -131,6 +131,12 @@ target, so an editor can reach anything the sender's machine can reach. Only giv
131
131
  of the tokens are stored, and failed attempts are rate limited. Only admins can open the sender's dashboard through the hub.
132
132
  The dashboard has **Remote access** and **Connected machines** cards for all of this.
133
133
 
134
+ `--cors` carries over: a sender's `--cors` mapping is `--cors` on the receiver too, with no extra flag. Preflights and CORS
135
+ headers work as they do locally, and origins in the sender's pages are rewritten to the receiver's own names and port (so
136
+ `http://localhost:8000` in a page becomes `http://api.alice.localhost` if that's the receiver's name for `api.localhost`).
137
+ An admin's `<port>.<domain>.localhost` gets the same treatment when a `--cors` mapping points at that port.
138
+ `remote:sync` picks up a sender turning `--cors` on or off.
139
+
134
140
  ### Certificates
135
141
 
136
142
  | Command | What it does |
@@ -289,6 +295,8 @@ locadot add --host signalsant.localhost --target https://signalsant.com --cors
289
295
  `XMLHttpRequest`, `EventSource`, `WebSocket` and `sendBeacon` calls to other origins through the page's own origin
290
296
  (`/__locadot/x/https/api.signalsant.com/…`). The browser sees a same-origin request, so CORS never applies, and locadot
291
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.
292
300
  - **Cookies.** The page's cookies are forwarded only to the same site (`api.signalsant.com`), never to third parties.
293
301
  Cookies set by third parties are dropped.
294
302
  - **Mapped domains.** If you also map a domain (`api.signalsant.localhost` → `https://api.signalsant.com --cors`), its URLs in
@@ -91,6 +91,8 @@ const isDomainLabel = (value) => {
91
91
  exports.isDomainLabel = isDomainLabel;
92
92
  /** Sender allows the peer onto its localhost, treating an older sender (no `localhost` field) as "yes" when admin. */
93
93
  const localhostAllowed = (data, role) => typeof data.localhost === "boolean" ? data.localhost : role === "admin";
94
+ /** A shared --cors mapping is --cors here too; the sender still does the CORS work (see router forwardRemote). */
95
+ const inherited = (h) => (h.cors ? { cors: true } : {});
94
96
  /** Domains already spoken for: other remotes' `domain`, and any single-label `*.localhost` registry host. */
95
97
  const takenDomains = (excludeName) => {
96
98
  const store = readFile();
@@ -212,7 +214,7 @@ class Remotes {
212
214
  continue;
213
215
  }
214
216
  taken.add(local);
215
- registry.hosts[local] = { target: remote.url, remote: { name, host: h.host }, createdAt: now, updatedAt: now };
217
+ registry.hosts[local] = { target: remote.url, remote: { name, host: h.host }, ...inherited(h), createdAt: now, updatedAt: now };
216
218
  mapped.push({ local, host: h.host });
217
219
  }
218
220
  });
@@ -244,9 +246,14 @@ class Remotes {
244
246
  const now = new Date().toISOString();
245
247
  await registry_1.default.mutate((registry) => {
246
248
  const already = new Set();
247
- for (const entry of Object.values(registry.hosts)) {
248
- if (entry.remote?.name === name)
249
- already.add(entry.remote.host);
249
+ const flags = new Map(hosts.map((h) => [h.host, h]));
250
+ for (const [local, entry] of Object.entries(registry.hosts)) {
251
+ if (entry.remote?.name !== name)
252
+ continue;
253
+ already.add(entry.remote.host);
254
+ const h = flags.get(entry.remote.host);
255
+ if (h && Boolean(h.cors) !== Boolean(entry.cors))
256
+ registry.hosts[local] = { ...entry, cors: h.cors || undefined };
250
257
  }
251
258
  const taken = new Set(Object.keys(registry.hosts));
252
259
  for (const h of hosts) {
@@ -256,7 +263,7 @@ class Remotes {
256
263
  if (!local)
257
264
  continue;
258
265
  taken.add(local);
259
- registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, createdAt: now, updatedAt: now };
266
+ registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, ...inherited(h), createdAt: now, updatedAt: now };
260
267
  }
261
268
  });
262
269
  const registry = registry_1.default.read();
@@ -270,13 +277,14 @@ class Remotes {
270
277
  const local = localhost_1.default.requireHost(localHost);
271
278
  const hostsRes = await call(`${apiBase(remote.url)}/hosts`, { token: remote.token });
272
279
  const hosts = hostsRes.hosts;
273
- if (!hosts.some((h) => h.host === remoteHost))
280
+ const shared = hosts.find((h) => h.host === remoteHost);
281
+ if (!shared)
274
282
  throw new hosts_1.NotFoundError(`"${remoteHost}" is not available on "${name}".`);
275
283
  const now = new Date().toISOString();
276
284
  await registry_1.default.mutate((registry) => {
277
285
  if (registry.hosts[local])
278
286
  throw new hosts_1.ConflictError(`❌ ${local} is already mapped to ${registry.hosts[local].target}.`);
279
- registry.hosts[local] = { target: remote.url, remote: { name, host: remoteHost }, createdAt: now, updatedAt: now };
287
+ registry.hosts[local] = { target: remote.url, remote: { name, host: remoteHost }, ...inherited(shared), createdAt: now, updatedAt: now };
280
288
  });
281
289
  }
282
290
  static setUrl(name, url) {
@@ -329,7 +337,7 @@ class Remotes {
329
337
  const taken = new Set(Object.keys(registry.hosts));
330
338
  const local = Remotes.localName(name, created.host, taken);
331
339
  if (local)
332
- registry.hosts[local] = { target: remote.url, remote: { name, host: created.host }, createdAt: now, updatedAt: now };
340
+ registry.hosts[local] = { target: remote.url, remote: { name, host: created.host }, ...inherited(created), createdAt: now, updatedAt: now };
333
341
  });
334
342
  return created;
335
343
  }
@@ -340,7 +348,14 @@ class Remotes {
340
348
  token: remote.token,
341
349
  body: input,
342
350
  });
343
- return data.host;
351
+ const updated = data.host;
352
+ await registry_1.default.mutate((registry) => {
353
+ for (const [local, entry] of Object.entries(registry.hosts)) {
354
+ if (entry.remote?.name === name && entry.remote.host === host)
355
+ registry.hosts[local] = { ...entry, cors: updated.cors || undefined };
356
+ }
357
+ });
358
+ return updated;
344
359
  }
345
360
  static async removeHost(name, host) {
346
361
  const remote = Remotes.mustGet(name);
@@ -24,12 +24,16 @@ exports.preflightHeaders = preflightHeaders;
24
24
  /**
25
25
  * --cors: the upstream should see a request from a site it trusts, so origin/CSRF checks pass.
26
26
  * A page on another mapped domain (signalsant.localhost calling api.signalsant.localhost) is
27
- * sent as that domain's real origin; anything else as the target's own origin.
27
+ * sent as that domain's real origin; anything else as the target's own origin. A peer's page is on the
28
+ * receiver's name for one of our domains, so it is matched by those names, never by our own.
28
29
  */
29
30
  const sameOriginHeaders = (req, entry, lookup) => {
31
+ const names = (0, request_1.peerNamesOf)(req);
32
+ const callerHost = (url) => names ? Object.keys(names).find((host) => new URL(names[host]).host === url.host) : url.hostname;
30
33
  const upstreamOrigin = (value) => {
31
34
  try {
32
- const caller = lookup(new URL(value).hostname);
35
+ const host = callerHost(new URL(value));
36
+ const caller = host ? lookup(host) : undefined;
33
37
  if (caller)
34
38
  return new URL(caller.target).origin;
35
39
  }
@@ -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
@@ -87,6 +87,39 @@ const receiverHost = (req) => {
87
87
  const match = /^([^:]+)(?::(\d{1,5}))?$/.exec(raw);
88
88
  return match && localhost_1.default.isValidLocalhostDomain(match[1]) ? raw : undefined;
89
89
  };
90
+ const MAX_NAMES = 16 * 1024;
91
+ /**
92
+ * `X-Locadot-Names` from the receiver: `<our host>=<its URL for it>,…`. Kept only for hosts this peer can see and
93
+ * `http(s)://*.localhost[:port]` URLs; they only ever end up in responses to that same peer.
94
+ */
95
+ const peerNames = (req, visible) => {
96
+ const header = req.headers["x-locadot-names"];
97
+ if (typeof header !== "string" || header.length > MAX_NAMES)
98
+ return undefined;
99
+ const names = {};
100
+ for (const pair of header.split(",")) {
101
+ const [host, url] = pair.trim().toLowerCase().split("=");
102
+ const match = url ? /^https?:\/\/([^/:]+)(?::(\d{1,5}))?$/.exec(url) : null;
103
+ if (host && match && visible.includes(host) && localhost_1.default.isValidLocalhostDomain(match[1]))
104
+ names[host] = url;
105
+ }
106
+ return names;
107
+ };
108
+ /** Sender side: a --cors mapping whose target is this machine's `port`, so port access gets its cors too. */
109
+ const corsMappingFor = (hosts, port) => Object.keys(hosts).find((host) => {
110
+ const entry = hosts[host];
111
+ if (!entry.cors || entry.remote)
112
+ return false;
113
+ try {
114
+ const url = new URL(entry.target);
115
+ const own = Number(url.port || (url.protocol === "https:" ? 443 : 80));
116
+ return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) && own === port;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ });
122
+ const ownHosts = (hosts, peer) => links_1.default.visibleHosts(peer, Object.keys(hosts)).filter((host) => !hosts[host].remote);
90
123
  const stripLocadotHeaders = (req) => {
91
124
  for (const name of Object.keys(req.headers))
92
125
  if (name.toLowerCase().startsWith("x-locadot-"))
@@ -127,7 +160,7 @@ function classify(req, publicHost, hosts, opts) {
127
160
  if (!origin)
128
161
  return { kind: "deny", status: 400, message: "Bad request" };
129
162
  // Keep X-Locadot-Token: the dashboard page sends it on every mutating call.
130
- for (const name of ["x-locadot-peer", "x-locadot-dashboard", "x-locadot-host", "x-locadot-port"])
163
+ for (const name of ["x-locadot-peer", "x-locadot-dashboard", "x-locadot-host", "x-locadot-port", "x-locadot-names"])
131
164
  delete req.headers[name];
132
165
  return { kind: "dashboard", peer, origin };
133
166
  }
@@ -139,8 +172,15 @@ function classify(req, publicHost, hosts, opts) {
139
172
  return { kind: "deny", status: 400, message: "Bad request" };
140
173
  if (opts?.blockedPorts?.includes(port))
141
174
  return { kind: "deny", status: 403, message: "Forbidden" };
175
+ const names = req.headers["x-locadot-names"] === undefined ? undefined : peerNames(req, ownHosts(hosts, peer));
176
+ const host = corsMappingFor(hosts, port);
177
+ // The page stays on <port>.<domain>.localhost rather than moving to the receiver's name for the mapping.
178
+ const self = receiverHost(req);
179
+ const scheme = names && Object.values(names)[0]?.split(":")[0];
180
+ if (host && names && self && scheme)
181
+ names[host] = `${scheme}://${self}`;
142
182
  stripLocadotHeaders(req);
143
- return { kind: "local", port, peer };
183
+ return { kind: "local", port, peer, names, host, loopback: opts?.blockedPorts ?? [] };
144
184
  }
145
185
  const hostHeader = req.headers["x-locadot-host"];
146
186
  const host = typeof hostHeader === "string" ? hostHeader.trim().toLowerCase() : "";
@@ -152,8 +192,11 @@ function classify(req, publicHost, hosts, opts) {
152
192
  if (!visible.includes(host)) {
153
193
  return { kind: "deny", status: 403, message: "Forbidden" };
154
194
  }
195
+ const names = req.headers["x-locadot-names"] === undefined ? undefined : peerNames(req, visible);
155
196
  stripLocadotHeaders(req);
156
- return { kind: "app", host, peer };
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 };
157
200
  }
158
201
  /* ---------- /_locadot/v1 API ---------- */
159
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 { }
@@ -32,7 +32,7 @@ const remotes = () => {
32
32
  };
33
33
  const remoteFor = (name) => remotes()[name];
34
34
  exports.remoteFor = remoteFor;
35
- const remoteOptions = (req, entry, remote) => ({
35
+ const remoteOptions = (req, entry, remote, names) => ({
36
36
  target: remote.url,
37
37
  changeOrigin: true,
38
38
  // Don't leak the receiver's LAN addresses to the sender.
@@ -47,6 +47,7 @@ const remoteOptions = (req, entry, remote) => ({
47
47
  "X-Original-Host": req.headers.host || "",
48
48
  "X-Locadot-Peer": remote.token,
49
49
  "X-Locadot-Host": entry.remote.host,
50
+ ...(names ? { "X-Locadot-Names": names } : {}),
50
51
  },
51
52
  });
52
53
  exports.remoteOptions = remoteOptions;
@@ -75,7 +76,7 @@ exports.localFor = localFor;
75
76
  const canReachLocalhost = (remote) => remote.role === "admin" && remote.localhost !== false;
76
77
  exports.canReachLocalhost = canReachLocalhost;
77
78
  /** Without a port the sender serves its dashboard. */
78
- const localOptions = (req, remote, port) => ({
79
+ const localOptions = (req, remote, port, names) => ({
79
80
  target: remote.url,
80
81
  changeOrigin: true,
81
82
  xfwd: false,
@@ -88,7 +89,7 @@ const localOptions = (req, remote, port) => ({
88
89
  headers: {
89
90
  "X-Original-Host": req.headers.host || "",
90
91
  "X-Locadot-Peer": remote.token,
91
- ...(port ? { "X-Locadot-Port": String(port) } : { "X-Locadot-Dashboard": "1" }),
92
+ ...(port ? { "X-Locadot-Port": String(port), ...(names ? { "X-Locadot-Names": names } : {}) } : { "X-Locadot-Dashboard": "1" }),
92
93
  },
93
94
  });
94
95
  exports.localOptions = localOptions;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isTls = exports.viaOf = 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 });
@@ -23,6 +23,10 @@ const fromRemote = (req) => Boolean(tags.get(req)?.remote);
23
23
  exports.fromRemote = fromRemote;
24
24
  const peerOriginOf = (req) => tags.get(req)?.peerOrigin;
25
25
  exports.peerOriginOf = peerOriginOf;
26
+ const peerNamesOf = (req) => tags.get(req)?.peerNames;
27
+ exports.peerNamesOf = peerNamesOf;
28
+ const loopbackOf = (req) => tags.get(req)?.loopback;
29
+ exports.loopbackOf = loopbackOf;
26
30
  const viaOf = (req) => tags.get(req)?.via;
27
31
  exports.viaOf = viaOf;
28
32
  /** Tunnel visitors are on https even though cloudflared talks plain http to us. */
@@ -23,7 +23,8 @@ const handleProxyResponse = (proxyRes, req, res, hosts, publicUrl = () => undefi
23
23
  return;
24
24
  stripHopByHop(proxyRes.headers);
25
25
  const entry = hosts[(0, request_1.mappedHost)(req)];
26
- if (!entry?.cors)
26
+ // A remote mapping's cors flag is inherited: the sender already applied it.
27
+ if (!entry?.cors || entry.remote)
27
28
  return;
28
29
  (0, cors_1.applyCors)(req, proxyRes.headers);
29
30
  const via = (0, request_1.viaOf)(req);
@@ -25,9 +25,11 @@ const originMap = (req, hosts, publicUrl = () => undefined) => {
25
25
  const tls = (0, request_1.isTls)(req);
26
26
  const self = (0, request_1.mappedHost)(req);
27
27
  const tunnel = (0, request_1.fromTunnel)(req);
28
+ // A peer's browser knows our hosts by its own names; older receivers don't send them.
29
+ const peer = (0, request_1.peerNamesOf)(req);
28
30
  const pairs = [];
29
31
  for (const [host, entry] of Object.entries(hosts)) {
30
- const local = tunnel ? publicUrl(host) : (0, urls_1.urlFor)(host, tls);
32
+ const local = tunnel ? publicUrl(host) : peer ? peer[host] : (0, urls_1.urlFor)(host, tls);
31
33
  if (!local)
32
34
  continue;
33
35
  try {
@@ -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() });
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") {
@@ -28,8 +28,9 @@ const resolveHost = (req, ctx, hub) => {
28
28
  return "localhost";
29
29
  }
30
30
  if (hub?.kind === "local") {
31
- const host = `localhost:${hub.port}`;
32
- (0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure() });
31
+ // A port behind a --cors mapping is served as that mapping, cors included.
32
+ const host = hub.host ?? `localhost:${hub.port}`;
33
+ (0, request_1.tag)(req, { host, remote: true, secure: ctx.hub.secure(), peerNames: hub.names, loopback: hub.loopback });
33
34
  return host;
34
35
  }
35
36
  const host = (0, request_1.hostOf)(req);
@@ -40,8 +41,26 @@ const resolveHost = (req, ctx, hub) => {
40
41
  };
41
42
  const dashboardUrl = (req) => `${(0, urls_1.urlFor)("localhost", (0, request_1.isTls)(req))}/`;
42
43
  const passThrough = (req, entry) => Boolean(entry.cors);
43
- /** Same-origin only, and a tunnel visitor may only reach public hosts (see guard.ts). */
44
- 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);
54
+ /**
55
+ * Receiver side: `<sender host>=<our URL for it>` for every mapping of this remote, so a --cors sender rewrites
56
+ * its apps' origins to the names this machine uses rather than its own.
57
+ */
58
+ const peerNames = (req, ctx, name) => (ctx.remoteHosts?.(name) || [])
59
+ .flatMap((local) => {
60
+ const host = ctx.lookup(local)?.remote?.host;
61
+ return host ? [`${host}=${(0, urls_1.urlFor)(local, (0, request_1.isTls)(req))}`] : [];
62
+ })
63
+ .join(",");
45
64
  const reason = (err) => err?.code || err?.message;
46
65
  const remoteGone = (entry) => `the connection to ${entry.remote.name} was removed`;
47
66
  /** Sender side: an admin peer reaching one of this machine's ports. */
@@ -84,7 +103,7 @@ function handleRequest(req, res, ctx) {
84
103
  ctx.dashboard(req, res);
85
104
  return;
86
105
  }
87
- const entry = hub?.kind === "local" ? localEntry(hub.port) : ctx.lookup(host);
106
+ const entry = hub?.kind === "local" && !hub.host ? localEntry(hub.port) : ctx.lookup(host);
88
107
  if (!entry) {
89
108
  const local = localTarget(req, ctx, host, hub);
90
109
  if (local) {
@@ -155,7 +174,7 @@ function forwardRemote(req, res, ctx, host, entry) {
155
174
  }
156
175
  const started = Date.now();
157
176
  res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
158
- ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote), (err) => {
177
+ ctx.proxy.web(req, res, (0, remote_1.remoteOptions)(req, entry, remote, peerNames(req, ctx, remote.name)), (err) => {
159
178
  logger_1.default.warn(`${host} → ${remote.name} (${remote.url}): ${reason(err)}`);
160
179
  if (res.headersSent) {
161
180
  res.destroy();
@@ -182,7 +201,7 @@ function forwardLocal(req, res, ctx, host, local) {
182
201
  const label = `${remote.name}: ${port ? `localhost:${port}` : "dashboard"}`;
183
202
  const started = Date.now();
184
203
  res.once("finish", () => (0, stats_1.record)(ctx.stats, host, res.statusCode, Date.now() - started, res.statusCode >= 500));
185
- ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port), (err) => {
204
+ ctx.proxy.web(req, res, (0, remote_1.localOptions)(req, remote, port, port ? peerNames(req, ctx, remote.name) : undefined), (err) => {
186
205
  logger_1.default.warn(`${host} → ${label}: ${reason(err)}`);
187
206
  if (res.headersSent) {
188
207
  res.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "2.1.0-beta.2",
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",