From 0a24ce1e90f9c94e1a4e71ba4ac2b5a927ea83c3 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Tue, 26 May 2026 16:04:31 +0000 Subject: [PATCH] feat(mcp): add GHOST_URL_LOCK env var to lock the OAuth login form to a specific Ghost URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When GHOST_URL_LOCK is set: - The /authorize form pre-fills the Ghost site URL field with the locked value and marks it as readonly, so users only need to supply their Staff Access Token. - A lock notice (๐Ÿ”’) is shown explaining the server is restricted to a specific Ghost instance. - POST /authorize enforces the lock server-side: the submitted ghostUrl is silently replaced with the lock value, preventing bypass via crafted requests. - The startup log mentions the locked URL when the server starts in admin mode. Adds parseGhostUrlLock() to flags.ts following the same convention as the other flag helpers. Tests cover GET and POST /authorize in both locked and unlocked states. Closes #3 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 24 ++++++ packages/mcp/README.md | 34 +++++--- packages/mcp/src/flags.ts | 11 +++ packages/mcp/src/oauth.test.ts | 142 ++++++++++++++++++++++++++++++++- packages/mcp/src/oauth.ts | 28 +++++-- packages/mcp/src/server.ts | 13 ++- 6 files changed, 233 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb2b3a..1948285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [0.1.3] โ€” 2026-05-26 + +### Added + +#### `@ikko-dev/ghost-mcp` + +- **`GHOST_URL_LOCK` environment variable** โ€” when set, the OAuth login form + pre-fills the Ghost site URL field with the configured value and renders it as + `readonly`, so users only need to supply their Staff Access Token. The lock is + also enforced server-side on `POST /authorize`: the submitted `ghostUrl` is + silently replaced with the lock value, preventing bypass via crafted requests. + + ```bash + GHOST_URL_LOCK=https://my-blog.ghost.io \ + OAUTH_SECRET=$(openssl rand -hex 32) \ + npx ghost-mcp-server + ``` + +- **`parseGhostUrlLock()`** โ€” exported from `@ikko-dev/ghost-mcp`, reads + `GHOST_URL_LOCK` from the environment and returns it trimmed, or `undefined` + if unset or empty. + +--- + ## [0.1.2] โ€” 2026-05-26 ### Added diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 978184f..7b8871e 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -94,6 +94,19 @@ The server is stateless โ€” credentials are AES-256-GCM-encrypted into the OAuth authorization code, then base64url-encoded into the access token the MCP client holds and sends as `Authorization: Bearer ` on each call. +#### Locking the Ghost URL + +When deploying for a single known Ghost instance, set `GHOST_URL_LOCK` to +pre-fill the login form's Ghost site URL and make it read-only. Users only need +to enter their Staff Access Token. The lock is also enforced server-side โ€” +crafted POST requests cannot bypass it. + +```bash +GHOST_URL_LOCK=https://my-blog.ghost.io \ +OAUTH_SECRET=$(openssl rand -hex 32) \ +npx ghost-mcp-server +``` + ### HTTP โ€” Public content mode No authentication. Pre-configured at startup for a single Ghost site. Exposes @@ -132,16 +145,17 @@ protected resource so MCP clients discover the correct OAuth flow. The public ## Environment variables -| Variable | Description | Required | -| ------------------- | ------------------------------------------------------- | ----------- | -| `GHOST_URL` | Ghost site URL (no trailing slash) | public/dual | -| `GHOST_CONTENT_KEY` | Content API key (26-char hex from a Custom Integration) | public/dual | -| `OAUTH_SECRET` | 64-char hex AES-256-GCM key for OAuth token encryption | HTTP admin | -| `PORT` | HTTP listen port (default `3000`) | โ€” | -| `HOST` | HTTP bind address (default `0.0.0.0`) | โ€” | -| `GHOST_READ_ONLY` | Set `true` to disable `ghost_write` | โ€” | -| `GHOST_PUBLIC` | Set `true` to enable public content mode | โ€” | -| `GHOST_DUAL` | Set `true` to enable dual mode | โ€” | +| Variable | Description | Required | +| ------------------- | ------------------------------------------------------------------------------------ | ----------- | +| `GHOST_URL` | Ghost site URL (no trailing slash) | public/dual | +| `GHOST_CONTENT_KEY` | Content API key (26-char hex from a Custom Integration) | public/dual | +| `OAUTH_SECRET` | 64-char hex AES-256-GCM key for OAuth token encryption | HTTP admin | +| `PORT` | HTTP listen port (default `3000`) | โ€” | +| `HOST` | HTTP bind address (default `0.0.0.0`) | โ€” | +| `GHOST_READ_ONLY` | Set `true` to disable `ghost_write` | โ€” | +| `GHOST_PUBLIC` | Set `true` to enable public content mode | โ€” | +| `GHOST_DUAL` | Set `true` to enable dual mode | โ€” | +| `GHOST_URL_LOCK` | Lock the OAuth login form to a specific Ghost URL; field is pre-filled and read-only | โ€” | `--public` and `--dual` are mutually exclusive. Using both exits with an error. diff --git a/packages/mcp/src/flags.ts b/packages/mcp/src/flags.ts index 504fc49..96cffb3 100644 --- a/packages/mcp/src/flags.ts +++ b/packages/mcp/src/flags.ts @@ -16,3 +16,14 @@ export function parsePublicFlag(): boolean { export function parseDualModeFlag(): boolean { return process.argv.includes("--dual") || process.env.GHOST_DUAL === "true"; } + +/** + * Returns the value of `GHOST_URL_LOCK` if set, otherwise `undefined`. + * + * When set, the OAuth login form pre-fills the Ghost site URL field with this + * value and marks it as read-only, and the `POST /authorize` handler ignores + * whatever the client submits for `ghostUrl` and always uses this value instead. + */ +export function parseGhostUrlLock(): string | undefined { + return process.env.GHOST_URL_LOCK?.trim() || undefined; +} diff --git a/packages/mcp/src/oauth.test.ts b/packages/mcp/src/oauth.test.ts index 3e7580f..1576602 100644 --- a/packages/mcp/src/oauth.test.ts +++ b/packages/mcp/src/oauth.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { mockEvent } from "h3"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createProtectedResourceHandler, createS256Challenge } from "./oauth.ts"; +import { + authorizeGetHandler, + authorizePostHandler, + createProtectedResourceHandler, + createS256Challenge, +} from "./oauth.ts"; describe("createS256Challenge", () => { it("produces a base64url-encoded SHA-256 digest", () => { @@ -55,3 +61,135 @@ describe("createProtectedResourceHandler", () => { expect(result).toHaveProperty("scopes_supported"); }); }); + +// โ”€โ”€โ”€ GHOST_URL_LOCK โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** Convenience: invoke a defineEventHandler-wrapped handler with an h3 event. */ +function invokeHandler(handler: unknown, event: unknown): Promise { + return (handler as (e: unknown) => Promise)(event); +} + +/** Minimal PKCE query params for GET /authorize. */ +const PKCE_QUERY = new URLSearchParams({ + redirect_uri: "http://localhost:12345/callback", + state: "test-state", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", +}).toString(); + +describe("GHOST_URL_LOCK โ€” GET /authorize", () => { + beforeEach(() => { + vi.stubEnv("GHOST_URL_LOCK", "https://locked.ghost.io"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("pre-fills the Ghost site URL field with the locked value", async () => { + const event = mockEvent(`/authorize?${PKCE_QUERY}`); + const html = await invokeHandler(authorizeGetHandler, event); + expect(html).toContain('value="https://locked.ghost.io"'); + }); + + it("renders the URL input as readonly", async () => { + const event = mockEvent(`/authorize?${PKCE_QUERY}`); + const html = await invokeHandler(authorizeGetHandler, event); + // The ghostUrl input must have the readonly attribute (not just the CSS rule mention) + expect(html).toMatch(/id="ghostUrl"[^>]* readonly/); + }); + + it("shows a lock notice mentioning the locked URL", async () => { + const event = mockEvent(`/authorize?${PKCE_QUERY}`); + const html = await invokeHandler(authorizeGetHandler, event); + expect(html).toContain('class="notice-lock"'); + expect(html).toContain("https://locked.ghost.io"); + }); +}); + +describe("GHOST_URL_LOCK โ€” GET /authorize (lock not set)", () => { + beforeEach(() => { + // Ensure GHOST_URL_LOCK is not set for these tests regardless of global env state + vi.stubEnv("GHOST_URL_LOCK", ""); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not show the lock notice when GHOST_URL_LOCK is not set", async () => { + const event = mockEvent(`/authorize?${PKCE_QUERY}`); + const html = await invokeHandler(authorizeGetHandler, event); + expect(html).not.toContain('class="notice-lock"'); + // The ghostUrl input must NOT have the readonly attribute (only the CSS rule mentions "readonly") + expect(html).not.toMatch(/id="ghostUrl"[^>]* readonly/); + }); +}); + +describe("GHOST_URL_LOCK โ€” POST /authorize (server-side enforcement)", () => { + beforeEach(() => { + vi.stubEnv("GHOST_URL_LOCK", "https://locked.ghost.io"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("succeeds using the locked URL even when ghostUrl is omitted from POST body", async () => { + const body = new URLSearchParams({ + // ghostUrl intentionally omitted โ€” lock must supply it + staffAccessToken: "abc123:def4567890abcdef", + redirectUri: "http://localhost:12345/callback", + state: "test-state", + codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + codeChallengeMethod: "S256", + }).toString(); + const event = mockEvent("/authorize", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + const html = await invokeHandler(authorizePostHandler, event); + // Should reach the success page, not an error form + expect(html).toContain("Successfully Connected"); + }); + + it("ignores a crafted ghostUrl in the POST body and uses the lock value instead", async () => { + const body = new URLSearchParams({ + ghostUrl: "https://attacker.ghost.io", // should be silently replaced + staffAccessToken: "abc123:def4567890abcdef", + redirectUri: "http://localhost:12345/callback", + state: "test-state", + codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + codeChallengeMethod: "S256", + }).toString(); + const event = mockEvent("/authorize", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + const html = await invokeHandler(authorizePostHandler, event); + // Successful response โ€” the lock URL was used, not the attacker URL + expect(html).toContain("Successfully Connected"); + // The attacker URL must not appear in the success page redirect + expect(html).not.toContain("attacker.ghost.io"); + }); + + it("shows the lock notice when re-rendering the form on error", async () => { + const body = new URLSearchParams({ + // No staffAccessToken โ†’ validation error โ†’ form re-rendered + redirectUri: "http://localhost:12345/callback", + state: "test-state", + codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + codeChallengeMethod: "S256", + }).toString(); + const event = mockEvent("/authorize", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }); + const html = await invokeHandler(authorizePostHandler, event); + expect(html).toContain('class="notice-lock"'); + expect(html).toContain("https://locked.ghost.io"); + }); +}); diff --git a/packages/mcp/src/oauth.ts b/packages/mcp/src/oauth.ts index ad72f67..d5bf085 100644 --- a/packages/mcp/src/oauth.ts +++ b/packages/mcp/src/oauth.ts @@ -17,6 +17,7 @@ import { createHash } from "node:crypto"; import { createAuthToken, debugAuth, summarizeSecret } from "./auth.ts"; import { createAuthCode, decodeAuthCode } from "./crypto.ts"; +import { parseGhostUrlLock } from "./flags.ts"; function baseUrl(event: H3Event): string { const host = event.req.headers.get("host") ?? "localhost:3000"; @@ -145,13 +146,13 @@ export const authorizeGetHandler = defineEventHandler((event: H3Event) => { state, codeChallenge, codeChallengeMethod: codeChallengeMethod || "S256", + ghostUrlLock: parseGhostUrlLock(), }); }); export const authorizePostHandler = defineEventHandler(async (event: H3Event) => { const body = ((await readBody(event)) as Record) ?? {}; const { - ghostUrl, staffAccessToken, contentApiKey, redirectUri, @@ -160,6 +161,11 @@ export const authorizePostHandler = defineEventHandler(async (event: H3Event) => codeChallengeMethod, } = body; + // Server-side enforcement: if GHOST_URL_LOCK is set, always use its value + // regardless of what the client submitted, preventing bypass via crafted requests. + const ghostUrlLock = parseGhostUrlLock(); + const ghostUrl = ghostUrlLock ?? body.ghostUrl; + event.res.headers.set("Content-Type", "text/html; charset=utf-8"); if (!redirectUri) { @@ -186,6 +192,7 @@ export const authorizePostHandler = defineEventHandler(async (event: H3Event) => codeChallenge, codeChallengeMethod, ghostUrl, + ghostUrlLock, error: "Ghost site URL and at least one credential are required", }); } @@ -198,6 +205,7 @@ export const authorizePostHandler = defineEventHandler(async (event: H3Event) => codeChallenge, codeChallengeMethod, ghostUrl, + ghostUrlLock, error: "Ghost site URL is not a valid URL", }); } @@ -322,9 +330,14 @@ function renderLoginForm(params: { codeChallenge?: string; codeChallengeMethod?: string; ghostUrl?: string; + /** When set, the Ghost site URL field is pre-filled and locked to this value. */ + ghostUrlLock?: string; error?: string; }): string { - const { redirectUri, state, codeChallenge, codeChallengeMethod, ghostUrl, error } = params; + const { redirectUri, state, codeChallenge, codeChallengeMethod, ghostUrlLock, error } = params; + // When locked, always use the lock value for the URL field. + const ghostUrl = ghostUrlLock ?? params.ghostUrl; + const isLocked = Boolean(ghostUrlLock); return ` @@ -346,10 +359,12 @@ function renderLoginForm(params: { .subtitle { text-align: center; color: #666; font-size: 14px; margin-bottom: 28px; } .error { background: #fee2e2; border: 1px solid #fecaca; color: #dc2626; padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 14px; } .notice { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 13px; line-height: 1.4; } + .notice-lock { background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af; padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; font-size: 13px; line-height: 1.4; } .form-group { margin-bottom: 18px; } label { display: block; font-size: 14px; font-weight: 500; color: #374151; margin-bottom: 6px; } input { width: 100%; padding: 11px 14px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 15px; } input:focus { outline: none; border-color: #ff1a75; box-shadow: 0 0 0 3px rgba(255,26,117,0.18); } + input[readonly] { background: #f3f4f6; color: #6b7280; cursor: default; } .help-text { font-size: 12px; color: #6b7280; margin-top: 4px; } .help-text a { color: #ff1a75; text-decoration: none; } .help-text a:hover { text-decoration: underline; } @@ -363,8 +378,9 @@ function renderLoginForm(params: {

Connect to your Ghost site

-

Enter your site URL and a Staff Access Token to let Claude work with your blog.

+

${isLocked ? "Enter your Staff Access Token to let Claude work with this Ghost site." : "Enter your site URL and a Staff Access Token to let Claude work with your blog."}

${error ? `
${escapeHtml(error)}
` : ""} + ${isLocked ? `
🔒 This server is restricted to ${escapeHtml(ghostUrlLock!)}. The Ghost site URL cannot be changed.
` : ""}
Your credentials are not stored on this server. They are encrypted into the OAuth code and held by your MCP client (Claude Desktop, claude.ai, etc.).
@@ -375,9 +391,9 @@ function renderLoginForm(params: {
- - -

The public URL of your Ghost site. No trailing slash.

+ + +

${isLocked ? "Pre-configured by the server administrator." : "The public URL of your Ghost site. No trailing slash."}

diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 8f2f57e..382b2fe 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -16,13 +16,19 @@ * GHOST_DUAL - Dual mode: /mcp (public) + /admin/mcp (OAuth admin) * GHOST_URL - Ghost site URL (required for --public and --dual) * GHOST_CONTENT_KEY- Content API key (required for --public and --dual) + * GHOST_URL_LOCK - Lock the OAuth login form to a specific Ghost URL * OAUTH_SECRET - AES-256-GCM key for OAuth tokens. Required in production. */ import { toNodeHandler } from "h3/node"; import { createServer, type Server } from "node:http"; -import { parseDualModeFlag, parsePublicFlag, parseReadOnlyFlag } from "./flags.ts"; +import { + parseDualModeFlag, + parseGhostUrlLock, + parsePublicFlag, + parseReadOnlyFlag, +} from "./flags.ts"; import { createHealthApp, createMcpRequestHandler, type HttpServerOptions } from "./http.ts"; import { SessionManager } from "./sessions.ts"; import { VERSION } from "./version.ts"; @@ -191,6 +197,11 @@ export function startHttpServer( console.log("OAuth 2.1 (login form):"); console.log(` GET ${base}/authorize`); console.log(` POST ${base}/token`); + const urlLock = parseGhostUrlLock(); + if (urlLock) { + console.log(""); + console.log(`Ghost URL lock: ${urlLock} (login form is restricted to this site)`); + } if (readOnly) { console.log(""); console.log("Mode: READ-ONLY (write operations disabled)");