@openparachute/hub 0.7.3-rc.10 → 0.7.3-rc.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.10",
3
+ "version": "0.7.3-rc.11",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -3,6 +3,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import {
7
+ _resetBootstrapTokenForTests,
8
+ generateBootstrapToken,
9
+ } from "../bootstrap-token.ts";
6
10
  import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
7
11
  import { HUB_SVC, hubPortPath } from "../hub-control.ts";
8
12
  import { createDbHolder } from "../hub-db-liveness.ts";
@@ -3676,6 +3680,74 @@ describe("layerOf — classify trust layer from proxy headers + peer (item E / #
3676
3680
  });
3677
3681
  expect(layerOf(r)).toBe("public");
3678
3682
  });
3683
+
3684
+ // Caddy/nginx-direct deploy (hub#704). A same-box reverse proxy dials
3685
+ // loopback (peer 127.0.0.1) and stamps NO cf/tailscale header — but it DOES
3686
+ // set a standard reverse-proxy forwarding header. That header is the
3687
+ // discriminator: a loopback peer carrying one is a proxied PUBLIC request and
3688
+ // must NOT be "loopback" (which would leak the bootstrap token + drop the
3689
+ // loopback-exposure cloak for every public visitor on a Caddy-direct box).
3690
+ test("loopback peer + X-Forwarded-For → public (Caddy-direct, NOT loopback) [#704]", () => {
3691
+ const r = req("/", { headers: { "X-Forwarded-For": "203.0.113.7" } });
3692
+ expect(layerOf(r, "127.0.0.1")).toBe("public");
3693
+ });
3694
+
3695
+ test("loopback peer + X-Forwarded-Host → public (Caddy-direct) [#704]", () => {
3696
+ const r = req("/", { headers: { "X-Forwarded-Host": "hub.example.com" } });
3697
+ expect(layerOf(r, "127.0.0.1")).toBe("public");
3698
+ });
3699
+
3700
+ test("loopback peer + Forwarded (RFC 7239) → public (Caddy-direct) [#704]", () => {
3701
+ const r = req("/", { headers: { Forwarded: "for=203.0.113.7;host=hub.example.com" } });
3702
+ expect(layerOf(r, "127.0.0.1")).toBe("public");
3703
+ });
3704
+
3705
+ test("IPv6-mapped loopback peer + X-Forwarded-For → public (Caddy-direct) [#704]", () => {
3706
+ const r = req("/", { headers: { "X-Forwarded-For": "203.0.113.7" } });
3707
+ expect(layerOf(r, "::ffff:127.0.0.1")).toBe("public");
3708
+ });
3709
+
3710
+ // Security edge: an EMPTY / whitespace-only forwarding header (a misconfigured
3711
+ // or stripped-but-present proxy header) must still err toward "public", never
3712
+ // back to "loopback". `layerOf` uses a presence check (`!== null`), not a
3713
+ // trim — keep it that way: the safe direction for a trust classifier is to
3714
+ // DOWNGRADE on ambiguity. A future "tidy up with .trim()" refactor that
3715
+ // flipped an empty XFF back to loopback would re-open the Caddy-direct leak.
3716
+ test("loopback peer + empty X-Forwarded-For → public (errs safe, not loopback) [#704]", () => {
3717
+ expect(layerOf(req("/", { headers: { "X-Forwarded-For": "" } }), "127.0.0.1")).toBe("public");
3718
+ expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe("public");
3719
+ });
3720
+
3721
+ // The genuine on-box caller (CLI, health probe, init bootstrap-token loopback
3722
+ // probe `curl 127.0.0.1/admin/setup`, hub self-requests) sets NO forwarding
3723
+ // header and MUST stay loopback. This is the not-broken half of the fix.
3724
+ test("loopback peer + NO forwarding header → loopback (genuine on-box caller) [#704]", () => {
3725
+ expect(layerOf(req("/"), "127.0.0.1")).toBe("loopback");
3726
+ expect(layerOf(req("/"), "::1")).toBe("loopback");
3727
+ });
3728
+
3729
+ // No spoof vector: forwarding headers can only DOWNGRADE a loopback caller.
3730
+ // A NON-loopback peer is already "public" regardless of headers, so adding
3731
+ // X-Forwarded-* never upgrades a network peer to a more-trusted layer.
3732
+ test("non-loopback peer + X-Forwarded-For → public (no upgrade; spoof-proof) [#704]", () => {
3733
+ const r = req("/", { headers: { "X-Forwarded-For": "127.0.0.1" } });
3734
+ expect(layerOf(r, "203.0.113.7")).toBe("public");
3735
+ });
3736
+
3737
+ // CF/TS header checks run FIRST — a Caddy-style forwarding header alongside a
3738
+ // cf/tailscale header doesn't change those (still public/tailnet). Order
3739
+ // preserved.
3740
+ test("CF-Ray + X-Forwarded-For from loopback peer → public (CF check wins, order preserved)", () => {
3741
+ const r = req("/", { headers: { "CF-Ray": "abc123-DEN", "X-Forwarded-For": "203.0.113.7" } });
3742
+ expect(layerOf(r, "127.0.0.1")).toBe("public");
3743
+ });
3744
+
3745
+ test("Tailscale-User-Login + X-Forwarded-For from loopback peer → tailnet (TS check wins)", () => {
3746
+ const r = req("/", {
3747
+ headers: { "Tailscale-User-Login": "alice@example.com", "X-Forwarded-For": "100.64.0.1" },
3748
+ });
3749
+ expect(layerOf(r, "127.0.0.1")).toBe("tailnet");
3750
+ });
3679
3751
  });
3680
3752
 
3681
3753
  describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
@@ -3823,6 +3895,74 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
3823
3895
  }
3824
3896
  });
3825
3897
 
3898
+ // hub#704 — Caddy/nginx-direct. A same-box reverse proxy dials loopback
3899
+ // (peer 127.0.0.1) and stamps NO cf/tailscale header, but DOES set
3900
+ // X-Forwarded-For. That request is a PUBLIC visitor proxied to the box and
3901
+ // must hit the loopback-exposure cloak (404) — otherwise a Caddy-direct box
3902
+ // leaks every loopback-only service/vault to the network.
3903
+ test("publicExposure: loopback + X-Forwarded-For + loopback peer → 404 (Caddy-direct cloak fires) [#704]", async () => {
3904
+ const h = makeHarness();
3905
+ const upstream = startUpstream("loopback-only");
3906
+ try {
3907
+ writeManifest(
3908
+ {
3909
+ services: [
3910
+ {
3911
+ name: "loopback-only",
3912
+ port: upstream.port,
3913
+ paths: ["/loopback-only"],
3914
+ health: "/loopback-only/health",
3915
+ version: "0.1.0",
3916
+ publicExposure: "loopback",
3917
+ },
3918
+ ],
3919
+ },
3920
+ h.manifestPath,
3921
+ );
3922
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3923
+ // Caddy: peer is 127.0.0.1 (reverse_proxy 127.0.0.1:1939) but the request
3924
+ // carries X-Forwarded-For for the real public client.
3925
+ const r = req("/loopback-only/health", { headers: { "X-Forwarded-For": "203.0.113.7" } });
3926
+ const res = await fetcher(r, fakeServer("127.0.0.1"));
3927
+ expect(res.status).toBe(404);
3928
+ } finally {
3929
+ upstream.stop();
3930
+ h.cleanup();
3931
+ }
3932
+ });
3933
+
3934
+ // The not-broken half: a header-less loopback peer (genuine on-box caller)
3935
+ // still reaches the loopback-only service through the cloak.
3936
+ test("publicExposure: loopback + NO forwarding header + loopback peer → reaches upstream [#704]", async () => {
3937
+ const h = makeHarness();
3938
+ const upstream = startUpstream("loopback-only");
3939
+ try {
3940
+ writeManifest(
3941
+ {
3942
+ services: [
3943
+ {
3944
+ name: "loopback-only",
3945
+ port: upstream.port,
3946
+ paths: ["/loopback-only"],
3947
+ health: "/loopback-only/health",
3948
+ version: "0.1.0",
3949
+ publicExposure: "loopback",
3950
+ },
3951
+ ],
3952
+ },
3953
+ h.manifestPath,
3954
+ );
3955
+ const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
3956
+ const res = await fetcher(req("/loopback-only/health"), fakeServer("127.0.0.1"));
3957
+ expect(res.status).toBe(200);
3958
+ const body = (await res.json()) as { tag: string };
3959
+ expect(body.tag).toBe("loopback-only");
3960
+ } finally {
3961
+ upstream.stop();
3962
+ h.cleanup();
3963
+ }
3964
+ });
3965
+
3826
3966
  test("publicExposure: allowed + tailnet header → reaches upstream (no gate)", async () => {
3827
3967
  const h = makeHarness();
3828
3968
  const upstream = startUpstream("allowed");
@@ -5850,3 +5990,103 @@ describe("resolveClientIp (H2)", () => {
5850
5990
  expect(resolveClientIp(r, null)).toBeNull();
5851
5991
  });
5852
5992
  });
5993
+
5994
+ describe("GET /admin/setup bootstrap-token probe — loopback-gated (hub#576 + Caddy-direct #704)", () => {
5995
+ // The hub#576 token probe: a LOOPBACK caller (the on-box operator's own
5996
+ // shell / `parachute init`) reading GET /admin/setup with Accept:
5997
+ // application/json gets the actual bootstrap token in the JSON envelope; a
5998
+ // public/tailnet caller does not. The hub derives requestIsLoopback from
5999
+ // `layerOf(req, peerAddr) === "loopback"` (hub-server.ts ~2296), so the
6000
+ // Caddy-direct fix (#704) flows straight through: a same-box reverse proxy
6001
+ // carrying X-Forwarded-For now classifies "public" and the token is withheld
6002
+ // even though the peer is 127.0.0.1.
6003
+
6004
+ // Fake Bun Server handle (mirrors the cloak suite) so the fetch fn resolves
6005
+ // the peer address. The on-box operator connects from 127.0.0.1; Caddy
6006
+ // reverse_proxy also dials 127.0.0.1 but stamps X-Forwarded-For.
6007
+ const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
6008
+
6009
+ test("loopback peer + NO forwarding header → bootstrapToken present (on-box operator) [#576]", async () => {
6010
+ const h = makeHarness();
6011
+ _resetBootstrapTokenForTests();
6012
+ const token = generateBootstrapToken();
6013
+ try {
6014
+ const db = openHubDb(hubDbPath(h.dir));
6015
+ try {
6016
+ // No admin row → wizard mode → GET /admin/setup JSON probe is live.
6017
+ const res = await hubFetch(h.dir, { getDb: () => db })(
6018
+ req("/admin/setup", { headers: { accept: "application/json" } }),
6019
+ fakeServer("127.0.0.1"),
6020
+ );
6021
+ expect(res.status).toBe(200);
6022
+ const body = (await res.json()) as {
6023
+ requireBootstrapToken?: boolean;
6024
+ bootstrapToken?: string;
6025
+ };
6026
+ expect(body.requireBootstrapToken).toBe(true);
6027
+ expect(body.bootstrapToken).toBe(token);
6028
+ } finally {
6029
+ db.close();
6030
+ }
6031
+ } finally {
6032
+ _resetBootstrapTokenForTests();
6033
+ h.cleanup();
6034
+ }
6035
+ });
6036
+
6037
+ test("loopback peer + X-Forwarded-For → bootstrapToken WITHHELD (Caddy-direct public visitor) [#704]", async () => {
6038
+ const h = makeHarness();
6039
+ _resetBootstrapTokenForTests();
6040
+ generateBootstrapToken();
6041
+ try {
6042
+ const db = openHubDb(hubDbPath(h.dir));
6043
+ try {
6044
+ // Caddy: peer 127.0.0.1, but the request carries the public client's
6045
+ // X-Forwarded-For. Pre-fix this leaked the token to any public visitor.
6046
+ const res = await hubFetch(h.dir, { getDb: () => db })(
6047
+ req("/admin/setup", {
6048
+ headers: { accept: "application/json", "x-forwarded-for": "203.0.113.7" },
6049
+ }),
6050
+ fakeServer("127.0.0.1"),
6051
+ );
6052
+ expect(res.status).toBe(200);
6053
+ const body = (await res.json()) as {
6054
+ requireBootstrapToken?: boolean;
6055
+ bootstrapToken?: string;
6056
+ };
6057
+ // The gate still SAYS a token is required (so the form renders the
6058
+ // field) — it just doesn't hand the value to the public caller.
6059
+ expect(body.requireBootstrapToken).toBe(true);
6060
+ expect(body.bootstrapToken).toBeUndefined();
6061
+ } finally {
6062
+ db.close();
6063
+ }
6064
+ } finally {
6065
+ _resetBootstrapTokenForTests();
6066
+ h.cleanup();
6067
+ }
6068
+ });
6069
+
6070
+ test("non-loopback peer → bootstrapToken WITHHELD (direct public/tailnet)", async () => {
6071
+ const h = makeHarness();
6072
+ _resetBootstrapTokenForTests();
6073
+ generateBootstrapToken();
6074
+ try {
6075
+ const db = openHubDb(hubDbPath(h.dir));
6076
+ try {
6077
+ const res = await hubFetch(h.dir, { getDb: () => db })(
6078
+ req("/admin/setup", { headers: { accept: "application/json" } }),
6079
+ fakeServer("203.0.113.9"),
6080
+ );
6081
+ expect(res.status).toBe(200);
6082
+ const body = (await res.json()) as { bootstrapToken?: string };
6083
+ expect(body.bootstrapToken).toBeUndefined();
6084
+ } finally {
6085
+ db.close();
6086
+ }
6087
+ } finally {
6088
+ _resetBootstrapTokenForTests();
6089
+ h.cleanup();
6090
+ }
6091
+ });
6092
+ });
package/src/hub-server.ts CHANGED
@@ -578,6 +578,16 @@ export type RequestLayer = "loopback" | "tailnet" | "public";
578
578
  * the cloak fires. When `peerAddr` is unknown (null/undefined — no Server
579
579
  * threaded, e.g. a unit test calling `layerOf(req)` directly), we fail CLOSED
580
580
  * to `public` rather than open to `loopback`.
581
+ *
582
+ * Caddy/nginx-direct (hub#704): a SAME-BOX reverse proxy dials loopback (peer
583
+ * is 127.0.0.1) but, unlike cloudflared/tailscale, sets NO cf/tailscale header
584
+ * — so a header-only-or-peer-only classifier would call every public request
585
+ * through it "loopback" (most-trusted). The discriminator is the standard
586
+ * reverse-proxy forwarding headers (X-Forwarded-For / X-Forwarded-Host /
587
+ * Forwarded): a loopback peer that carries one is a proxied PUBLIC request →
588
+ * `public`; a header-less loopback peer (direct on-box caller — CLI, probes,
589
+ * the init bootstrap-token loopback probe) stays `loopback`. See the inline
590
+ * comment in the function for the full rationale + spoof analysis.
581
591
  */
582
592
  export function layerOf(req: Request, peerAddr?: string | null): RequestLayer {
583
593
  const h = req.headers;
@@ -590,6 +600,42 @@ export function layerOf(req: Request, peerAddr?: string | null): RequestLayer {
590
600
  // value to compare against, hence the presence-check above.
591
601
  if (h.get("tailscale-funnel-request") === "?1") return "public";
592
602
  if (h.get("tailscale-user-login") !== null) return "tailnet";
603
+ // Caddy/nginx-direct deploy (hub#704): a same-box reverse proxy terminates
604
+ // TLS and `reverse_proxy 127.0.0.1:1939` — so it dials loopback (peer is
605
+ // 127.0.0.1) and, unlike cloudflared/tailscale, stamps NO cf/tailscale
606
+ // header. Without this branch every PUBLIC request through such a proxy
607
+ // would classify "loopback" (the MOST-trusted layer): the GET /admin/setup
608
+ // bootstrap-token JSON probe would hand the token to any public visitor, and
609
+ // the publicExposure:"loopback" 404-cloak would stop hiding loopback-only
610
+ // services/vaults from the network.
611
+ //
612
+ // The discriminator is the standard reverse-proxy forwarding headers. A
613
+ // same-box proxy carrying a PUBLIC request sets X-Forwarded-For /
614
+ // X-Forwarded-Host / Forwarded; a direct on-box caller (the CLI, health
615
+ // probes, the init bootstrap-token loopback probe `curl 127.0.0.1/admin/setup`,
616
+ // the hub's own loopback self-requests) sets none of them — the hub never
617
+ // injects X-Forwarded-* on the INBOUND request it classifies (it only stamps
618
+ // X-Forwarded-Host/Proto on OUTBOUND proxy requests to modules). So a
619
+ // loopback peer that ALSO carries a forwarding header is a proxied public
620
+ // request → "public"; a header-less loopback peer stays "loopback".
621
+ //
622
+ // No spoof vector: a NON-loopback peer is already "public" regardless of
623
+ // headers (the branch below), so adding these headers can only DOWNGRADE a
624
+ // loopback caller (the on-box operator hurting only their own request) —
625
+ // never upgrade a network peer to "loopback".
626
+ //
627
+ // Presence check (`!== null`), NOT a trim: an empty/whitespace forwarding
628
+ // header still means "a proxy is in front" → err to public. Downgrading on
629
+ // ambiguity is the safe direction for a trust classifier; a future ".trim()
630
+ // tidy-up" that let an empty XFF fall back to loopback would re-open the leak.
631
+ if (
632
+ isLoopbackPeer(peerAddr) &&
633
+ (h.get("x-forwarded-for") !== null ||
634
+ h.get("x-forwarded-host") !== null ||
635
+ h.get("forwarded") !== null)
636
+ ) {
637
+ return "public";
638
+ }
593
639
  // No proxy headers — classify by peer address, failing closed when unknown.
594
640
  return isLoopbackPeer(peerAddr) ? "loopback" : "public";
595
641
  }