feat: support private network access without identity headers
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
Reference in New Issue
Block a user