diff --git a/README.md b/README.md index f33b772..334623e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ bound to a connection account, so platforms and multiple accounts work side by s - Platform logos in column headers distinguish Twitter and Mastodon at a glance - SQLite-backed shared decks, revision conflicts, and device-local active selection - Temporary views for AI exploration, with explicit save and temporary copies -- Server-only encrypted Mastodon credentials and Tailscale owner access +- Server-only encrypted Mastodon credentials and configurable Tailscale owner access - Read-only cards with original-post links, text, media, and quotes - Experimental WebMCP tools to manage decks and read or paginate their columns - Prototype: chat with a resident home Codex beside a live deck, reuse existing @@ -206,14 +206,25 @@ use a Tailscale Serve HTTPS origin for remote access, and allow its exact hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process environment. HTTP and HTTPS origins have separate localStorage. -Set `TWITTER_LITE_ORIGIN` to the exact Serve HTTPS origin (no trailing slash) -and `TWITTER_LITE_ALLOWED_LOGIN` to your Tailscale login. The app requires +Set `TWITTER_LITE_ORIGIN` to the exact public HTTPS origin (no trailing slash). +The default `TWITTER_LITE_AUTH_MODE=tailscale` requires +`TWITTER_LITE_ALLOWED_LOGIN` to be your Tailscale login. The app requires Serve's `Tailscale-User-Login` header and rejects other users. Keep the backend on localhost: the trusted Serve proxy supplies identity. Tagged clients do not provide user identity. Missing configuration fails closed; direct browser access to localhost does not supply the required identity. Playwright supplies an explicit fixture identity to its isolated test server. +For a private, tailnet-only reverse proxy such as Traefik, explicitly set +`TWITTER_LITE_AUTH_MODE=none` to disable application identity checks. This mode +does not require `TWITTER_LITE_ALLOWED_LOGIN` or Tailscale identity headers; +anyone who can reach that proxy can use the app and its connected accounts and +Codex. Bind the backend to loopback or its Tailscale address and restrict proxy +access to the tailnet. +Both modes require the exact configured `Origin` for state-changing requests, +including chat and deck mutations. Missing origin or an unknown auth mode +fails closed. Mastodon OAuth uses the configured public origin for its callback. + ## NixOS service The flake provides a production package and a NixOS module: diff --git a/src/features/access/policy.server.ts b/src/features/access/policy.server.ts index 61926eb..960a72b 100644 --- a/src/features/access/policy.server.ts +++ b/src/features/access/policy.server.ts @@ -1,21 +1,27 @@ -type AccessConfig = { origin: string; allowedLogin: string } +type AccessConfig = { origin: string } & ( + | { mode: 'tailscale'; allowedLogin: string } + | { mode: 'none' } +) export function readAccessConfig(): AccessConfig | null { const origin = process.env.TWITTER_LITE_ORIGIN + const mode = process.env.TWITTER_LITE_AUTH_MODE ?? 'tailscale' const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN - if (!origin || !allowedLogin?.trim()) return null + if (!origin || (mode !== 'tailscale' && mode !== 'none')) return null try { const url = new URL(origin) const secure = url.protocol === 'https:' const local = url.protocol === 'http:' && url.hostname === '127.0.0.1' if ((!secure && !local) || url.origin !== origin) return null - return { origin, allowedLogin } + if (mode === 'none') return { origin, mode } + if (!allowedLogin?.trim()) return null + return { origin, mode, allowedLogin } } catch { return null } } -/** The backend must bind to loopback; only Serve may supply identity headers. */ +/** Use loopback behind Serve for identity, or a private network for mode none. */ export function checkAccess( request: Request, config: AccessConfig | null, @@ -23,7 +29,10 @@ export function checkAccess( if (!config) { return new Response('Access configuration is required.', { status: 503 }) } - if (request.headers.get('Tailscale-User-Login') !== config.allowedLogin) { + if ( + config.mode === 'tailscale' && + request.headers.get('Tailscale-User-Login') !== config.allowedLogin + ) { return new Response('Forbidden', { status: 403 }) } if ( diff --git a/src/features/access/policy.test.ts b/src/features/access/policy.test.ts index f8446a8..6ec43b9 100644 --- a/src/features/access/policy.test.ts +++ b/src/features/access/policy.test.ts @@ -1,15 +1,88 @@ // @vitest-environment node -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { checkAccess, readAccessConfig } from './policy.server' const config = { + mode: 'tailscale' as const, origin: 'https://deck.invalid', allowedLogin: 'owner@deck.invalid', } +beforeEach(() => vi.stubEnv('TWITTER_LITE_AUTH_MODE', undefined)) afterEach(() => vi.unstubAllEnvs()) -describe('Serve access boundary', () => { +describe('access boundary', () => { + it.each([ + undefined, + 'tailscale', + ])('requires an owner in identity mode: %s', (mode) => { + vi.stubEnv('TWITTER_LITE_AUTH_MODE', mode) + vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin) + vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '') + expect(readAccessConfig()).toBeNull() + }) + + it.each([ + '', + 'off', + 'NONE', + ])('fails closed for an unknown auth mode: %s', (mode) => { + vi.stubEnv('TWITTER_LITE_AUTH_MODE', mode) + vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin) + vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', config.allowedLogin) + expect( + checkAccess(new Request(config.origin), readAccessConfig())?.status, + ).toBe(503) + }) + + it('allows private-proxy navigation without an identity only when explicitly configured', () => { + vi.stubEnv('TWITTER_LITE_AUTH_MODE', 'none') + vi.stubEnv('TWITTER_LITE_ORIGIN', config.origin) + vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '') + const access = readAccessConfig() + expect(access).toEqual({ origin: config.origin, mode: 'none' }) + expect(checkAccess(new Request(config.origin), access)).toBeNull() + }) + + it('still requires a configured origin without identity authentication', () => { + vi.stubEnv('TWITTER_LITE_AUTH_MODE', 'none') + vi.stubEnv('TWITTER_LITE_ORIGIN', '') + expect(readAccessConfig()).toBeNull() + }) + + it.each([ + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + ])('requires exact Origin without identity authentication for %s', (method) => { + const access = { origin: config.origin, mode: 'none' as const } + expect( + checkAccess(new Request(config.origin, { method }), access)?.status, + ).toBe(403) + expect( + checkAccess( + new Request(config.origin, { + method, + headers: { + Origin: 'https://other.invalid', + 'Sec-Fetch-Site': 'same-origin', + }, + }), + access, + )?.status, + ).toBe(403) + expect( + checkAccess( + new Request(config.origin, { + method, + headers: { Origin: config.origin }, + }), + access, + ), + ).toBeNull() + }) + it('fails closed when the deployment is not configured', () => { vi.stubEnv('TWITTER_LITE_ORIGIN', '') vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')