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)");