@adrrr/tarmac 0.4.1 → 0.6.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/dist/server.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // so a read-only snapshot directory (the fleet's own, for the demo) is just a parameter.
5
5
  import http from 'node:http';
6
6
  import { reason, renderLive, renderPage } from './render.js';
7
- import { SOURCE_PHRASE } from './config.js';
7
+ import { hostName, SOURCE_PHRASE } from './config.js';
8
8
  import { createHistory, HISTORY_CADENCE_MS } from './history.js';
9
9
  /**
10
10
  * On every answer, including the refusals and the 500s. The page swaps what this port returns
@@ -20,7 +20,23 @@ const PAGES = new Map([
20
20
  ['/', 'table'],
21
21
  ['/map', 'map'],
22
22
  ]);
23
- export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS }) {
23
+ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS, trustedHosts = [] }) {
24
+ // Normalised HERE rather than trusted to arrive that way. This is the last thing between a
25
+ // foreign origin and the fleet, so it owns both sides of its own comparison — the config
26
+ // parser cuts a name the same way, and neither leans on the other having done it.
27
+ //
28
+ // The empty one is dropped for the same reason, and it is not tidiness: a name that
29
+ // normalises to nothing is a name that every Host normalising to nothing matches — `:8443`,
30
+ // a lone bracket — which would be a guard standing open on a list that looks set. Nothing
31
+ // reachable from a flag, a variable or a file gets here empty; that is the parser's promise,
32
+ // and this is the guard not resting on it.
33
+ const trusted = new Set(trustedHosts.map((h) => hostName(h.trim()).toLowerCase()).filter((h) => h !== ''));
34
+ // Which rule refused, decided once. With hosts named, "loopback hosts only" would read as a
35
+ // flag that never took; with none, this is the sentence it has always been, to the byte. The
36
+ // Host itself is never quoted back: it is the one string on the request the caller wrote.
37
+ const refusal = trusted.size === 0
38
+ ? 'tarmac serves loopback hosts only\n'
39
+ : 'tarmac serves loopback and trusted hosts only\n';
24
40
  // What this serve has already read, kept for a day and never written down. `since` is the
25
41
  // moment this server was made, not the first sample that landed: the span it covers is how
26
42
  // long the process has been up, and an hour of it with nothing in it is a fact worth
@@ -51,10 +67,12 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS
51
67
  };
52
68
  const server = http.createServer(async (req, res) => {
53
69
  // Loopback binding alone does not stop a DNS-rebinding page in the user's own browser
54
- // from reading /api/fleet — which carries cwd paths, session ids and costs.
55
- if (!isLoopbackHost(req.headers.host)) {
70
+ // from reading /api/fleet — which carries cwd paths, session ids and costs. Whatever a
71
+ // reader trusted on top of that is a name, exactly: matched whole, never as a prefix, a
72
+ // suffix or a pattern, so trusting one host can never be trusting a family of them.
73
+ if (!isLoopbackHost(req.headers.host) && !isTrustedHost(req.headers.host, trusted)) {
56
74
  res.writeHead(403, { ...IDENTITY, 'content-type': 'text/plain; charset=utf-8' });
57
- res.end('tarmac serves loopback hosts only\n');
75
+ res.end(refusal);
58
76
  return;
59
77
  }
60
78
  // The Host check stops another origin READING this port; it does not stop one poking it.
@@ -226,6 +244,30 @@ function attempt(server, port, host) {
226
244
  function isLoopbackHost(host) {
227
245
  if (!host)
228
246
  return false;
229
- const name = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
247
+ const name = hostName(host);
230
248
  return name === 'localhost' || name === '127.0.0.1' || name === '::1';
231
249
  }
250
+ /**
251
+ * One of the names the reader wrote down, matched whole — never as a prefix, a suffix or a
252
+ * pattern, and no wildcard is accepted into that list or honoured against a request.
253
+ *
254
+ * What is matched is the name `hostName` cuts out, and that cut is the loopback check's, kept
255
+ * shared rather than tightened: it drops the port, and it drops a leading `[` or a trailing
256
+ * `]` whether or not they pair. So `[name` and `name]` reach the comparison as `name`, exactly
257
+ * as `[localhost` has always reached it as `localhost`. Nothing is opened by it — `Host` is a
258
+ * forbidden header, a browser derives it from the URL, and a client free to type the header is
259
+ * free to type the name itself — and narrowing it here would change what the default answers.
260
+ *
261
+ * The port is not part of the name on either side: a proxy presents `name:8443` on one setup
262
+ * and a bare `name` on 443, and the port in a `Host` header is chosen by whoever sends it, so
263
+ * matching on it would have refused half the setups this exists for and barred nobody. Case is
264
+ * not part of it either — host names are case-insensitive, and the loopback names above are
265
+ * left exactly as strict as they have always been rather than loosened to match.
266
+ *
267
+ * `host` is whichever `Host` node reports; it reports the first when a request carries two.
268
+ */
269
+ function isTrustedHost(host, trusted) {
270
+ if (!host || trusted.size === 0)
271
+ return false;
272
+ return trusted.has(hostName(host).toLowerCase());
273
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrrr/tarmac",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
5
5
  "keywords": [
6
6
  "claude",