Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/workflows/shots.yml
Original file line number Diff line number Diff line change
@@ -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 <id> -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
11 changes: 9 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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://<suffix>/` — 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
Expand Down
24 changes: 24 additions & 0 deletions e2e/shots-config/.ssh/config
Original file line number Diff line number Diff line change
@@ -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
22 changes: 21 additions & 1 deletion e2e/shots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { chromium } from "playwright";

import {
ALIAS,
HOST,
SUFFIX,
browserOptions,
connectThroughDashboard,
Expand All @@ -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-"));
Expand Down
51 changes: 39 additions & 12 deletions extension/STORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>` 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:

Expand All @@ -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 <id> -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

Expand Down