Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ SEATABLE_MODE=selfhosted

# CORS: comma-separated list of allowed origins (HTTP mode only). If empty, CORS is disabled.
# CORS_ALLOWED_ORIGINS=https://cloud.seatable.io,https://seatable-demo.de,http://localhost:3000

# OAuth (managed mode only): comma-separated list of trusted redirect_uri hosts.
# Loopback (localhost/127.0.0.1/::1) is always trusted. Listed https hosts are shown
# without a warning; any other https host still works but the user sees a warning
# before submitting their token. Remote http and non-https/loopback URIs are rejected.
# SEATABLE_OAUTH_TRUSTED_REDIRECT_HOSTS=claude.ai,claude.com,chatgpt.com
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ Auth (one required in selfhosted): `SEATABLE_API_TOKEN` (single-base) or `SEATAB

Optional: `SEATABLE_MODE` (`selfhosted`|`managed`, default `selfhosted`), `SEATABLE_MOCK=true` (offline mock), `SEATABLE_ENABLE_DEBUG_TOOLS=1` (enables `echo_args` tool)

Managed-mode OAuth: `SEATABLE_OAUTH_TRUSTED_REDIRECT_HOSTS` (comma-separated https hosts shown without a warning on the authorize page; loopback is always trusted, unknown https hosts are allowed but warned, remote http is rejected)

Copy `.env.example` to `.env` for local development.

## Architecture
Expand Down
57 changes: 57 additions & 0 deletions SECURITY_OAUTH_REDIRECT_HARDENING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# OAuth redirect_uri Hardening (Posture D)

Härtung des OAuth-Authorize-Flows gegen Phishing/Token-Diebstahl über nicht
validierte `redirect_uri`. Betrifft ausschließlich den **`managed`-Modus**
(SeaTable-Cloud); im `selfhosted`-Default ist der OAuth-Stack inaktiv.

## Problem

Der `/authorize`-Flow sammelt das SeaTable-API-Token des Users und leitete den
resultierenden Auth-Code (der das Token freischaltet) an eine **beliebige,
ungeprüfte `redirect_uri`** weiter. Ein Angreifer konnte damit einen Link auf die
echte Cloud-Domain bauen (`.../authorize?redirect_uri=https://evil.example/cb`),
ein Opfer zum Eintippen seines Tokens verleiten und den Code abgreifen.

PKCE, der `redirect_uri`-Match am `/token` und die offene Dynamic Client
Registration verhindern das nicht, da der Angreifer den gesamten Flow selbst
initiiert.

## Lösung: Klassifizierung des redirect_uri (Posture D)

`kuratieren statt enumerieren` — keine vollständige Client-Allowlist nötig:

| redirect_uri | Verhalten |
|---|---|
| Loopback (`localhost`/`127.0.0.1`/`::1`, http/https) | erlaubt, **keine Warnung** (Code landet auf dem Rechner des Users, sicher by design) |
| Konfigurierter Trusted-Host (https) | erlaubt, **keine Warnung** |
| Unbekannter https-Host | **erlaubt, aber Warn-Banner** vor der Token-Eingabe |
| Remote http / fremdes Schema / kaputte URL | **abgelehnt (400)** |

Zusätzlich:

- **Ziel-Host** wird auf der Token-Seite immer angezeigt.
- Enforcement bei **GET und POST** `/authorize` (direkter POST umgeht die Prüfung nicht).
- **PKCE nur noch S256** (`plain` abgelehnt, Metadata entsprechend).

## Konfiguration

```bash
# Komma-separierte https-Hosts, die ohne Warnung angezeigt werden.
# Leer = nur Loopback ist trusted.
SEATABLE_OAUTH_TRUSTED_REDIRECT_HOSTS=claude.ai,claude.com,chatgpt.com
```

## Geänderte Dateien

- `src/auth/oauthProvider.ts` — `classifyRedirectUri()`, Ziel-/Warn-Anzeige, Error-Page, S256-only
- `src/http/httpServer.ts` — `SEATABLE_OAUTH_TRUSTED_REDIRECT_HOSTS` parsen und übergeben
- `.env.example`, `CLAUDE.md` — Doku
- `tests/oauthProvider.spec.ts` — bestehende Tests angepasst + 8 neue

## Bekannte Grenze

Entschärft den gemeldeten Vektor proportional (Angreifer-Ziel wird abgelehnt bzw.
laut gewarnt), beseitigt aber nicht das Grundmodell „langlebiges Token in ein
Webformular tippen". Der robuste Umbau (Delegation der Authentifizierung an
cloud.seatable.io mit echter Session/Consent) bleibt der langfristige Weg und
setzt einen OAuth-Authorization-Server im SeaTable-Core voraus.
139 changes: 126 additions & 13 deletions src/auth/oauthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,83 @@ export interface OAuthProviderOptions {
hostname?: string
/** Optional callback to validate an API token before issuing an authorization code. */
validateToken?: (token: string) => Promise<boolean>
/**
* Hosts (lowercased, without scheme/port) whose https redirect_uris are treated as
* trusted and shown without a warning. Loopback hosts are always trusted regardless.
* Unknown https hosts are still permitted, but the user is warned before submitting.
*/
trustedRedirectHosts?: string[]
}

/** Result of classifying a redirect_uri for the authorization flow. */
interface RedirectClassification {
/** Whether the redirect_uri is permitted at all. */
ok: boolean
/** Whether it is trusted (no warning shown). Only meaningful when ok is true. */
trusted: boolean
/** The redirect target host, for display. */
host: string
/** Human-readable rejection reason when ok is false. */
reason?: string
}

export class OAuthProvider {
private readonly codes = new Map<string, AuthorizationCode>()
private readonly cleanupInterval: ReturnType<typeof setInterval>
private readonly configuredHostname?: string
private readonly validateToken?: (token: string) => Promise<boolean>
private readonly trustedRedirectHosts: Set<string>

constructor(options?: OAuthProviderOptions) {
this.configuredHostname = options?.hostname
this.validateToken = options?.validateToken
this.trustedRedirectHosts = new Set(
(options?.trustedRedirectHosts ?? []).map((h) => h.trim().toLowerCase()).filter(Boolean),
)
this.cleanupInterval = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS)
if (this.cleanupInterval.unref) {
this.cleanupInterval.unref()
}
}

/**
* Classify a redirect_uri (Posture D):
* - Loopback (localhost / 127.0.0.1 / ::1) over http|https → trusted (code lands on the
* user's own machine, safe by construction).
* - Configured trusted host over https → trusted.
* - Any other https host → permitted but untrusted (user is warned before submitting).
* - Remote http, unknown schemes, or malformed URIs → rejected.
*/
private classifyRedirectUri(uri: string): RedirectClassification {
let parsed: URL
try {
parsed = new URL(uri)
} catch {
return { ok: false, trusted: false, host: '', reason: 'redirect_uri is not a valid URL.' }
}

const scheme = parsed.protocol.replace(/:$/, '').toLowerCase()
const host = parsed.hostname.toLowerCase()
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1'

if (isLoopback) {
if (scheme === 'http' || scheme === 'https') {
return { ok: true, trusted: true, host }
}
return { ok: false, trusted: false, host, reason: 'Loopback redirect_uri must use http or https.' }
}

if (scheme !== 'https') {
return { ok: false, trusted: false, host, reason: 'A non-loopback redirect_uri must use https.' }
}

if (this.trustedRedirectHosts.has(host)) {
return { ok: true, trusted: true, host }
}

return { ok: true, trusted: false, host }
}

/**
* Derive the base URL from SEATABLE_MCP_HOSTNAME or the incoming Host header.
*/
Expand All @@ -60,7 +120,7 @@ export class OAuthProvider {
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none'],
code_challenge_methods_supported: ['S256', 'plain'],
code_challenge_methods_supported: ['S256'],
}
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(metadata))
}
Expand Down Expand Up @@ -111,8 +171,17 @@ export class OAuthProvider {
const codeChallengeMethod = url.searchParams.get('code_challenge_method') ?? ''

if (req.method === 'GET') {
const classification = redirectUri
? this.classifyRedirectUri(redirectUri)
: { ok: false, trusted: false, host: '', reason: 'Missing redirect_uri.' }
if (!classification.ok) {
logger.warn({ host: classification.host, reason: classification.reason }, 'OAuth authorize rejected: invalid redirect_uri')
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
res.end(this.renderErrorPage('Invalid redirect target', classification.reason ?? 'The redirect_uri is not allowed.'))
return
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(this.renderAuthorizePage(clientId, redirectUri, state, responseType, codeChallenge, codeChallengeMethod))
res.end(this.renderAuthorizePage(clientId, redirectUri, state, responseType, codeChallenge, codeChallengeMethod, classification))
return
}

Expand All @@ -124,9 +193,27 @@ export class OAuthProvider {
const formCodeChallenge = body.get('code_challenge') ?? codeChallenge
const formCodeChallengeMethod = body.get('code_challenge_method') ?? codeChallengeMethod

// Validate the redirect target first — never issue a token-bearing code to a
// disallowed destination, even if the request bypassed the GET form.
const classification = this.classifyRedirectUri(formRedirectUri)
if (!classification.ok) {
logger.warn({ host: classification.host, reason: classification.reason }, 'OAuth authorize rejected: invalid redirect_uri')
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
res.end(this.renderErrorPage('Invalid redirect target', classification.reason ?? 'The redirect_uri is not allowed.'))
return
}

// Enforce PKCE S256-only: reject 'plain' (and any implied default) when a challenge is present.
if (formCodeChallenge && formCodeChallengeMethod !== 'S256') {
logger.warn({ method: formCodeChallengeMethod || '(default)' }, 'OAuth authorize rejected: unsupported PKCE method')
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
res.end(this.renderErrorPage('Unsupported PKCE method', 'Only the S256 code_challenge_method is supported.'))
return
}

if (!apiToken) {
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
res.end(this.renderAuthorizePage(clientId, formRedirectUri, formState, responseType, formCodeChallenge, formCodeChallengeMethod, 'Please enter your API token.'))
res.end(this.renderAuthorizePage(clientId, formRedirectUri, formState, responseType, formCodeChallenge, formCodeChallengeMethod, classification, 'Please enter your API token.'))
return
}

Expand All @@ -136,16 +223,11 @@ export class OAuthProvider {
if (!valid) {
logger.warn('OAuth authorization rejected: invalid API token')
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
res.end(this.renderAuthorizePage(clientId, formRedirectUri, formState, responseType, formCodeChallenge, formCodeChallengeMethod, 'Invalid API token. Please check your token and try again.'))
res.end(this.renderAuthorizePage(clientId, formRedirectUri, formState, responseType, formCodeChallenge, formCodeChallengeMethod, classification, 'Invalid API token. Please check your token and try again.'))
return
}
}

if (!formRedirectUri) {
res.writeHead(400, { 'content-type': 'text/plain' }).end('Missing redirect_uri')
return
}

const code = randomBytes(32).toString('hex')
this.codes.set(code, {
apiToken,
Expand Down Expand Up @@ -247,9 +329,7 @@ export class OAuthProvider {
return
}

const expected = stored.codeChallengeMethod === 'plain'
? codeVerifier
: base64UrlEncode(createHash('sha256').update(codeVerifier).digest())
const expected = base64UrlEncode(createHash('sha256').update(codeVerifier).digest())

if (expected !== stored.codeChallenge) {
logger.warn('OAuth PKCE verification failed')
Expand Down Expand Up @@ -332,9 +412,16 @@ export class OAuthProvider {
})
}

private renderAuthorizePage(clientId: string, redirectUri: string, state: string, responseType: string, codeChallenge: string, codeChallengeMethod: string, error?: string): string {
private renderAuthorizePage(clientId: string, redirectUri: string, state: string, responseType: string, codeChallenge: string, codeChallengeMethod: string, classification: RedirectClassification, error?: string): string {
const errorHtml = error ? `<div class="error">${this.escapeHtml(error)}</div>` : ''

const host = this.escapeHtml(classification.host)
const destinationHtml = classification.trusted
? `<div class="destination">Access will be sent to <strong>${host}</strong>.</div>`
: `<div class="warning"><strong>⚠️ Unrecognized destination: ${host}</strong><br>
Your API token will be sent here. We don't recognize this service — only continue if
<em>you</em> started this connection from a tool you trust. If you didn't, close this page.</div>`

return `<!DOCTYPE html>
<html lang="en">
<head>
Expand Down Expand Up @@ -385,13 +472,16 @@ export class OAuthProvider {
}
button:hover { background: #e07b00; }
.error { background: #fee; color: #c00; padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; font-size: 0.9em; }
.destination { background: #f0f7ff; color: #345; padding: 10px 12px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85em; }
.warning { background: #fff4e5; color: #8a4b00; border: 1px solid #ffcc80; padding: 12px 14px; border-radius: 6px; margin-bottom: 16px; font-size: 0.85em; line-height: 1.5; }
.hint { margin-top: 16px; font-size: 0.8em; color: #999; line-height: 1.4; }
</style>
</head>
<body>
<div class="card">
<h1>SeaTable MCP</h1>
<p class="subtitle">Enter your SeaTable API token to authorize access to your base.</p>
${destinationHtml}
${errorHtml}
<form method="POST" action="/authorize">
<input type="hidden" name="redirect_uri" value="${this.escapeHtml(redirectUri)}">
Expand All @@ -410,6 +500,29 @@ export class OAuthProvider {
</html>`
}

private renderErrorPage(title: string, message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SeaTable MCP — ${this.escapeHtml(title)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
.card { background: white; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); padding: 40px; max-width: 440px; width: 100%; }
h1 { font-size: 1.4em; margin-bottom: 12px; color: #c00; }
p { color: #555; line-height: 1.5; }
</style>
</head>
<body>
<div class="card">
<h1>${this.escapeHtml(title)}</h1>
<p>${this.escapeHtml(message)}</p>
</div>
</body>
</html>`
}

private escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
Expand Down
7 changes: 7 additions & 0 deletions src/http/httpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ function parseCorsOrigins(): string[] {
return raw.split(',').map(o => o.trim()).filter(Boolean)
}

function parseTrustedRedirectHosts(): string[] {
const raw = process.env.SEATABLE_OAUTH_TRUSTED_REDIRECT_HOSTS
if (!raw) return []
return raw.split(',').map(h => h.trim().toLowerCase()).filter(Boolean)
}

function setCorsHeaders(req: IncomingMessage, res: ServerResponse, allowedOrigins: string[]): void {
const origin = req.headers.origin
if (!origin || !allowedOrigins.includes(origin)) return
Expand All @@ -90,6 +96,7 @@ export async function startHttpServer(options: StartHttpServerOptions = {}) {
const oauthProvider = mode === 'managed' ? new OAuthProvider({
hostname: process.env.SEATABLE_MCP_HOSTNAME,
validateToken: tokenValidator ? (token) => tokenValidator.validate(token) : undefined,
trustedRedirectHosts: parseTrustedRedirectHosts(),
}) : undefined

const toolDefinitions = getStaticToolDefinitions()
Expand Down
Loading
Loading