feat: support private network access without identity headers

This commit is contained in:
2026-09-24 20:25:41 +09:00
parent 80cadf7242
commit 2346848839
3 changed files with 103 additions and 10 deletions
+14 -3
View File
@@ -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:
+14 -5
View File
@@ -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 (
+75 -2
View File
@@ -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: '[email protected]',
}
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', '')