diff --git a/docs/components/install/wizard.tsx b/docs/components/install/wizard.tsx index 5fe15010..82a19d98 100644 --- a/docs/components/install/wizard.tsx +++ b/docs/components/install/wizard.tsx @@ -81,7 +81,8 @@ import { stackName, stacksConsoleUrl, stateToQuery, - statusFileUrl, + progressDoc, + registerProgress, stepsFor, urlFromPaste, validate, @@ -673,7 +674,7 @@ function AccountDisclosure({ s, hybrid }: { s: InstallState; hybrid: boolean })
  • An install helper function, its code inside the template. It copies the release files, stores the one-time setup key, asks for your domain's certificate, and on delete empties buckets and removes logs. Its role reaches only this install's names.
  • No installer build, no deploy role and no CDK bootstrap. The stack runs with your own AWS permissions. Only if you turn auto-update on does it add coderhelm-{s.stage}-deploy-stack, a role that only CloudFormation can use, limited to resources named or tagged for this install.
  • CoderHelm itself: Lambda functions, DynamoDB tables, S3 buckets, CloudFront, Cognito, queues, and a role for each service with only what that service uses.
  • -
  • A progress bucket, coderhelm-setup-<id>, with one file: the install's progress and, at the end, its address. No emails, keys or account numbers. It is public if your account allows it, the file expires after 30 days, and the stack deletes the bucket when you delete the stack.
  • +
  • No progress bucket and nothing public: the install reports its progress and, at the end, its address to CoderHelm with a key only this browser has. No emails, keys or account numbers.
  • No access for CoderHelm the company. No role for another account, and nothing that trusts an account outside yours.{hybrid ? <> Hybrid adds no AWS access either: what CoderHelm sees. : null}
  • @@ -682,7 +683,7 @@ function AccountDisclosure({ s, hybrid }: { s: InstallState; hybrid: boolean })

    CoderHelm can act within its boundary. Whoever can change CoderHelm's functions can do what the boundary allows. Changing those functions or roles already takes administrator rights in your account. Keep Lambda and IAM rights to the people who already run this account.

    A malicious release could change CoderHelm. You can check a release yourself before launching: its template's SHA-256 is in its signed checksums, and the template pins every other file. Updates are stack updates you review as a change set; with auto-update on, CoderHelm applies only published release templates, through a role that reaches only this install. It could not create an admin role or touch IAM elsewhere. Auto-update is off unless you turn it on in More options.

    Deleting the stack keeps your data unless you say otherwise. KeepDataOnDelete is true by default: the tables, data bucket, keys, user pool and secret stay. Set it to false before deleting to remove them too.

    -

    The progress file is public while the install runs. It holds only the install's progress and address. If your account blocks public access to S3, that's fine: the install still runs, and this page asks you for the address from the stack's Outputs.

    +

    Nothing in your account is public. The install reports its progress and address to CoderHelm with a key only this browser has, and older releases' public progress bucket is deleted on the first update.

    The AWS link holds your one-time setup key. Don't share it or paste it anywhere. Until your GitHub App exists, someone with the key could give your install a GitHub App of their own. The link to this page holds no secret.

    @@ -765,9 +766,9 @@ export function InstallWizard() { if (!s?.launchedAt || s.url) return; let live = true; const read = async () => { - const r = await getJson(statusFileUrl(s)); + const body = await progressDoc(s, getJson); if (!live) return; - const d = r.ok ? parseStatusDoc(r.body) : null; + const d = body ? parseStatusDoc(body) : null; if (d) { setDoc(d); setS((p) => (p ? withDomainDoc(p, d) ?? p : p)); @@ -796,8 +797,8 @@ export function InstallWizard() { useEffect(() => { if (!s?.url || s.domainDoc || !s.launchedAt) return; let live = true; - void getJson(statusFileUrl(s)).then((r) => { - const d = r.ok ? parseStatusDoc(r.body) : null; + void progressDoc(s, getJson).then((body) => { + const d = body ? parseStatusDoc(body) : null; if (live && d) setS((p) => (p ? withDomainDoc(p, d) ?? p : p)); }); return () => { @@ -939,16 +940,20 @@ export function InstallWizard() { setS(next); setDoc(null); setManual(null); + // Registered while AWS opens: the install's first report comes minutes later. + void registerProgress(next); window.open(quickCreateUrl(next), '_blank', 'noopener'); }; const relaunch = () => { if (!TAG_RE.test(s.version)) return; - // A failed stack keeps its name and its progress bucket: new ones for the new try. - const next = { ...s, statusId: randomId(16), setupToken: newState().setupToken, attempt: s.attempt + 1, launchedAt: Date.now(), url: '' }; + // A new progress id, key and setup key for the new try. + const fresh = newState(); + const next = { ...s, statusId: randomId(16), statusKey: fresh.statusKey, setupToken: fresh.setupToken, attempt: s.attempt + 1, launchedAt: Date.now(), url: '' }; setS(next); setDoc(null); setSetup(null); + void registerProgress(next); window.open(quickCreateUrl(next), '_blank', 'noopener'); }; @@ -1267,7 +1272,7 @@ export function InstallWizard() {

    Everything it creates is named after the name in your AWS account, {s.stage}: the stack is {stackName(s)}, and every resource name starts with coderhelm-{s.stage}, so you can find them in the AWS console.

    The boxes you tick let the stack create IAM roles and expand its nested templates. Every role it creates carries CoderHelm's permissions boundary. The stack runs with your own permissions; nothing keeps deploy rights afterwards unless you turn auto-update on.

    {s.domainChoice === 'own' ? `Your domain: CoderHelm starts on its free cloudfront.net address and requests the certificate for ${cleanHostname(s.hostname) || 'your domain'}. This page shows the two records to add where its DNS is hosted (CoderHelm never changes your DNS, Route 53 included). Once the certificate is issued, CoderHelm prepares the stack update that moves it to your domain, for you to apply in AWS.` : s.domainChoice === 'coderhelm' ? `Your CoderHelm address: once your install answers, this page registers it with CoderHelm and claims ${cleanName(s.coderhelmName) || 'your name'}.${NAME_ZONE}. Your install requests the certificate in your account; CoderHelm adds the DNS records. Nothing for you to do.` : 'No domain or DNS work: CoderHelm gets a free cloudfront.net address. You can add your own domain later, in More options or in CoderHelm.'}

    -

    Two random values from this page go along in the AWS link: a progress ID, so this page can follow the install, and a one-time setup key, so GitHub (or CoderHelm, for hybrid) can talk to your CoderHelm directly. Both stay in your account and this browser; the setup key stops working once your GitHub App exists.

    +

    Two random values from this page go along in the AWS link: a progress ID and key, so your install can report its progress to CoderHelm and only this browser can read it, and a one-time setup key, so GitHub (or CoderHelm, for hybrid) can talk to your CoderHelm directly. The keys stay in your account and this browser; the setup key stops working once your GitHub App exists.

    ); diff --git a/docs/content/self-host-site/aws-account.mdx b/docs/content/self-host-site/aws-account.mdx index 3d426d4f..8bb9dcf2 100644 --- a/docs/content/self-host-site/aws-account.mdx +++ b/docs/content/self-host-site/aws-account.mdx @@ -48,9 +48,9 @@ A Secrets Manager secret, `coderhelm//secrets`. CloudFormation makes it wi With **Install new releases by themselves** on, the stack adds `coderhelm--deploy-stack`, a role only CloudFormation can use, limited to resources named or tagged for this install, with its own copy of the boundary on it. The role and its copy are kept when you delete the stack (CloudFormation keeps using a stack's role, and a role can't delete itself); `leftovers` lists them and `--delete` removes them. The updater may hand it only to CloudFormation, and only for a change set on this stack from a published CoderHelm release template. With auto-update off (the default) there is no such role: an admin reviews each update's change set and applies it with their own permissions. -### The progress bucket +### Progress, and nothing public -`coderhelm-setup-`, with one file, `status.json`: the install's phase, a plain message and, at the end, its address. No emails, keys or account numbers. It is public if your account allows it, the file expires after 30 days, and the bucket is deleted with the stack. +The stack reports its phase, a plain message and, at the end, its address to CoderHelm, with a key the install guide made in your browser. Only a browser with that key can read it back, and CoderHelm keeps it for 14 days. No emails, keys or account numbers are in it. Nothing in your account is public: releases before this one kept the progress in a public bucket, `coderhelm-setup-`, which the install helper deletes on the stack's first update. ### CoderHelm itself @@ -78,9 +78,9 @@ What reaches CoderHelm depends only on what you turn on: ## The setup key and the two links -The install guide makes two random values and puts them in the link that opens AWS: +The install guide makes three random values and puts them in the link that opens AWS: -- **The progress ID** names the progress bucket. The guide's own link (`/self-host/install/?id=...`) holds only this ID, the region and the install name. It is not a secret: with it, someone can see the install's progress and address, nothing more. +- **The progress ID and key** let the stack report its progress to CoderHelm and the guide read it back. The key is a hidden stack parameter (NoEcho) and stays in your browser. The guide's own link (`/self-host/install/?id=...`) holds only the ID, the region and the install name: without the key it shows nothing. - **The setup key** is one time. It lets the guide hand your new GitHub App's keys straight to your install. It stops working once the app exists. The stack parameter hides it (NoEcho). The AWS link holds the setup key and your email. Don't share that link or paste it anywhere. @@ -106,9 +106,9 @@ If CoderHelm's release pipeline were ever broken into, a malicious release you a The stack parameter `KeepDataOnDelete` is `true` by default: deleting the stack keeps the tables, the data bucket, the KMS keys, the user pool and the secret. A first launch that fails and rolls back still removes everything it made. To delete the data too, set `KeepDataOnDelete` to `false` with **Update stack** first. See [Uninstall](/self-host/uninstall/). -### The progress file is public while the install runs +### Progress goes through CoderHelm -It holds only the install's progress and address. If your account blocks public access to S3, that's fine: the install still runs, and the guide asks for your address from the stack's **Outputs** tab. +The stack sends its progress to CoderHelm (api.coderhelm.com). If your install can't reach it, the install still runs, and the guide asks for your address from the stack's **Outputs** tab. ### A leaked AWS link exposes the setup key diff --git a/docs/e2e/install.spec.ts b/docs/e2e/install.spec.ts index 8ddd305a..9f83a9af 100644 --- a/docs/e2e/install.spec.ts +++ b/docs/e2e/install.spec.ts @@ -28,6 +28,10 @@ interface Mock { githubTag?: string | null; /** Release templates in the bucket (default: v0.7.0's). */ templates?: string[]; + /** CoderHelm's copy of the progress (hybrid): undefined or null = no report yet, as for an older release. */ + hosted?: Json | null; + /** Bodies the guide sent to CoderHelm's progress routes. */ + progressCalls?: { path: string; body: Json }[]; } /** @@ -115,6 +119,13 @@ async function mock(page: Page, m: Mock) { await page.route(/^https:\/\/coderhelm-setup-[a-z0-9]{16}\.s3\.[a-z0-9-]+\.amazonaws\.com\/status\.json/, (r) => m.status ? r.fulfill({ json: m.status, headers: CORS }) : r.fulfill({ status: 403, body: 'AccessDenied', headers: CORS }), ); + // CoderHelm's progress routes (routes::install_progress in coderhelm-platform). + await page.route(/^https:\/\/api\.coderhelm\.com\/api\/install-progress(\/.*)?$/, (r) => { + const path = new URL(r.request().url()).pathname; + (m.progressCalls ??= []).push({ path, body: JSON.parse(r.request().postData() ?? '{}') }); + if (path.endsWith('/read')) return r.fulfill({ json: { doc: m.hosted ?? null }, headers: CORS }); + return r.fulfill({ status: 201, body: '', headers: CORS }); + }); await page.route(`${INSTALL}/api/setup/status**`, (r) => { if (m.setup === null) return r.abort('connectionrefused'); if (typeof m.setup === 'number') return r.fulfill({ status: m.setup, body: '', headers: CORS }); @@ -166,6 +177,8 @@ const newMock = (over: Partial = {}): Mock => ({ status: null, setup: null function assertPrivate(m: Mock) { for (const u of m.requests) { const h = new URL(u).hostname; + // CoderHelm only for the hybrid guide's progress, never anything else. + if (h === 'api.coderhelm.com' && new URL(u).pathname.startsWith('/api/install-progress')) continue; const ok = h === 'localhost' || h.endsWith('.amazonaws.com') || h === 'github.com' || h === 'api.github.com' || h === new URL(INSTALL).hostname || h === new URL(DOMAIN).hostname; expect(ok, `unexpected request to ${u}`).toBe(true); } @@ -472,7 +485,7 @@ test('before launch: what the stack creates in the AWS account, the risks behind await expect(panel).not.toContainText('CodeBuild'); // Nothing on the install page claims the old admin installer. expect(await page.locator('body').textContent()).not.toContain('AdministratorAccess'); - await expect(panel).toContainText('coderhelm-setup-'); + await expect(panel).toContainText('No progress bucket and nothing public'); await expect(panel).toContainText('No access for CoderHelm the company.'); // The risks are folded until asked for, then name each one. const risks = panel.getByTestId('disclosure-risks'); @@ -483,11 +496,11 @@ test('before launch: what the stack creates in the AWS account, the risks behind 'CoderHelm can act within its boundary.', 'A malicious release could change CoderHelm.', 'Deleting the stack keeps your data unless you say otherwise.', - 'The progress file is public while the install runs.', + 'Nothing in your account is public.', 'The AWS link holds your one-time setup key.', ]) await expect(risks).toContainText(t); await expect(risks).toContainText('Auto-update is off unless you turn it on in More options.'); - await expect(risks).toContainText('If your account blocks public access to S3, that\'s fine'); + await expect(risks).toContainText('public progress bucket is deleted on the first update'); // The full page, with every resource, role, risk and how to remove it. const link = panel.getByTestId('disclosure-page'); await expect(link).toHaveAttribute('href', '/self-host/aws-account/'); @@ -1454,3 +1467,36 @@ test('the install says its CoderHelm address is live: the guide follows it befor await expect.poll(() => new URL(page.url()).searchParams.get('url')).toBe(ADDRESS); await expect.poll(() => asked).toBeGreaterThan(0); }); + +test('progress goes through CoderHelm with a key only this browser has; nothing is read from the account', async ({ page }) => { + const KEY = 'K'.repeat(40); + const m = newMock({ status: null, hosted: { schema: 1, phase: 'deploying', step: 2, steps: 10, part: { name: 'API', done: 5, total: 41 } } }); + await seeded(page, m, { setup: 'hybrid', statusKey: KEY, domainChoice: 'aws', githubChoice: 'own', jiraChoice: 'own' }); + await expect(page.getByTestId('part-progress')).toHaveText('ยท API: 5 of 41'); + const read = m.progressCalls!.find((c) => c.path === `/api/install-progress/${ID}/read`)!; + expect(read.body).toEqual({ key: KEY }); + expect(m.requests.some((u) => u.includes('/status.json'))).toBe(false); + expect(m.requests.some((u) => u.includes(KEY))).toBe(false); + // No report yet (an older release): the old public file is read as before. + m.hosted = null; + m.status = { schema: 1, phase: 'deploying', step: 3, steps: 10 }; + await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange'))); + await expect(page.getByText('Part 3 of 10')).toBeVisible(); + expect(m.requests.some((u) => u.includes('/status.json'))).toBe(true); +}); + +test('the private guide sends CoderHelm nothing: no key in the AWS link, no progress calls', async ({ page }) => { + const m = newMock({ status: null }); + await mock(page, m); + await page.goto('/self-host/install/'); + await page.locator('[data-setup="private"]').click(); + await page.getByLabel('Your email').fill('ada@example.com'); + await pickModels(page); + await page.getByTestId('launch').click(); + const [link] = await opened(page); + expect(link).not.toContain('param_StatusKey'); + await page.waitForTimeout(600); + expect(m.progressCalls ?? []).toEqual([]); + assertPrivate(m); + expect(m.requests.some((u) => new URL(u).hostname === 'api.coderhelm.com')).toBe(false); +}); diff --git a/docs/lib/install/core.ts b/docs/lib/install/core.ts index bccc3b65..2226addd 100644 --- a/docs/lib/install/core.ts +++ b/docs/lib/install/core.ts @@ -233,6 +233,8 @@ export const REGIONS: { id: string; name: string }[] = [ /** Everything the guide remembers (localStorage). The setup key never goes in the URL. */ export interface InstallState { statusId: string; + /** The key the install reports progress with (to CoderHelm); only this browser reads it back. Never in the URL. */ + statusKey: string; setupToken: string; region: string; stage: string; @@ -323,6 +325,7 @@ export function randomId(length: number, alphabet = 'abcdefghijklmnopqrstuvwxyz0 export function newState(over: Partial = {}): InstallState { return { statusId: randomId(16), + statusKey: randomId(40, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), setupToken: randomId(40, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), region: 'us-east-1', stage: 'prod', @@ -443,6 +446,8 @@ export function quickCreateUrl(s: InstallState): string { ['param_SetupToken', s.setupToken], ]; // The optional ones only when set, so the link stays short and older templates still open. + // Progress through CoderHelm only with hybrid: the private guide sends CoderHelm nothing. + if (s.setup === 'hybrid') params.push(['param_StatusKey', s.statusKey]); if (s.domainChoice === 'own' && s.hostname) params.push(['param_Hostname', cleanHostname(s.hostname)]); if (s.emailTransport === 'coderhelm') params.push(['param_EmailTransport', 'coderhelm']); else if (s.emailFrom?.trim()) params.push(['param_EmailFrom', s.emailFrom.trim().toLowerCase()]); @@ -458,11 +463,77 @@ export function quickCreateUrl(s: InstallState): string { return `https://${s.region}.console.aws.amazon.com/cloudformation/home?region=${s.region}#/stacks/create/review?${q}`; } -/** The progress file the installer keeps in the person's own bucket. */ +/** + * An older release's progress file, in a public bucket in the person's own + * account. Read only when CoderHelm has no report for this id yet (an older + * release never sends one); a release that reports to CoderHelm has no public file. + */ export function statusFileUrl(s: Pick): string { return `https://coderhelm-setup-${s.statusId}.s3.${s.region}.amazonaws.com/status.json`; } +/** Where the install reports progress, on hosted CoderHelm (routes::install_progress). */ +export const PROGRESS_URL = `${HOSTED_API}/api/install-progress`; + +export async function sha256Hex(text: string): Promise { + const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + return Array.from(new Uint8Array(d), (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Registers this guide's progress id with its key's hash, before AWS opens: + * the install then reports with the key, and only a browser with the key + * reads it back. Nothing in the person's account is public. + */ +export async function registerProgress(s: Pick): Promise { + if (s.setup !== 'hybrid') return false; + try { + const r = await fetch(PROGRESS_URL, { + method: 'POST', + credentials: 'omit', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: s.statusId, key_hash: await sha256Hex(s.statusKey) }), + }); + return r.status === 201 || r.status === 409; + } catch { + return false; + } +} + +/** + * The install's latest report from CoderHelm: the document, or null when + * there is none yet (an older release never reports: read its file instead). + */ +export async function readProgress(s: Pick): Promise { + try { + const r = await fetch(`${PROGRESS_URL}/${s.statusId}/read`, { + method: 'POST', + cache: 'no-store', + credentials: 'omit', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ key: s.statusKey }), + }); + if (!r.ok) return null; + const body = (await r.json()) as { doc?: unknown }; + return body?.doc ?? null; + } catch { + return null; + } +} + +/** + * The progress document: CoderHelm's copy first (hybrid only), an older + * release's public file only when there is none. The private guide never + * asks CoderHelm; with a release that has no public file it asks for the + * address from the stack's Outputs. + */ +export async function progressDoc(s: Pick, getJson: (url: string) => Promise<{ ok: boolean; body: unknown }>): Promise { + const hosted = s.setup === 'hybrid' ? await readProgress(s) : null; + if (hosted) return hosted; + const old = await getJson(statusFileUrl(s)); + return old.ok ? old.body : null; +} + export function stacksConsoleUrl(region: string): string { return `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks`; } diff --git a/docs/scripts/install-core.test.mjs b/docs/scripts/install-core.test.mjs index e6c2d98f..48985b40 100644 --- a/docs/scripts/install-core.test.mjs +++ b/docs/scripts/install-core.test.mjs @@ -678,3 +678,25 @@ test('the install address: once its own address is live, the guide follows it, a assert.deepEqual(c.followAddress(own, closed), { url: 'https://coderhelm.example.com', probe: true }); assert.equal(c.followAddress({ ...st, domainChoice: 'aws' }, closed), null); }); + +test('the AWS link carries the progress key only for hybrid; the guide registers only its hash', async () => { + const st = { ...c.newState(), email: 'a@example.com', provider: 'bedrock', version: 'v0.9.0' }; + const q = (s) => new URLSearchParams(c.quickCreateUrl(s).split('#')[1].split('?')[1]); + assert.equal(q({ ...st, setup: 'hybrid' }).get('param_StatusKey'), st.statusKey); + assert.equal(q({ ...st, setup: 'private' }).get('param_StatusKey'), null); + assert.match(st.statusKey, /^[A-Za-z0-9]{40}$/); + const sent = []; + const real = globalThis.fetch; + globalThis.fetch = async (url, init) => { sent.push({ url, body: JSON.parse(init.body) }); return { status: 201 }; }; + try { + assert.equal(await c.registerProgress({ ...st, setup: 'private' }), false); + assert.equal(sent.length, 0, 'the private guide sends CoderHelm nothing'); + assert.equal(await c.registerProgress({ ...st, setup: 'hybrid' }), true); + } finally { + globalThis.fetch = real; + } + assert.equal(sent[0].url, 'https://api.coderhelm.com/api/install-progress'); + assert.deepEqual(Object.keys(sent[0].body).sort(), ['id', 'key_hash']); + assert.equal(sent[0].body.key_hash, await c.sha256Hex(st.statusKey)); + assert.ok(!JSON.stringify(sent[0]).includes(st.statusKey), 'the key itself never leaves for registration'); +});