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
|
- Platform logos in column headers distinguish Twitter and Mastodon at a glance
|
||||||
- SQLite-backed shared decks, revision conflicts, and device-local active selection
|
- SQLite-backed shared decks, revision conflicts, and device-local active selection
|
||||||
- Temporary views for AI exploration, with explicit save and temporary copies
|
- 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
|
- Read-only cards with original-post links, text, media, and quotes
|
||||||
- Experimental WebMCP tools to manage decks and read or paginate their columns
|
- 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
|
- 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
|
hostname through `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` in the Vite process
|
||||||
environment. HTTP and HTTPS origins have separate localStorage.
|
environment. HTTP and HTTPS origins have separate localStorage.
|
||||||
|
|
||||||
Set `TWITTER_LITE_ORIGIN` to the exact Serve HTTPS origin (no trailing slash)
|
Set `TWITTER_LITE_ORIGIN` to the exact public HTTPS origin (no trailing slash).
|
||||||
and `TWITTER_LITE_ALLOWED_LOGIN` to your Tailscale login. The app requires
|
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
|
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
|
on localhost: the trusted Serve proxy supplies identity. Tagged clients do not
|
||||||
provide user identity. Missing configuration fails closed; direct browser access
|
provide user identity. Missing configuration fails closed; direct browser access
|
||||||
to localhost does not supply the required identity. Playwright supplies an
|
to localhost does not supply the required identity. Playwright supplies an
|
||||||
explicit fixture identity to its isolated test server.
|
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
|
## NixOS service
|
||||||
|
|
||||||
The flake provides a production package and a NixOS module:
|
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 {
|
export function readAccessConfig(): AccessConfig | null {
|
||||||
const origin = process.env.TWITTER_LITE_ORIGIN
|
const origin = process.env.TWITTER_LITE_ORIGIN
|
||||||
|
const mode = process.env.TWITTER_LITE_AUTH_MODE ?? 'tailscale'
|
||||||
const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN
|
const allowedLogin = process.env.TWITTER_LITE_ALLOWED_LOGIN
|
||||||
if (!origin || !allowedLogin?.trim()) return null
|
if (!origin || (mode !== 'tailscale' && mode !== 'none')) return null
|
||||||
try {
|
try {
|
||||||
const url = new URL(origin)
|
const url = new URL(origin)
|
||||||
const secure = url.protocol === 'https:'
|
const secure = url.protocol === 'https:'
|
||||||
const local = url.protocol === 'http:' && url.hostname === '127.0.0.1'
|
const local = url.protocol === 'http:' && url.hostname === '127.0.0.1'
|
||||||
if ((!secure && !local) || url.origin !== origin) return null
|
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 {
|
} catch {
|
||||||
return null
|
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(
|
export function checkAccess(
|
||||||
request: Request,
|
request: Request,
|
||||||
config: AccessConfig | null,
|
config: AccessConfig | null,
|
||||||
@@ -23,7 +29,10 @@ export function checkAccess(
|
|||||||
if (!config) {
|
if (!config) {
|
||||||
return new Response('Access configuration is required.', { status: 503 })
|
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 })
|
return new Response('Forbidden', { status: 403 })
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,15 +1,88 @@
|
|||||||
// @vitest-environment node
|
// @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'
|
import { checkAccess, readAccessConfig } from './policy.server'
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
|
mode: 'tailscale' as const,
|
||||||
origin: 'https://deck.invalid',
|
origin: 'https://deck.invalid',
|
||||||
allowedLogin: '[email protected]',
|
allowedLogin: '[email protected]',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => vi.stubEnv('TWITTER_LITE_AUTH_MODE', undefined))
|
||||||
afterEach(() => vi.unstubAllEnvs())
|
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', () => {
|
it('fails closed when the deployment is not configured', () => {
|
||||||
vi.stubEnv('TWITTER_LITE_ORIGIN', '')
|
vi.stubEnv('TWITTER_LITE_ORIGIN', '')
|
||||||
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
|
vi.stubEnv('TWITTER_LITE_ALLOWED_LOGIN', '')
|
||||||
|
|||||||
Reference in New Issue
Block a user