fix: prevent Invalid URL crash when APP_URL lacks a protocol (#972)

* fix: prevent Invalid URL crash when APP_URL lacks a protocol (issue #970)

- Add getMcpSafeUrl() to notifications.ts: wraps getAppUrl() and
  guarantees a result that satisfies the MCP SDK's checkIssuerUrl
  requirement (https:// or http://localhost). Non-HTTPS, non-localhost
  URLs fall back to http://localhost:{PORT} instead of propagating an
  "Issuer URL must be HTTPS" error.
- Switch app.ts, mcp/index.ts, mcp/oauthProvider.ts, and oauthService.ts
  to import getMcpSafeUrl instead of getAppUrl for all MCP resource URL
  construction, so a misconfigured APP_URL never crashes the metadata
  router initialisation.
- Restrict the SDK metadata router middleware to /.well-known/* paths
  only. Previously it was invoked on every request; in production the
  lazy getMetaRouter() init ran on GET / and threw "Invalid URL" when
  APP_URL had no scheme, returning 500 for every page load.
- Log a startup warning when APP_URL is set but not usable, and include
  the resolved App URL in the startup banner so operators can confirm
  the correct value at a glance.
- Update oauth.test.ts mock to target notifications.getMcpSafeUrl.

* fix: show getAppUrl in banner and add two separate APP_URL startup checks

- Banner now displays getAppUrl() (the resolved app URL) rather than
  getMcpSafeUrl() so operators see the actual configured value
- Two independent startup warnings after the banner when APP_URL is set:
  1. whether APP_URL is a valid URL (parseable by new URL())
  2. whether APP_URL is MCP-safe (https:// or http://localhost)
- Fix getMcpSafeUrl() fallback port to use Number(PORT) || 3001,
  consistent with how index.ts parses PORT

* fix: update oidc.ts to import getAppUrl from notifications
This commit is contained in:
Julien G.
2026-05-07 13:49:39 +02:00
committed by GitHub
parent 9f1d05e886
commit de6c0fb781
9 changed files with 81 additions and 39 deletions
+6 -9
View File
@@ -50,11 +50,11 @@ import { getCollabFeatures } from './services/adminService';
import { isAddonEnabled } from './services/adminService'; import { isAddonEnabled } from './services/adminService';
import { ADDON_IDS } from './addons'; import { ADDON_IDS } from './addons';
import { ALL_SCOPES } from './mcp/scopes'; import { ALL_SCOPES } from './mcp/scopes';
import { getAppUrl } from './services/oidcService';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize'; import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register'; import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth'; import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
import { getMcpSafeUrl } from './services/notifications';
export function createApp(): express.Application { export function createApp(): express.Application {
const app = express(); const app = express();
@@ -388,7 +388,7 @@ export function createApp(): express.Application {
function getOAuthMetadata(): OAuthMetadata { function getOAuthMetadata(): OAuthMetadata {
if (_oauthMetadata) return _oauthMetadata; if (_oauthMetadata) return _oauthMetadata;
const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, ''); const base = getMcpSafeUrl().replace(/\/+$/, '');
_oauthMetadata = { _oauthMetadata = {
issuer: base, issuer: base,
authorization_endpoint: `${base}/oauth/authorize`, authorization_endpoint: `${base}/oauth/authorize`,
@@ -416,14 +416,11 @@ export function createApp(): express.Application {
return _sdkMetaRouter; return _sdkMetaRouter;
} }
// Path-aware gate: only /.well-known/* returns 404 when disabled; other paths pass through // Only invoke the SDK metadata router for /.well-known/* paths.
// so static files and SPA routes are unaffected when MCP is off. // Calling getMetaRouter() on every request triggers lazy init (new URL(...)) which
// throws "Invalid URL" when APP_URL lacks a protocol — breaking all page loads.
app.use((req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response, next: NextFunction) => {
const isMetadataPath = if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
req.path === '/.well-known/oauth-authorization-server' ||
req.path === '/.well-known/openid-configuration' ||
req.path.startsWith('/.well-known/oauth-protected-resource');
if (isMetadataPath && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
getMetaRouter()(req, res, next); getMetaRouter()(req, res, next);
}); });
+31 -10
View File
@@ -19,6 +19,7 @@ const tmpDir = path.join(__dirname, '../data/tmp');
const app = createApp(); const app = createApp();
import * as scheduler from './scheduler'; import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
const PORT = Number(process.env.PORT) || 3001; const PORT = Number(process.env.PORT) || 3001;
const HOST = process.env.HOST; const HOST = process.env.HOST;
@@ -29,22 +30,42 @@ const onListen = () => {
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase(); const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)'; const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
const appUrl = getAppUrl();
const resolvedAppUrl = getMcpSafeUrl();
const banner = [ const banner = [
'──────────────────────────────────────', '──────────────────────────────────────',
' TREK API started', ' TREK API started',
` Version ${APP_VERSION}`, ` Version ${APP_VERSION}`,
...(HOST ? [` Host: ${HOST}`] : []), ...(HOST ? [` Host: ${HOST}`] : []),
` Port: ${PORT}`, ` Container Port: ${PORT}`,
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`, ` App URL: ${appUrl}`,
` Timezone: ${tz}`, ` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
` Origins: ${origins}`, ` Timezone: ${tz}`,
` Log level: ${LOG_LVL}`, ` Origins: ${origins}`,
` Log file: /app/data/logs/trek.log`, ` Log level: ${LOG_LVL}`,
` PID: ${process.pid}`, ` Log file: /app/data/logs/trek.log`,
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`, ` PID: ${process.pid}`,
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
'──────────────────────────────────────', '──────────────────────────────────────',
]; ];
banner.forEach(l => console.log(l)); banner.forEach(l => console.log(l));
if (process.env.APP_URL) {
let parsedAppUrl: URL | null = null;
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
if (!parsedAppUrl) {
sLogWarn(`APP_URL: "${process.env.APP_URL}" is not a valid URL — it will be ignored.`);
}
const mcpSafe = parsedAppUrl !== null && (
parsedAppUrl.protocol === 'https:' ||
parsedAppUrl.hostname === 'localhost' ||
parsedAppUrl.hostname === '127.0.0.1'
);
if (!mcpSafe) {
sLogWarn(`APP_URL: not MCP-safe (requires https:// or http://localhost) — MCP will use ${resolvedAppUrl}.`);
}
}
if (process.env.DEMO_MODE?.toLowerCase() === 'true') sLogInfo('Demo mode: ENABLED'); if (process.env.DEMO_MODE?.toLowerCase() === 'true') sLogInfo('Demo mode: ENABLED');
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') { if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') {
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!'); sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
+3 -3
View File
@@ -11,7 +11,7 @@ import { registerResources } from './resources';
import { registerTools } from './tools'; import { registerTools } from './tools';
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager'; import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
import { writeAudit, getClientIp } from '../services/auditLog'; import { writeAudit, getClientIp } from '../services/auditLog';
import { getAppUrl } from '../services/oidcService'; import { getMcpSafeUrl } from '../services/notifications';
export { revokeUserSessions, revokeUserSessionsForClient }; export { revokeUserSessions, revokeUserSessionsForClient };
@@ -153,7 +153,7 @@ const sessionSweepInterval = setInterval(() => {
sessionSweepInterval.unref(); sessionSweepInterval.unref();
function setAuthChallenge(res: Response, error = 'invalid_token'): void { function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getAppUrl() || '').replace(/\/+$/, ''); const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path // RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
res.set('WWW-Authenticate', res.set('WWW-Authenticate',
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`); `Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
@@ -183,7 +183,7 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
if (!result) return null; if (!result) return null;
// RFC 8707: audience must always match this resource endpoint. // RFC 8707: audience must always match this resource endpoint.
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration. // Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`; const expected = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`;
if (result.audience !== expected) return null; if (result.audience !== expected) return null;
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false }; return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
} }
+2 -2
View File
@@ -16,7 +16,7 @@ import {
getUserByAccessToken, getUserByAccessToken,
} from '../services/oauthService'; } from '../services/oauthService';
import { ALL_SCOPES } from './scopes'; import { ALL_SCOPES } from './scopes';
import { getAppUrl } from '../services/oidcService'; import { getMcpSafeUrl } from '../services/notifications';
import { writeAudit } from '../services/auditLog'; import { writeAudit } from '../services/auditLog';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -125,7 +125,7 @@ export const trekOAuthProvider: OAuthServerProvider = {
// Redirects browser to the SPA consent page with OAuth params forwarded. // Redirects browser to the SPA consent page with OAuth params forwarded.
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> { async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`; const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource; const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
if (resource !== mcpResource) { if (resource !== mcpResource) {
+1 -1
View File
@@ -14,8 +14,8 @@ import {
touchLastLogin, touchLastLogin,
generateToken, generateToken,
frontendUrl, frontendUrl,
getAppUrl,
} from '../services/oidcService'; } from '../services/oidcService';
import { getAppUrl } from '../services/notifications';
import { resolveAuthToggles } from '../services/authService'; import { resolveAuthToggles } from '../services/authService';
const router = express.Router(); const router = express.Router();
+32 -3
View File
@@ -46,13 +46,42 @@ function getSmtpConfig(): SmtpConfig | null {
// Exported for use by notificationService // Exported for use by notificationService
export function getAppUrl(): string { export function getAppUrl(): string {
if (process.env.APP_URL) return process.env.APP_URL; if (process.env.APP_URL) {
try {
const _ = new URL(process.env.APP_URL);
return process.env.APP_URL.replace(/\/+$/, '');
} catch (_ignored) {
}
}
const origins = process.env.ALLOWED_ORIGINS; const origins = process.env.ALLOWED_ORIGINS;
if (origins) { if (origins) {
const first = origins.split(',')[0]?.trim(); const first = origins.split(',')[0]?.trim();
if (first) return first.replace(/\/+$/, ''); if (first) {
try {
const _ = new URL(first);
return first.replace(/\/+$/, '');
} catch (_ignored) {
}
}
} }
const port = process.env.PORT || '3000'; const port = Number(process.env.PORT) || 3001;
return `http://localhost:${port}`;
}
/** Returns a URL guaranteed to satisfy the MCP SDK's issuer requirements (HTTPS or localhost).
* Falls back to http://localhost:{PORT} when APP_URL/ALLOWED_ORIGINS use a non-HTTPS, non-localhost scheme
* that would cause checkIssuerUrl to throw "Issuer URL must be HTTPS". */
export function getMcpSafeUrl(): string {
const candidate = getAppUrl();
try {
const u = new URL(candidate);
if (u.protocol === 'https:' || u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
return candidate;
}
} catch {
// candidate was somehow invalid — fall through to localhost
}
const port = Number(process.env.PORT) || 3001;
return `http://localhost:${port}`; return `http://localhost:${port}`;
} }
+2 -2
View File
@@ -6,7 +6,7 @@ import { ADDON_IDS } from '../addons';
import { User } from '../types'; import { User } from '../types';
import { writeAudit, logWarn } from './auditLog'; import { writeAudit, logWarn } from './auditLog';
import { revokeUserSessionsForClient } from '../mcp/sessionManager'; import { revokeUserSessionsForClient } from '../mcp/sessionManager';
import { getAppUrl } from './oidcService'; import { getMcpSafeUrl } from './notifications';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Constants // Constants
@@ -587,7 +587,7 @@ export function validateAuthorizeRequest(
// bind the token to the MCP endpoint by default — previously this // bind the token to the MCP endpoint by default — previously this
// left `audience = null`, and the audience-bind check on MCP requests // left `audience = null`, and the audience-bind check on MCP requests
// then treated a null audience as "valid for any resource". // then treated a null audience as "valid for any resource".
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`; const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const resource = params.resource const resource = params.resource
? params.resource.replace(/\/+$/, '') ? params.resource.replace(/\/+$/, '')
: mcpResource; : mcpResource;
-8
View File
@@ -194,14 +194,6 @@ export function generateToken(user: { id: number }): string {
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' }); return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' });
} }
export function getAppUrl(): string | null {
return (
process.env.APP_URL ||
(db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value ||
null
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Token exchange with OIDC provider // Token exchange with OIDC provider
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+4 -1
View File
@@ -48,7 +48,10 @@ vi.mock('../../src/services/adminService', async (importOriginal) => {
return { ...actual, isAddonEnabled: isAddonEnabledMock }; return { ...actual, isAddonEnabled: isAddonEnabledMock };
}); });
vi.mock('../../src/services/oidcService', () => ({ getAppUrl: () => 'https://trek.example.com' })); vi.mock('../../src/services/notifications', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/services/notifications')>();
return { ...actual, getMcpSafeUrl: () => 'https://trek.example.com' };
});
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() })); vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));