From 5fd128f26632c387c17fd53786a000985dd04fd0 Mon Sep 17 00:00:00 2001 From: Souta Date: Mon, 14 Sep 2026 22:10:05 +0900 Subject: [PATCH] The store listing must not be a photograph of your machine Taking the screenshots for the Chrome Web Store listing put six of souta's hosts on screen: panza souta@
: rosina @
:, via Panza yukawa-mercury @mercury. yukawa-sushiki @sushiki, via yukawa-mercury yukawa-front @front, via yukawa-mercury issp-ohtaka @ohtaka. The dashboard's second section lists every host ssh can reach, with what `ssh -G` resolves for each, and the first names the account and path a site is rooted at. Those images exist to be uploaded to a public page. Nothing leaked -- `e2e/shots/` is gitignored and the files never left this machine -- but the documented procedure was "run this, attach the output", and the output is somebody's infrastructure. So the shots move to a fresh runner. `shots.yml` sets up a throwaway sshd, copies the invented ssh_config in `e2e/shots-config/` over the runner's own, and uploads the images as an artifact. Nothing in frame belongs to anyone, and the picture is the same every time, which a listing image should be anyway. `shots.mjs` refuses any host but a local one. A procedure that depends on remembering is the one that fails once. The listing text had drifted from the extension in the other direction. Its data disclosure told you to tick "Website content" and "Personal communications", describing an annotation feature that was removed -- two false statements on a compliance form. The extension has no content script and no host permission for any site, so there is no code path that could read a page; both are now declared "no" with what makes them checkable. `web_accessible_resources` arrived today and is justified for a reviewer who reads the manifest. PRIVACY.md invited a check and failed it: it said `background.ts` contains exactly one `fetch`. It contains three. All three go to 127.0.0.1, which was the point being made, but a claim offered for verification has to survive being verified. Signed-off-by: Souta --- .github/workflows/shots.yml | 78 ++++++++++++++++++++++++++++++++++++ PRIVACY.md | 11 ++++- e2e/shots-config/.ssh/config | 24 +++++++++++ e2e/shots.mjs | 22 +++++++++- extension/STORE.md | 51 +++++++++++++++++------ 5 files changed, 171 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/shots.yml create mode 100644 e2e/shots-config/.ssh/config diff --git a/.github/workflows/shots.yml b/.github/workflows/shots.yml new file mode 100644 index 0000000..0dc03b1 --- /dev/null +++ b/.github/workflows/shots.yml @@ -0,0 +1,78 @@ +name: shots + +# Photograph the product for the Chrome Web Store listing, somewhere with nothing to leak. +# +# The images are not the reason this runs in CI; the *machine* is. The dashboard lists every +# host your ssh can reach, with the user, address, port and jump host `ssh -G` resolves for +# each, and above that what is being served, with the account and path it is rooted at. Taken +# on a developer's own laptop, that is their infrastructure in a picture whose destination is a +# public listing page. It happened once, which is why this file exists. +# +# A fresh runner has none of it: a throwaway sshd on loopback, `e2e/tree` as the site, and the +# invented ssh_config in `e2e/shots-config/`. What comes out is a photograph of the product +# rather than of whoever took it, and it is the same picture every time. +# +# gh workflow run shots.yml +# gh run download -n store-screenshots + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + shots: + name: screenshots + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + cache-bin: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + e2e/package-lock.json + extension/package-lock.json + + - name: Local sshd + # The same arrangement the e2e job uses. The daemon only ever speaks to a host through + # `ssh -s sftp`, so the site in the picture is served over a real transport. + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq openssh-server + ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 + cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys + chmod 700 ~/.ssh + chmod 600 ~/.ssh/authorized_keys + sudo systemctl start ssh || sudo service ssh start + ssh-keyscan -H localhost >> ~/.ssh/known_hosts + + - name: An ssh_config with nobody's hosts in it + # Copied over the runner's, which has none, rather than pointed at with `HOME`: moving + # `HOME` would also move the keys the sshd above was just given. + # + # `ssh -G` resolves these without connecting to anything, which is all the dashboard + # needs in order to list them -- so they can be as invented as they look. + run: cp e2e/shots-config/.ssh/config ~/.ssh/config + + - run: cargo build --bin ssh-browser + - run: npm --prefix extension ci + - run: npm --prefix extension run build + - run: npm --prefix e2e ci + - run: npm --prefix e2e run browser + - run: npm --prefix e2e run shots + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: store-screenshots + path: e2e/shots/ + if-no-files-found: error diff --git a/PRIVACY.md b/PRIVACY.md index e972c41..1b1ffa0 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -38,8 +38,9 @@ Nothing is written to your host. The daemon has no write path: the SFTP requests are `OPEN`, `CLOSE`, `READ`, `OPENDIR`, `READDIR` and `REALPATH`, and the only open flag it defines is read. -You can check rather than take this on trust: `extension/src/background.ts` contains exactly -one `fetch`, in `callDaemon`, and its URL begins `http://127.0.0.1:`. +You can check rather than take this on trust: `extension/src/background.ts` contains three +`fetch` calls — the control API, the proxy script, and the token on first connect — and each +of their URLs begins `http://127.0.0.1:`. There are none anywhere else in the extension. ## What it does not do @@ -58,6 +59,12 @@ one `fetch`, in `callDaemon`, and its URL begins `http://127.0.0.1:`. allowed to reach, and there is no optional permission to grant later: it never asks to run on the sites you open. +One more manifest entry is worth naming even though it is not a permission. The dashboard is +listed as reachable from `http:///` — the page your daemon serves at the root of your +configured suffix, whose only job is to send you to the dashboard. That one origin serves +nothing else. Pages served from a host are a different origin and are refused it, which is the +point of listing one rather than all. + ## Removing it Uninstalling deletes everything in the table above with it. Nothing of yours is left behind diff --git a/e2e/shots-config/.ssh/config b/e2e/shots-config/.ssh/config new file mode 100644 index 0000000..3d05835 --- /dev/null +++ b/e2e/shots-config/.ssh/config @@ -0,0 +1,24 @@ +# The ssh_config the store screenshots are taken against. +# +# Invented, and that is the whole point. `shots.mjs` photographs the dashboard, the dashboard +# lists the hosts ssh can reach, and those are a person's account names, addresses, ports and +# jump hosts. A listing image is public forever. The first run of this against a real machine +# put six of them on screen, which is how this file came to exist. +# +# Also makes the picture the same on every machine, so a screenshot is of the product rather +# than of whoever took it. + +Host example-login + HostName login.example.edu + User you + Port 22 + +Host example-cluster + HostName cluster.example.edu + User you + ProxyJump example-login + +Host example-vps + HostName vps.example.net + User you + Port 2222 diff --git a/e2e/shots.mjs b/e2e/shots.mjs index 8def3fd..f77a280 100644 --- a/e2e/shots.mjs +++ b/e2e/shots.mjs @@ -20,6 +20,7 @@ import { chromium } from "playwright"; import { ALIAS, + HOST, SUFFIX, browserOptions, connectThroughDashboard, @@ -35,7 +36,26 @@ const OUT = join(import.meta.dirname, "shots"); /// not a speck in one corner. const VIEW = { width: 1280, height: 800 }; -// The fuller of the two fixtures: a heading, an image and a script that ran. +// Refused against anything but a local host, and this is not tidiness. +// +// The dashboard's second section lists the hosts ssh can reach, with the user, address, port +// and jump host `ssh -G` resolved for each; the first lists what is being served, with the +// account and path it is rooted at. Run against a real machine that is six accounts, three +// addresses, two jump hosts and somebody's home directory — in an image whose destination is a +// public store page. It was run that way once, which is why this is here. +// +// `shots.yml` runs it on a fresh runner with a throwaway sshd and the invented ssh_config in +// `shots-config/`, so the picture is of the product rather than of whoever took it. +if (HOST !== "localhost" && HOST !== "127.0.0.1" && !process.env["SSH_BROWSER_SHOTS_ANY_HOST"]) { + console.error( + `refusing to photograph ${HOST}: these images go on a public listing, and the dashboard\n` + + `shows the accounts, addresses, ports and jump hosts of every host your ssh can reach.\n\n` + + `Run them where everything is invented:\n` + + ` gh workflow run shots.yml\n\n` + + `To override anyway, knowing what is in frame: SSH_BROWSER_SHOTS_ANY_HOST=1`, + ); + process.exit(2); +} const { child } = await startDaemon(PORT); const profile = await mkdtemp(join(tmpdir(), "ssh-browser-shots-")); diff --git a/extension/STORE.md b/extension/STORE.md index e08c0f7..770725f 100644 --- a/extension/STORE.md +++ b/extension/STORE.md @@ -88,26 +88,41 @@ The form asks for one per permission. Keep these in step with `../PRIVACY.md`. > The extension's only network destination. This is the user's own daemon, which they started > themselves, listening on loopback. +**`web_accessible_resources: dashboard.html` for `http://ssh-browser/*`** + +Not a permission, so the form does not ask — but a reviewer reads the manifest, and this is +the entry that would raise the question. + +> The daemon serves a page at the root of the configured suffix whose only job is to hand the +> browser to this extension's dashboard. That navigation is refused unless the page is listed +> here, so exactly one origin is: the bare suffix, which serves nothing but that page. Pages +> served from a host — `docs.ssh-browser`, remote content, untrusted — are not covered by the +> pattern and are refused the same navigation. + **Remote code** > None. Everything the extension executes is in the uploaded package. ## Data disclosure -Tick these three and explain in the notes. Under-declaring is a policy violation, so anything -arguable is declared; the notes are where the shape of it gets said. +Tick **one**. Under-declaring is a policy violation, so anything arguable is declared — but +declaring a category the extension has no code for is a false statement on the same form, and +this section had two of them until it was checked against the extension that actually shipped. - **Authentication information** — the daemon's control token. Held in the service worker, stored in `chrome.storage.local`, sent only to `http://127.0.0.1:` as a request - header. Never given to a content script, because pages from the remote host are untrusted. -- **Website content** — the text of pages under the configured suffix, read in order to find - the words a note was attached to. Anchoring cannot be done without reading them. The text is - not transmitted; only the note and its selectors go to the local daemon. -- **Personal communications** — the notes the user types. They go to the local daemon, which - writes them to a file on the user's own SSH host. There is no service in between. + header. Never given to a page, because pages from the remote host are untrusted code. + +Nothing else, and each of these is checkable rather than asserted: -Also sent to the local daemon: the path of the page being viewed, which is how a document is -identified. No browsing history is assembled or retained. +- **Website content** — no. There is no content script — `extension/permissions.json` pins + that and CI fails if it changes — and no host permission for any site, so there is no code + path that could read a page. +- **Personal communications** — no. There was going to be an annotation feature; it was + removed, and with it the only thing the user would have typed. +- **Web history, location, financial, health, personal identifiers, user activity** — no. + Nothing about the page being viewed is reported anywhere. The extension makes three `fetch` + calls and all three begin `http://127.0.0.1:`. All three certifications are true: @@ -121,8 +136,20 @@ All three certifications are true: ## Screenshots -At least one, 1280×800 or 640×400. `npm --prefix e2e run shots` captures them from the real -product against a live daemon rather than mocking anything up, and writes to `e2e/shots/`. +At least one, 1280×800 or 640×400. Taken from the real product against a live daemon rather +than mocked up — but **not on your own machine**: + +``` +gh workflow run shots.yml +gh run download -n store-screenshots +``` + +The dashboard lists every host your ssh can reach, with the user, address, port and jump host +`ssh -G` resolves for each, and above that what is being served, with the account and path it +is rooted at. On a laptop that is your infrastructure in an image destined for a public page; +the first run of this produced exactly that. `shots.yml` runs on a fresh runner against a +throwaway sshd and the invented `ssh_config` in `e2e/shots-config/`, and `shots.mjs` refuses +any host but a local one so it cannot happen by habit. ## Still to do by hand