@dbx-tools/cli-tunnel 0.6.48 → 0.6.49
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 +93 -17
- package/index.ts +5 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -1
- package/lib/src/allowlist.d.ts +14 -9
- package/lib/src/allowlist.js +28 -53
- package/lib/src/cli.js +24 -16
- package/lib/src/env.d.ts +51 -0
- package/lib/src/env.js +51 -0
- package/lib/src/headers.d.ts +108 -0
- package/lib/src/headers.js +140 -0
- package/lib/src/otp.d.ts +1 -1
- package/lib/src/otp.js +7 -6
- package/lib/src/plugin.d.ts +6 -6
- package/lib/src/plugin.js +11 -8
- package/lib/src/portr.d.ts +3 -3
- package/lib/src/portr.js +10 -8
- package/lib/src/proxy.d.ts +8 -1
- package/lib/src/proxy.js +59 -19
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +22 -6
- package/src/allowlist.ts +27 -55
- package/src/cli.ts +29 -15
- package/src/env.ts +62 -0
- package/src/headers.ts +155 -0
- package/src/otp.ts +6 -5
- package/src/plugin.ts +23 -17
- package/src/portr.ts +9 -7
- package/src/proxy.ts +71 -18
package/src/cli.ts
CHANGED
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { type ChildProcess, spawn } from "node:child_process";
|
|
29
|
-
import { log } from "@dbx-tools/shared-core";
|
|
29
|
+
import { env, log } from "@dbx-tools/shared-core";
|
|
30
30
|
import { Command, CommanderError } from "commander";
|
|
31
31
|
import { startGateApp } from "./app.ts";
|
|
32
|
+
import { FORWARD_HEADERS_ENV, INSECURE_ENV } from "./env.ts";
|
|
32
33
|
import type { AuthGateConfig } from "./plugin.ts";
|
|
33
34
|
import { installPortr, resolvePortrConfig, startPortr, writePortrConfig } from "./portr.ts";
|
|
34
35
|
import { startProxy } from "./proxy.ts";
|
|
@@ -52,6 +53,7 @@ interface TunnelOpts {
|
|
|
52
53
|
sessionTtl?: string;
|
|
53
54
|
codeTtl?: string;
|
|
54
55
|
insecure?: boolean;
|
|
56
|
+
forwardHeaders?: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/** Build the commander program. `--` separates flags from the app start command. */
|
|
@@ -59,20 +61,24 @@ function program(): Command {
|
|
|
59
61
|
return new Command()
|
|
60
62
|
.name("dbx-tools-tunnel")
|
|
61
63
|
.description("Front an app with a public portr tunnel + email-OTP gate")
|
|
62
|
-
.option("--subject <text>", "Subject line for the code email (env
|
|
64
|
+
.option("--subject <text>", "Subject line for the code email (env TUNNEL_AUTH_SUBJECT)")
|
|
63
65
|
.option(
|
|
64
66
|
"--allow <patterns>",
|
|
65
|
-
"Comma/space-separated allow-list: domain / glob / /regex/ (env
|
|
67
|
+
"Comma/space-separated allow-list: domain / glob / /regex/ (env TUNNEL_AUTH_ALLOW)",
|
|
66
68
|
)
|
|
67
|
-
.option("--subdomain <name>", "portr subdomain (else derived from
|
|
68
|
-
.option("--public-domain <host>", "portr <subdomain>.<server> (env
|
|
69
|
+
.option("--subdomain <name>", "portr subdomain (else derived from TUNNEL_PUBLIC_DOMAIN)")
|
|
70
|
+
.option("--public-domain <host>", "portr <subdomain>.<server> (env TUNNEL_PUBLIC_DOMAIN)")
|
|
69
71
|
.option(
|
|
70
72
|
"--brand-name <name>",
|
|
71
|
-
"Display name in the code email copy (env
|
|
73
|
+
"Display name in the code email copy (env TUNNEL_AUTH_BRAND_NAME; defaults to the brand context name)",
|
|
74
|
+
)
|
|
75
|
+
.option("--message <text>", "Line shown above the code in the email (env TUNNEL_AUTH_MESSAGE)")
|
|
76
|
+
.option("--session-ttl <seconds>", "Session lifetime (env TUNNEL_AUTH_SESSION_TTL)")
|
|
77
|
+
.option("--code-ttl <seconds>", "One-time-code lifetime (env TUNNEL_AUTH_CODE_TTL)")
|
|
78
|
+
.option(
|
|
79
|
+
"--forward-headers <patterns>",
|
|
80
|
+
"Extra x- request headers tunnel traffic may forward: literal / glob / /regex/ (env TUNNEL_FORWARD_HEADERS)",
|
|
72
81
|
)
|
|
73
|
-
.option("--message <text>", "Line shown above the code in the email (env AUTH_MESSAGE)")
|
|
74
|
-
.option("--session-ttl <seconds>", "Session lifetime (env AUTH_SESSION_TTL)")
|
|
75
|
-
.option("--code-ttl <seconds>", "One-time-code lifetime (env AUTH_CODE_TTL)")
|
|
76
82
|
.option(
|
|
77
83
|
"--insecure",
|
|
78
84
|
"Run the tunnel OPEN with no gate (env TUNNEL_INSECURE=true). Otherwise the CLI fails fast when email SMTP is not configured.",
|
|
@@ -143,7 +149,7 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
143
149
|
// 2. Boot the gate app (cache + email transport + gate API). `startGateApp`
|
|
144
150
|
// FAILS FAST when email can't send codes (no SMTP). Insecure mode
|
|
145
151
|
// (`--insecure` / TUNNEL_INSECURE) skips the gate and runs the tunnel open.
|
|
146
|
-
const insecure = opts.insecure
|
|
152
|
+
const insecure = env.boolean(opts.insecure, INSECURE_ENV) ?? false;
|
|
147
153
|
let gate: Awaited<ReturnType<typeof startGateApp>> | undefined;
|
|
148
154
|
if (insecure) {
|
|
149
155
|
logger.warn("insecure mode - tunnel runs OPEN with no email-OTP gate");
|
|
@@ -159,7 +165,13 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
// 3. Start the gate proxy on the public port (open when `gate` is undefined).
|
|
162
|
-
|
|
168
|
+
// `forwardHeaders` only ADDS to the built-in allow-list; see `./headers.ts`.
|
|
169
|
+
await startProxy({
|
|
170
|
+
publicPort,
|
|
171
|
+
appPort,
|
|
172
|
+
gate,
|
|
173
|
+
forwardHeaders: env.list(opts.forwardHeaders, FORWARD_HEADERS_ENV),
|
|
174
|
+
});
|
|
163
175
|
|
|
164
176
|
// 4. Install + run portr when a tunnel is configured.
|
|
165
177
|
const portrConfig = resolvePortrConfig({
|
|
@@ -169,11 +181,13 @@ export async function runCli(argv: string[]): Promise<void> {
|
|
|
169
181
|
});
|
|
170
182
|
const children: ChildProcess[] = [app];
|
|
171
183
|
if (portrConfig) {
|
|
172
|
-
const
|
|
173
|
-
writePortrConfig(portrConfig,
|
|
174
|
-
children.push(startPortr(portrConfig,
|
|
184
|
+
const portrEnv = installPortr();
|
|
185
|
+
writePortrConfig(portrConfig, portrEnv);
|
|
186
|
+
children.push(startPortr(portrConfig, portrEnv));
|
|
175
187
|
} else {
|
|
176
|
-
logger.info(
|
|
188
|
+
logger.info(
|
|
189
|
+
"no PORTR_TOKEN/TUNNEL_PUBLIC_DOMAIN - serving the gate proxy without a public tunnel",
|
|
190
|
+
);
|
|
177
191
|
}
|
|
178
192
|
|
|
179
193
|
// Any child exit (or a signal) tears the whole thing down.
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment-variable names the tunnel reads.
|
|
3
|
+
*
|
|
4
|
+
* Every setting is `TUNNEL_`-prefixed, matching the repo convention of naming a
|
|
5
|
+
* variable after the package that owns it (`MASTRA_*`, `TEAMS_*`,
|
|
6
|
+
* `WEB_SEARCH_*`). The gate's original names were unprefixed (`AUTH_SUBJECT`,
|
|
7
|
+
* `PUBLIC_DOMAIN`, ...), which is a real hazard for a package that runs as a
|
|
8
|
+
* WRAPPER: the tunnel and the app it wraps share one environment, so a generic
|
|
9
|
+
* name is one the wrapped app may already use for something else, and
|
|
10
|
+
* `PUBLIC_DOMAIN` in particular reads like an app-wide setting rather than a
|
|
11
|
+
* portr detail. `EMAIL_AUTH_ALLOW` was worse than generic - it sat in
|
|
12
|
+
* `@dbx-tools/email`'s `EMAIL_*` namespace while configuring the gate, not email.
|
|
13
|
+
*
|
|
14
|
+
* Each entry is an {@link EnvKey} list, EARLIEST-WINS, whose first element is the
|
|
15
|
+
* current name and whose remaining elements are the deprecated originals. A
|
|
16
|
+
* deployment set up against the old names keeps working; nothing needs a
|
|
17
|
+
* coordinated rename. Read them through `env.string` / `env.positiveInt` /
|
|
18
|
+
* `env.list`, which accept the list directly.
|
|
19
|
+
*
|
|
20
|
+
* Not renamed:
|
|
21
|
+
*
|
|
22
|
+
* - `DATABRICKS_APP_PORT` - the Databricks Apps runtime contract. The platform
|
|
23
|
+
* sets it; the gate honours it.
|
|
24
|
+
* - `PORTR_TOKEN` / `PORTR_SERVER` / `PORTR_AUTO_ADD_PATH` - upstream
|
|
25
|
+
* [portr](https://github.com/amalshaji/portr)'s own namespace, and
|
|
26
|
+
* `PORTR_AUTO_ADD_PATH` is passed straight to that binary. Renaming these
|
|
27
|
+
* would rename someone else's contract.
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { EnvKey } from "@dbx-tools/shared-core";
|
|
33
|
+
|
|
34
|
+
/** Access allow-list patterns (domain / glob / `/regex/`). */
|
|
35
|
+
export const ALLOW_ENV: EnvKey = ["TUNNEL_AUTH_ALLOW", "EMAIL_AUTH_ALLOW"];
|
|
36
|
+
|
|
37
|
+
/** Subject line for the code email. */
|
|
38
|
+
export const SUBJECT_ENV: EnvKey = ["TUNNEL_AUTH_SUBJECT", "AUTH_SUBJECT"];
|
|
39
|
+
|
|
40
|
+
/** Display name used in the code email copy. */
|
|
41
|
+
export const BRAND_NAME_ENV: EnvKey = ["TUNNEL_AUTH_BRAND_NAME", "AUTH_BRAND_NAME"];
|
|
42
|
+
|
|
43
|
+
/** Line shown immediately above the code in the email. */
|
|
44
|
+
export const MESSAGE_ENV: EnvKey = ["TUNNEL_AUTH_MESSAGE", "AUTH_MESSAGE"];
|
|
45
|
+
|
|
46
|
+
/** Session lifetime, in seconds. */
|
|
47
|
+
export const SESSION_TTL_ENV: EnvKey = ["TUNNEL_AUTH_SESSION_TTL", "AUTH_SESSION_TTL"];
|
|
48
|
+
|
|
49
|
+
/** One-time-code lifetime, in seconds. */
|
|
50
|
+
export const CODE_TTL_ENV: EnvKey = ["TUNNEL_AUTH_CODE_TTL", "AUTH_CODE_TTL"];
|
|
51
|
+
|
|
52
|
+
/** HS256 signing secret for the session JWT. */
|
|
53
|
+
export const JWT_SECRET_ENV: EnvKey = ["TUNNEL_AUTH_JWT_SECRET", "AUTH_JWT_SECRET"];
|
|
54
|
+
|
|
55
|
+
/** The public `<subdomain>.<server>` portr should serve on. */
|
|
56
|
+
export const PUBLIC_DOMAIN_ENV: EnvKey = ["TUNNEL_PUBLIC_DOMAIN", "PUBLIC_DOMAIN"];
|
|
57
|
+
|
|
58
|
+
/** Run the tunnel OPEN, with no gate. Ignored unless truthy. */
|
|
59
|
+
export const INSECURE_ENV: EnvKey = "TUNNEL_INSECURE";
|
|
60
|
+
|
|
61
|
+
/** Extra `x-` request headers tunnel traffic may forward. */
|
|
62
|
+
export const FORWARD_HEADERS_ENV: EnvKey = "TUNNEL_FORWARD_HEADERS";
|
package/src/headers.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound header policy for tunnel traffic.
|
|
3
|
+
*
|
|
4
|
+
* Everything the gate forwards arrives from the PUBLIC internet through the
|
|
5
|
+
* portr client, so every header on it is attacker-controlled. The headers an app
|
|
6
|
+
* trusts are precisely the ones a caller must not be able to write, because the
|
|
7
|
+
* app cannot tell a header the Databricks front door set from one a browser
|
|
8
|
+
* typed.
|
|
9
|
+
*
|
|
10
|
+
* ## Policy shape: strip by default, allow by pattern
|
|
11
|
+
*
|
|
12
|
+
* Enumerating what to remove is a losing game - a deny-list is only correct until
|
|
13
|
+
* the platform adds a header or a library starts trusting another one - so the
|
|
14
|
+
* policy is inverted. EVERY `x-`-prefixed request header is dropped from tunnel
|
|
15
|
+
* traffic unless it matches a configured pattern. That fails CLOSED: a header
|
|
16
|
+
* nobody thought about is removed rather than trusted.
|
|
17
|
+
*
|
|
18
|
+
* The allow-list is zero-to-many literals, globs, or `/regex/`es, compiled by
|
|
19
|
+
* shared-core's {@link pattern.toPatternMatcher}, and is UNIONED with
|
|
20
|
+
* {@link DEFAULT_FORWARD_HEADERS} so extending it never silently breaks the
|
|
21
|
+
* built-in surfaces. Configure it with `forwardHeaders` /
|
|
22
|
+
* `TUNNEL_FORWARD_HEADERS`.
|
|
23
|
+
*
|
|
24
|
+
* Non-`x-` headers are untouched. Standard ones (`content-type`, `accept`,
|
|
25
|
+
* `authorization`, `cookie`, ...) are the app's normal input and the gate has no
|
|
26
|
+
* business rewriting them.
|
|
27
|
+
*
|
|
28
|
+
* ## The headers no pattern can forward
|
|
29
|
+
*
|
|
30
|
+
* {@link PROTECTED_HEADERS} is stripped BEFORE the allow-list is consulted, so a
|
|
31
|
+
* permissive pattern (`x-*`, or a careless `*`) cannot re-open impersonation:
|
|
32
|
+
*
|
|
33
|
+
* | Header | What an app does with it | Why spoofing it matters |
|
|
34
|
+
* | -------------------------------- | ------------------------------- | ----------------------- |
|
|
35
|
+
* | `x-forwarded-access-token` | OBO auth (AppKit `asUser`) | Paste any workspace token and every call runs as its owner. The gate's verified email says nothing about who a pasted credential belongs to. |
|
|
36
|
+
* | `x-forwarded-user` | Caller identity | Impersonate another user. The gate sets this itself, from a verified session. |
|
|
37
|
+
* | `x-forwarded-email` | Caller identity | Same. |
|
|
38
|
+
* | `x-forwarded-preferred-username` | Display name from the IdP | Same. |
|
|
39
|
+
* | `x-forwarded-host` | The originally-requested host | Poison absolute URLs the app builds (the classic reset-link attack). |
|
|
40
|
+
* | `x-forwarded-proto` / `-port` | Original scheme / port | Convince the app a plaintext request arrived over TLS. |
|
|
41
|
+
* | `x-forwarded-for` | Client IP | Forge the audit trail, and fan out per-IP rate-limit keys (see below). |
|
|
42
|
+
* | `x-real-ip` | Client IP | Same. |
|
|
43
|
+
* | `x-request-id` | Request correlation UUID | Forge or collide trace ids, making logs unreliable. |
|
|
44
|
+
*
|
|
45
|
+
* The identity four are AppKit's OBO contract; the rest are the `X-Forwarded-*`
|
|
46
|
+
* set the Databricks Apps reverse proxy documents passing to an app
|
|
47
|
+
* ({@link https://docs.databricks.com/aws/en/dev-tools/databricks-apps/http-headers}),
|
|
48
|
+
* plus the conventional `x-forwarded-proto`/`-port`/`x-real-ip` an app or one of
|
|
49
|
+
* its libraries may read even though the table omits them.
|
|
50
|
+
*
|
|
51
|
+
* Stripping the `x-forwarded-*` transport trio is safe because `http-proxy-3` is
|
|
52
|
+
* configured with `xfwd: true` and re-adds them AFTER this policy runs - from the
|
|
53
|
+
* real socket, not from the caller's claim. The app therefore sees the honest
|
|
54
|
+
* (loopback) values instead of whatever the internet asserted. The gate reads the
|
|
55
|
+
* client IP for rate limiting from the raw inbound headers BEFORE stripping, and
|
|
56
|
+
* takes the RIGHTMOST `x-forwarded-for` entry, which is the only one a proxy
|
|
57
|
+
* appended rather than a client supplied.
|
|
58
|
+
*
|
|
59
|
+
* @module
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { pattern, token, type Predicate } from "@dbx-tools/shared-core";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Headers the gate strips UNCONDITIONALLY, before the allow-list is consulted -
|
|
66
|
+
* the platform-shaped set documented in this module's table.
|
|
67
|
+
*
|
|
68
|
+
* These answer WHO a request is and WHERE it came from, and on tunnel traffic
|
|
69
|
+
* only the gate may answer that: it injects {@link token.USER_ID_HEADER} /
|
|
70
|
+
* {@link token.USER_EMAIL_HEADER} itself after verifying a session, and the proxy
|
|
71
|
+
* re-derives the transport headers from the real socket.
|
|
72
|
+
*
|
|
73
|
+
* The identity names come from the shared `token` constants, so a renamed wire
|
|
74
|
+
* contract cannot leave a stale spelling here. `x-forwarded-preferred-username`
|
|
75
|
+
* has no constant because nothing in this repo READS it - it is listed precisely
|
|
76
|
+
* so a host app that does read it cannot be fed one.
|
|
77
|
+
*/
|
|
78
|
+
export const PROTECTED_HEADERS: readonly string[] = [
|
|
79
|
+
token.ACCESS_TOKEN_HEADER,
|
|
80
|
+
token.USER_ID_HEADER,
|
|
81
|
+
token.USER_EMAIL_HEADER,
|
|
82
|
+
"x-forwarded-preferred-username",
|
|
83
|
+
"x-forwarded-host",
|
|
84
|
+
"x-forwarded-proto",
|
|
85
|
+
"x-forwarded-port",
|
|
86
|
+
"x-forwarded-for",
|
|
87
|
+
"x-real-ip",
|
|
88
|
+
"x-request-id",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The `x-` headers forwarded when a deployment configures nothing.
|
|
93
|
+
*
|
|
94
|
+
* These are the header NAMESPACES this repo's own UI sends and its own server
|
|
95
|
+
* reads - Mastra thread/model routing (`x-mastra-thread-id`, `x-mastra-model`)
|
|
96
|
+
* and MLflow trace correlation (`x-mlflow-trace-id`) - so a dbx-tools app keeps
|
|
97
|
+
* working behind the tunnel with no configuration.
|
|
98
|
+
*
|
|
99
|
+
* Deliberately GLOBS rather than the exact constants from
|
|
100
|
+
* `@dbx-tools/shared-mastra`: importing them would make the gate - a
|
|
101
|
+
* transport-level component that has no other opinion about Mastra - depend on
|
|
102
|
+
* an agent package for three strings, and a namespace glob also covers the next
|
|
103
|
+
* header those packages add. The namespaces are the stable part of the contract.
|
|
104
|
+
*/
|
|
105
|
+
export const DEFAULT_FORWARD_HEADERS: readonly string[] = [
|
|
106
|
+
"x-mastra-*",
|
|
107
|
+
"x-mlflow-*",
|
|
108
|
+
// Sent by fetch/XHR wrappers to mark an AJAX request; harmless and widely read.
|
|
109
|
+
"x-requested-with",
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/** A compiled inbound-header policy. Build one with {@link toHeaderPolicy}. */
|
|
113
|
+
export interface HeaderPolicy {
|
|
114
|
+
/** The allow-list entries backing this policy (for diagnostics and tests). */
|
|
115
|
+
readonly patterns: readonly string[];
|
|
116
|
+
/** Whether `name` survives on tunnel traffic. */
|
|
117
|
+
forwards(name: string): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Delete every disallowed header from a mutable Node header bag, returning the
|
|
120
|
+
* lower-cased names removed (for debug logging).
|
|
121
|
+
*/
|
|
122
|
+
apply(headers: Record<string, unknown>): string[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Compile the inbound-header policy. `configured` entries are UNIONED with
|
|
127
|
+
* {@link DEFAULT_FORWARD_HEADERS}; each may be a literal name, a glob
|
|
128
|
+
* (`x-myapp-*`), or a `/regex/`. Matching is case-insensitive.
|
|
129
|
+
*/
|
|
130
|
+
export function toHeaderPolicy(configured: readonly string[] = []): HeaderPolicy {
|
|
131
|
+
const patterns = [...DEFAULT_FORWARD_HEADERS, ...configured];
|
|
132
|
+
const allowed: Predicate<string> = pattern.toPatternMatcher(patterns);
|
|
133
|
+
const protectedNames = new Set(PROTECTED_HEADERS);
|
|
134
|
+
|
|
135
|
+
const forwards = (name: string): boolean => {
|
|
136
|
+
const lower = name.toLowerCase();
|
|
137
|
+
if (protectedNames.has(lower)) return false;
|
|
138
|
+
if (!lower.startsWith("x-")) return true;
|
|
139
|
+
return allowed(lower);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
patterns,
|
|
144
|
+
forwards,
|
|
145
|
+
apply: (headers) => {
|
|
146
|
+
const removed: string[] = [];
|
|
147
|
+
for (const name of Object.keys(headers)) {
|
|
148
|
+
if (forwards(name)) continue;
|
|
149
|
+
delete headers[name];
|
|
150
|
+
removed.push(name.toLowerCase());
|
|
151
|
+
}
|
|
152
|
+
return removed;
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/otp.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* the hash, and the entry is deleted on success or once attempts are exhausted.
|
|
10
10
|
*
|
|
11
11
|
* The session JWT is a short-lived HS256 token (via `jose`) carrying only the
|
|
12
|
-
* email. Its signing key comes from `
|
|
12
|
+
* email. Its signing key comes from `TUNNEL_AUTH_JWT_SECRET`; when unset the gate uses an
|
|
13
13
|
* ephemeral per-process key rather than refusing service, so an unset secret
|
|
14
14
|
* degrades to "sessions don't survive a restart", not "nobody can log in". Note
|
|
15
15
|
* what this does NOT weaken: a caller still needs a code delivered to an
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
|
|
23
23
|
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
24
24
|
import { CacheManager } from "@databricks/appkit";
|
|
25
|
-
import { log } from "@dbx-tools/shared-core";
|
|
25
|
+
import { env, log } from "@dbx-tools/shared-core";
|
|
26
26
|
import { jwtVerify, SignJWT } from "jose";
|
|
27
|
+
import { JWT_SECRET_ENV } from "./env.ts";
|
|
27
28
|
|
|
28
29
|
const logger = log.logger("tunnel:otp");
|
|
29
30
|
|
|
@@ -107,18 +108,18 @@ export class CodeStore {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
/**
|
|
110
|
-
* Resolve the HS256 signing key. Prefers `
|
|
111
|
+
* Resolve the HS256 signing key. Prefers `TUNNEL_AUTH_JWT_SECRET`; when unset, mints an
|
|
111
112
|
* ephemeral per-process key (fail-open) and warns once. Memoized.
|
|
112
113
|
*/
|
|
113
114
|
let cachedKey: Uint8Array | undefined;
|
|
114
115
|
function signingKey(): Uint8Array {
|
|
115
116
|
if (cachedKey) return cachedKey;
|
|
116
|
-
const secret =
|
|
117
|
+
const secret = env.text(JWT_SECRET_ENV);
|
|
117
118
|
if (secret) {
|
|
118
119
|
cachedKey = new TextEncoder().encode(secret);
|
|
119
120
|
} else {
|
|
120
121
|
logger.warn(
|
|
121
|
-
|
|
122
|
+
`${JWT_SECRET_ENV[0]} is not set - using an ephemeral per-process key; sessions will not survive a restart`,
|
|
122
123
|
);
|
|
123
124
|
cachedKey = randomBytes(32);
|
|
124
125
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -20,6 +20,14 @@ import { Plugin, toPlugin, type BasePluginConfig, type PluginManifest } from "@d
|
|
|
20
20
|
import { brand, env, log, string } from "@dbx-tools/shared-core";
|
|
21
21
|
import type { AuthStatus } from "@dbx-tools/shared-email";
|
|
22
22
|
import { looksLikeEmail, matchesAllowlist } from "./allowlist.ts";
|
|
23
|
+
import {
|
|
24
|
+
ALLOW_ENV,
|
|
25
|
+
BRAND_NAME_ENV,
|
|
26
|
+
CODE_TTL_ENV,
|
|
27
|
+
MESSAGE_ENV,
|
|
28
|
+
SESSION_TTL_ENV,
|
|
29
|
+
SUBJECT_ENV,
|
|
30
|
+
} from "./env.ts";
|
|
23
31
|
import { CodeStore, signSession, verifySession } from "./otp.ts";
|
|
24
32
|
import { RateLimiter } from "./rate-limit.ts";
|
|
25
33
|
|
|
@@ -27,10 +35,10 @@ const logger = log.logger("tunnel:auth");
|
|
|
27
35
|
|
|
28
36
|
/** Options for the {@link authGate} plugin (all resolvable from env - see below). */
|
|
29
37
|
export interface AuthGateConfig extends BasePluginConfig {
|
|
30
|
-
/** Allow-list patterns (domain / glob / `/regex/`). Empty = allow nobody. Env
|
|
38
|
+
/** Allow-list patterns (domain / glob / `/regex/`). Empty = allow nobody. Env TUNNEL_AUTH_ALLOW. */
|
|
31
39
|
allow?: string | string[];
|
|
32
40
|
/**
|
|
33
|
-
* Subject line for the code email. Env
|
|
41
|
+
* Subject line for the code email. Env TUNNEL_AUTH_SUBJECT.
|
|
34
42
|
*
|
|
35
43
|
* Defaults to "Your verification code". The wording of the subject and
|
|
36
44
|
* {@link message} is deliberately the conventional phrasing rather than
|
|
@@ -40,7 +48,7 @@ export interface AuthGateConfig extends BasePluginConfig {
|
|
|
40
48
|
*/
|
|
41
49
|
subject?: string;
|
|
42
50
|
/**
|
|
43
|
-
* Display name used in the code email copy. Env
|
|
51
|
+
* Display name used in the code email copy. Env TUNNEL_AUTH_BRAND_NAME.
|
|
44
52
|
*
|
|
45
53
|
* Defaults to the brand context's `name` - the app's own `branding/brand.yaml`
|
|
46
54
|
* when it has one, else the dbx-tools default. Set this only to override the
|
|
@@ -48,15 +56,15 @@ export interface AuthGateConfig extends BasePluginConfig {
|
|
|
48
56
|
*/
|
|
49
57
|
brandName?: string;
|
|
50
58
|
/**
|
|
51
|
-
* Line shown immediately above the code in the email. Env
|
|
59
|
+
* Line shown immediately above the code in the email. Env TUNNEL_AUTH_MESSAGE.
|
|
52
60
|
*
|
|
53
61
|
* Keep the code on its OWN line directly after this text - that adjacency is
|
|
54
62
|
* what the platform code-detection heuristics key on.
|
|
55
63
|
*/
|
|
56
64
|
message?: string;
|
|
57
|
-
/** Session lifetime (seconds). Env
|
|
65
|
+
/** Session lifetime (seconds). Env TUNNEL_AUTH_SESSION_TTL. Default 43200 (12h). */
|
|
58
66
|
sessionTtlSeconds?: number;
|
|
59
|
-
/** One-time-code lifetime (seconds). Env
|
|
67
|
+
/** One-time-code lifetime (seconds). Env TUNNEL_AUTH_CODE_TTL. Default 600 (10m). */
|
|
60
68
|
codeTtlSeconds?: number;
|
|
61
69
|
/** Max verify attempts per issued code. Default 5. */
|
|
62
70
|
maxAttempts?: number;
|
|
@@ -108,21 +116,19 @@ const DEFAULTS = {
|
|
|
108
116
|
export function resolveAuthGateConfig(config: AuthGateConfig): ResolvedAuthGateConfig {
|
|
109
117
|
return {
|
|
110
118
|
// Both sources are unioned rather than one overriding: a deployment-wide
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
// TUNNEL_AUTH_ALLOW and a per-invocation `--allow` should both grant access.
|
|
120
|
+
// Hence `parseList` on each rather than `env.list`, which stops at the first
|
|
121
|
+
// source that yields anything.
|
|
122
|
+
allow: [...string.parseList(config.allow), ...string.parseList(env.text(ALLOW_ENV))],
|
|
123
|
+
subject: env.string(config.subject, SUBJECT_ENV) ?? DEFAULTS.subject,
|
|
124
|
+
brandName: env.string(config.brandName, BRAND_NAME_ENV) ?? DEFAULTS.brandName,
|
|
125
|
+
message: env.string(config.message, MESSAGE_ENV) ?? DEFAULTS.message,
|
|
116
126
|
sessionTtlSeconds: env.positiveInt(
|
|
117
127
|
config.sessionTtlSeconds,
|
|
118
|
-
|
|
128
|
+
SESSION_TTL_ENV,
|
|
119
129
|
DEFAULTS.sessionTtlSeconds,
|
|
120
130
|
),
|
|
121
|
-
codeTtlSeconds: env.positiveInt(
|
|
122
|
-
config.codeTtlSeconds,
|
|
123
|
-
"AUTH_CODE_TTL",
|
|
124
|
-
DEFAULTS.codeTtlSeconds,
|
|
125
|
-
),
|
|
131
|
+
codeTtlSeconds: env.positiveInt(config.codeTtlSeconds, CODE_TTL_ENV, DEFAULTS.codeTtlSeconds),
|
|
126
132
|
maxAttempts: config.maxAttempts ?? DEFAULTS.maxAttempts,
|
|
127
133
|
};
|
|
128
134
|
}
|
package/src/portr.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* on cold start, so the portr binary and its config are placed under a writable,
|
|
6
6
|
* cwd-rooted `.home` unconditionally - it costs nothing where `$HOME` is writable.
|
|
7
7
|
* The install is idempotent (the installer skips when the on-PATH binary is
|
|
8
|
-
* current). The config is rendered from `
|
|
8
|
+
* current). The config is rendered from `TUNNEL_PUBLIC_DOMAIN` (`<subdomain>.<server>`)
|
|
9
9
|
* + `PORTR_TOKEN` and points portr at the PUBLIC port (the proxy listens there).
|
|
10
10
|
*
|
|
11
11
|
* @module
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
import { spawn, spawnSync } from "node:child_process";
|
|
15
15
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { delimiter, join } from "node:path";
|
|
17
|
-
import { log } from "@dbx-tools/shared-core";
|
|
17
|
+
import { env, log } from "@dbx-tools/shared-core";
|
|
18
|
+
import { PUBLIC_DOMAIN_ENV } from "./env.ts";
|
|
18
19
|
|
|
19
20
|
const logger = log.logger("tunnel:portr");
|
|
20
21
|
|
|
@@ -27,8 +28,8 @@ export interface PortrConfig {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
* Resolve portr config from `
|
|
31
|
-
* `subdomain`.
|
|
31
|
+
* Resolve portr config from `TUNNEL_PUBLIC_DOMAIN` + `PORTR_TOKEN`, or an
|
|
32
|
+
* explicit `subdomain`. The domain is `<subdomain>.<server>` (e.g.
|
|
32
33
|
* `demo.apps.dbx.tools`). Returns `undefined` (no tunnel) when the token or a
|
|
33
34
|
* usable domain is absent.
|
|
34
35
|
*/
|
|
@@ -38,8 +39,9 @@ export function resolvePortrConfig(opts: {
|
|
|
38
39
|
token?: string;
|
|
39
40
|
port: number;
|
|
40
41
|
}): PortrConfig | undefined {
|
|
41
|
-
|
|
42
|
-
const
|
|
42
|
+
// PORTR_* is upstream portr's own namespace, so it keeps its name.
|
|
43
|
+
const token = env.string(opts.token, "PORTR_TOKEN");
|
|
44
|
+
const domain = env.string(opts.publicDomain, PUBLIC_DOMAIN_ENV);
|
|
43
45
|
if (!token) return undefined;
|
|
44
46
|
let subdomain = opts.subdomain;
|
|
45
47
|
let server: string | undefined;
|
|
@@ -47,7 +49,7 @@ export function resolvePortrConfig(opts: {
|
|
|
47
49
|
subdomain ??= domain.split(".")[0];
|
|
48
50
|
server = domain.slice(domain.indexOf(".") + 1);
|
|
49
51
|
}
|
|
50
|
-
server ??=
|
|
52
|
+
server ??= env.text("PORTR_SERVER") ?? undefined;
|
|
51
53
|
if (!subdomain || !server || server === domain) return undefined;
|
|
52
54
|
return { subdomain, server, token, port: opts.port };
|
|
53
55
|
}
|
package/src/proxy.ts
CHANGED
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
|
|
28
28
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
29
29
|
import type { Socket } from "node:net";
|
|
30
|
-
import { http, json, log } from "@dbx-tools/shared-core";
|
|
30
|
+
import { http, json, log, token } from "@dbx-tools/shared-core";
|
|
31
31
|
import { authRequestSchema, authVerifySchema, SESSION_COOKIE_NAME } from "@dbx-tools/shared-email";
|
|
32
32
|
import ProxyModule from "http-proxy-3";
|
|
33
|
+
import { toHeaderPolicy, type HeaderPolicy } from "./headers.ts";
|
|
33
34
|
import type { AuthGateApi } from "./plugin.ts";
|
|
34
35
|
|
|
35
36
|
const logger = log.logger("tunnel:proxy");
|
|
@@ -74,10 +75,22 @@ export interface ProxyOptions {
|
|
|
74
75
|
* operator passed `--insecure` / `TUNNEL_INSECURE=true`.
|
|
75
76
|
*/
|
|
76
77
|
gate?: AuthGateApi;
|
|
78
|
+
/**
|
|
79
|
+
* Extra `x-` request headers tunnel traffic may forward, as literals, globs, or
|
|
80
|
+
* `/regex/`es - unioned with {@link DEFAULT_FORWARD_HEADERS}. Every other `x-`
|
|
81
|
+
* header is stripped, and {@link PROTECTED_HEADERS} is stripped regardless. See
|
|
82
|
+
* `./headers.ts` for why the policy is an allow-list.
|
|
83
|
+
*/
|
|
84
|
+
forwardHeaders?: readonly string[];
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
/** Start the gate proxy. Resolves once it is listening. */
|
|
80
|
-
export function startProxy({
|
|
88
|
+
export function startProxy({
|
|
89
|
+
publicPort,
|
|
90
|
+
appPort,
|
|
91
|
+
gate,
|
|
92
|
+
forwardHeaders,
|
|
93
|
+
}: ProxyOptions): Promise<void> {
|
|
81
94
|
const proxy = ProxyModule.createProxyServer({
|
|
82
95
|
target: { host: "127.0.0.1", port: appPort },
|
|
83
96
|
ws: true,
|
|
@@ -91,6 +104,10 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
|
|
|
91
104
|
}
|
|
92
105
|
});
|
|
93
106
|
|
|
107
|
+
// Compiled once: the inbound-header allow-list applied to every gated request.
|
|
108
|
+
const headerPolicy = toHeaderPolicy(forwardHeaders);
|
|
109
|
+
logger.debug("inbound header policy", { forward: headerPolicy.patterns });
|
|
110
|
+
|
|
94
111
|
/** The session cookie for a verified email, as a Set-Cookie string. */
|
|
95
112
|
const sessionCookie = (token: string, maxAgeSeconds: number): string =>
|
|
96
113
|
[
|
|
@@ -104,11 +121,25 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
|
|
|
104
121
|
.filter(Boolean)
|
|
105
122
|
.join("; ");
|
|
106
123
|
|
|
107
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Client IP for rate-limiting: the portr client's forwarded XFF, else the socket.
|
|
126
|
+
*
|
|
127
|
+
* Deliberately the RIGHTMOST `x-forwarded-for` entry, not the leftmost. The
|
|
128
|
+
* list grows left-to-right as each hop appends, so the last entry is the one
|
|
129
|
+
* the nearest trusted proxy (portr) wrote and every earlier entry is a value
|
|
130
|
+
* the caller could have sent. Reading the leftmost lets a client vary one
|
|
131
|
+
* header to get a fresh rate-limit bucket per request, which defeats both the
|
|
132
|
+
* per-IP code-request and verify-attempt limiters. Called BEFORE
|
|
133
|
+
* {@link HeaderPolicy.apply} strips the header, so the honest hop value is
|
|
134
|
+
* still available here.
|
|
135
|
+
*/
|
|
108
136
|
const clientIp = (req: IncomingMessage): string => {
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
|
|
137
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
138
|
+
const chain = (Array.isArray(forwarded) ? forwarded.join(",") : (forwarded ?? ""))
|
|
139
|
+
.split(",")
|
|
140
|
+
.map((entry) => entry.trim())
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
return chain.at(-1) ?? req.socket.remoteAddress ?? "unknown";
|
|
112
143
|
};
|
|
113
144
|
|
|
114
145
|
/**
|
|
@@ -129,14 +160,38 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
|
|
|
129
160
|
|
|
130
161
|
/**
|
|
131
162
|
* Present an OTP-authenticated caller to the app the SAME way a platform front
|
|
132
|
-
* door does: set
|
|
133
|
-
*
|
|
134
|
-
* needs no gate-specific code path.
|
|
135
|
-
*
|
|
163
|
+
* door does: set the front door's own identity headers to the verified address
|
|
164
|
+
* (AppKit reads {@link token.USER_ID_HEADER} for the OBO user id), so the app
|
|
165
|
+
* needs no gate-specific code path. The names come from
|
|
166
|
+
* `@dbx-tools/shared-core`'s `token` module - the same constants AppKit-side
|
|
167
|
+
* code reads them by - rather than being re-spelled here.
|
|
168
|
+
*
|
|
169
|
+
* What the gate CANNOT set is {@link token.ACCESS_TOKEN_HEADER}: an OTP session
|
|
170
|
+
* proves an email address, not possession of a Databricks credential, and
|
|
171
|
+
* there is no way to mint one for the caller. Its absence is exactly what
|
|
172
|
+
* `@dbx-tools/appkit`'s `identity` `"auto"` mode detects, so a gated request
|
|
173
|
+
* runs as the app's service principal instead of throwing.
|
|
136
174
|
*/
|
|
137
175
|
const injectIdentity = (req: IncomingMessage, email: string): void => {
|
|
138
|
-
req.headers[
|
|
139
|
-
req.headers[
|
|
176
|
+
req.headers[token.USER_ID_HEADER] = email;
|
|
177
|
+
req.headers[token.USER_EMAIL_HEADER] = email;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Apply the inbound-header policy to a tunnel request: every `x-` header the
|
|
182
|
+
* allow-list does not name is deleted, and the platform identity/transport set
|
|
183
|
+
* is deleted regardless. See `./headers.ts` for the reasoning; the
|
|
184
|
+
* security-critical case is {@link token.ACCESS_TOKEN_HEADER}, which the gate
|
|
185
|
+
* never sets but an app running `identity: "auto"` treats as proof the request
|
|
186
|
+
* can do OBO.
|
|
187
|
+
*
|
|
188
|
+
* `xfwd: true` on the proxy re-adds `x-forwarded-for`/`-proto`/`-port`/`-host`
|
|
189
|
+
* afterwards from the real socket, so the app still sees those - it just sees
|
|
190
|
+
* the honest values rather than the caller's claim.
|
|
191
|
+
*/
|
|
192
|
+
const applyHeaderPolicy = (req: IncomingMessage): void => {
|
|
193
|
+
const removed = headerPolicy.apply(req.headers as Record<string, unknown>);
|
|
194
|
+
if (removed.length) logger.debug("stripped inbound headers", { removed });
|
|
140
195
|
};
|
|
141
196
|
|
|
142
197
|
const server = createServer(async (req, res) => {
|
|
@@ -184,10 +239,9 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
|
|
|
184
239
|
);
|
|
185
240
|
}
|
|
186
241
|
|
|
187
|
-
// Anti-spoof:
|
|
188
|
-
// gate may
|
|
189
|
-
|
|
190
|
-
delete req.headers["x-forwarded-email"];
|
|
242
|
+
// Anti-spoof: apply the inbound-header allow-list to portr traffic - only the
|
|
243
|
+
// gate may assert identity, and it does so below for a verified session.
|
|
244
|
+
applyHeaderPolicy(req);
|
|
191
245
|
|
|
192
246
|
// Static (non-API) loads freely so the SPA + <AuthGate> can render. Strip the
|
|
193
247
|
// session cookie so it never leaks to the static handler.
|
|
@@ -218,8 +272,7 @@ export function startProxy({ publicPort, appPort, gate }: ProxyOptions): Promise
|
|
|
218
272
|
proxy.ws(req, socket, head);
|
|
219
273
|
return;
|
|
220
274
|
}
|
|
221
|
-
|
|
222
|
-
delete req.headers["x-forwarded-email"];
|
|
275
|
+
applyHeaderPolicy(req);
|
|
223
276
|
if (!(req.url ?? "").startsWith("/api/")) {
|
|
224
277
|
stripSessionCookie(req);
|
|
225
278
|
proxy.ws(req, socket, head);
|