Public: anyone can read this; the crew can change it.

The connect command is being eaten by the proxy: print FORGE_APP_SSH so nothing can rewrite it #54

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/relay:scout/29-the-connect-command-is-being-eaten-by-th → main 1 commit fork

  • Merged. scout/29-the-connect-command-is-being-eaten-by-th was merged into main as 837ec5d.

Review

Sign in to review this change.
6 files changed +93 −5
README.md +7
@@ -138,6 +138,13 @@ the host; `<name>--<pull>@<forge>` for a preview's). It is `src/tty.ts` on
138138 a real pty over the same data, as the account at the keyboard: j/k move
139139 through the timeline, q leaves.
140140
141 The footer prints that command verbatim, quoted, never a guessed form.
142 The forge sits behind a proxy that rewrites anything shaped like an email
143 address, so the command is wrapped in `<!--email_off-->` and its host sits
144 in its own element — readable and copyable without JavaScript, even
145 behind the rewrite. With `FORGE_APP_SSH` unset the footer says the
146 terminal is off.
147
141148 The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV` (`production` or
142149 `preview`), `FORGE_APP_URL`, `FORGE_APP_COMMIT`, `FORGE_APP_SSH` and
143150 `FORGE_APP_IDENTITY_KEY` when it starts the app. `forge.json` says how.
src/page.ts +23 −4
@@ -10,7 +10,6 @@ import {
1010 description,
1111 env,
1212 publicOrigin,
13 ssh,
1413 title,
1514 where,
1615 } from "./env.ts";
@@ -18,7 +17,25 @@ import { asset, escape } from "./http.ts";
1817 import { ago } from "./when.ts";
1918 import type { Who } from "./who.ts";
2019
21 export function layout(body: string, who: Who): string {
20 /**
21 * The proxy in front of this forge rewrites anything shaped like an email
22 * address — including `user@host` in an ssh command — into a decoder
23 * script a browser must run before a person can read it. `<!--email_off-->`
24 * is its documented opt-out; splitting the host into its own element is
25 * belt and braces, so no single text node is left looking like an address
26 * even if the comment is ever stripped.
27 */
28 const terminal = (ssh: string | undefined): string => {
29 if (ssh === undefined) return `<p>Terminal: off.</p>`;
30 const at = ssh.lastIndexOf("@");
31 const command =
32 at === -1
33 ? escape(ssh)
34 : `${escape(ssh.slice(0, at + 1))}<span class="ssh-host">${escape(ssh.slice(at + 1))}</span>`;
35 return `<p>Terminal: <!--email_off--><code class="ssh">${command}</code><!--/email_off--></p>`;
36 };
37
38 export function layout(body: string, who: Who, ssh: string | undefined): string {
2239 const door =
2340 who.role === "anon"
2441 ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>`
@@ -47,7 +64,7 @@ export function layout(body: string, who: Who): string {
4764 </header>
4865 <main>${body}</main>
4966 <footer class="muted">
50 ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`}
67 ${terminal(ssh)}
5168 <p>Also at <a href="/api/events">/api/events</a> and <code>POST /mcp</code>, with the same rules.</p>
5269 </footer>
5370 </div>
@@ -62,8 +79,9 @@ export function home(input: {
6279 source: string | null;
6380 kind: string | null;
6481 error?: string;
82 ssh: string | undefined;
6583 }): string {
66 const { who, events, source, kind, error } = input;
84 const { who, events, source, kind, error, ssh } = input;
6785 const filtered = source !== null || kind !== null;
6886 const body =
6987 events.length === 0 ? empty(filtered) : days(events, source, kind);
@@ -73,6 +91,7 @@ export function home(input: {
7391 ${body}
7492 </section>`,
7593 who,
94 ssh,
7695 );
7796 }
7897
src/routes.ts +3
@@ -32,6 +32,8 @@ export interface Context {
3232 readonly db: Database;
3333 readonly who: Who;
3434 readonly params: Record<string, string>;
35 /** The terminal command, printed verbatim, or `undefined` when there isn't one. */
36 readonly ssh: string | undefined;
3537 }
3638
3739 export interface Route {
@@ -75,6 +77,7 @@ export const routes: readonly Route[] = [
7577 source,
7678 kind,
7779 error: listed.ok ? undefined : listed.message,
80 ssh: c.ssh,
7881 }),
7982 );
8083 },
src/server.test.ts +48
@@ -390,6 +390,54 @@ test("refuses a cross-site post", async () => {
390390 expect(posted.status).toBe(403);
391391 });
392392
393 test("the terminal command survives the proxy's email-address rewrite", async () => {
394 const ssh = "ssh -p 2222 [email protected]";
395 const withSsh = serve({
396 port: 0,
397 dataDir: mkdtempSync(join(tmpdir(), "app-")),
398 identityKey: KEY,
399 ssh,
400 });
401 try {
402 const page = await (
403 await fetch(`http://${withSsh.hostname}:${withSsh.port}/`)
404 ).text();
405 // As text — tags stripped — the exact value is there to read and copy.
406 const asText = page
407 .replace(/<!--[\s\S]*?-->/g, "")
408 .replace(/<[^>]+>/g, "");
409 expect(asText).toContain(ssh);
410 // The opt-out the proxy documents, actually present.
411 expect(page).toContain("<!--email_off-->");
412 // Nothing the rewrite (or a stripped comment) could still catch.
413 expect(page).not.toContain("__cf_email__");
414 expect(page).not.toContain("email-protection");
415 expect(page).not.toContain("mailto:");
416 expect(page).not.toMatch(/<a[^>]*>[^<]*@[^<]*<\/a>/);
417 } finally {
418 withSsh.stop(true);
419 }
420 });
421
422 test("with no terminal configured, the footer says so plainly and guesses nothing", async () => {
423 const off = serve({
424 port: 0,
425 dataDir: mkdtempSync(join(tmpdir(), "app-")),
426 identityKey: KEY,
427 ssh: undefined,
428 });
429 try {
430 const page = await (
431 await fetch(`http://${off.hostname}:${off.port}/`)
432 ).text();
433 expect(page).toContain("Terminal: off.");
434 // No `[email protected]` or any other invented address-shaped text.
435 expect(page).not.toMatch(/[\w.-]+@[\w.-]+/);
436 } finally {
437 off.stop(true);
438 }
439 });
440
393441 test("static files are cached for a year when linked by content", async () => {
394442 const page = await (await fetch(base + "/")).text();
395443 const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
src/server.ts +5 −1
@@ -8,7 +8,7 @@
88 */
99
1010 import { openDb } from "./db.ts";
11 import { dataDir, identityKey, title, where } from "./env.ts";
11 import { dataDir, identityKey, ssh as envSsh, title, where } from "./env.ts";
1212 import { html, json, sameOrigin, text } from "./http.ts";
1313 import { PERSON_WRITES, take } from "./ration.ts";
1414 import { match } from "./routes.ts";
@@ -21,6 +21,8 @@ export interface ServeOptions {
2121 readonly identityKey: string;
2222 /** The clock the ration is measured against. A test brings its own. */
2323 readonly now?: (() => number) | undefined;
24 /** The terminal command, printed verbatim. Defaults to `FORGE_APP_SSH`; a test brings its own to cover both branches. */
25 readonly ssh?: string | undefined;
2426 }
2527
2628 /**
@@ -54,6 +56,7 @@ function tooManyRequests(pathname: string, retryAfterMs: number): Response {
5456 export function serve(options: ServeOptions) {
5557 const db = openDb(options.dataDir);
5658 const clock = options.now ?? Date.now;
59 const ssh = options.ssh ?? envSsh;
5760 return Bun.serve({
5861 port: options.port,
5962 hostname: options.hostname ?? "127.0.0.1",
@@ -87,6 +90,7 @@ export function serve(options: ServeOptions) {
8790 db,
8891 who: me,
8992 params: found.params,
93 ssh,
9094 });
9195 // HEAD is GET without the body: a health check sees the app up.
9296 return request.method === "HEAD"
static/app.css +7
@@ -197,6 +197,13 @@ footer {
197197 footer code {
198198 overflow-wrap: anywhere;
199199 }
200 /* A tap selects the whole command; the padded box is the touch target. */
201 .ssh {
202 display: inline-flex;
203 align-items: center;
204 min-height: var(--tap);
205 user-select: all;
206 }
200207
201208 /* Controls: at least 44px on a side, keyboard and mouse alike. */
202209 .button {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs