Skip to content
Merged
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
9 changes: 7 additions & 2 deletions lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import useMcpServerProxy from './use-mcp-server-proxy.js';
import { useSessionReset } from './use-session-reset.js';
import runMcpServerAndThen from './run-mcp-server-and-then.js';
import { createRateLimitMiddleware } from './rate-limit-redis-adapter.js';
import { sanitizeUrl } from './url-sanitizer.js';

const __dirname = dirname(import.meta.url);

Expand Down Expand Up @@ -101,7 +102,7 @@ function server(env = {}, listeningCallback, exitFunc) {
logger.error('OIDC provider error', {
'request-id': requestId,
method: ctx.request.method,
url: ctx.request.url,
url: sanitizeUrl(ctx.request.url),
status: ctx.response.status,
error: error.message || error,
stack: error.stack,
Expand Down Expand Up @@ -153,7 +154,11 @@ function server(env = {}, listeningCallback, exitFunc) {
// fix for confirm-login form in Safari with localhost
delete cspDirectives['upgrade-insecure-requests'];
// only log all requests when local (Heroku router produces these logs)
app.use(morgan('tiny'));
// Use custom format that sanitizes URLs to prevent logging sensitive OAuth parameters
morgan.token('url-sanitized', (req) => sanitizeUrl(req.originalUrl || req.url));
app.use(
morgan(':method :url-sanitized :status :res[content-length] - :response-time ms')
);
}

app.use(
Expand Down
116 changes: 116 additions & 0 deletions lib/url-sanitizer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Sensitive OAuth query parameters that should be redacted from logs
*/
const SENSITIVE_PARAMS = new Set([
'client_id',
'code_challenge',
'code_challenge_method',
'state',
'redirect_uri',
'code',
]);

/**
* Sanitizes a URL by removing sensitive OAuth query parameters.
* This prevents sensitive data like client_id, code_challenge, state, etc.
* from being logged.
*
* @param {string} urlString - The URL string to sanitize
* @returns {string} - The sanitized URL with sensitive query parameters removed
*/
export function sanitizeUrl(urlString) {
if (!urlString || typeof urlString !== 'string') {
return urlString || '';
}

try {
// Handle relative URLs (pathname + query string)
// If it doesn't start with http:// or https://, treat as relative
if (!urlString.startsWith('http://') && !urlString.startsWith('https://')) {
// Parse as relative URL
const urlObj = new URL(urlString, 'http://placeholder');
const sanitizedParams = sanitizeQueryParams(urlObj.searchParams);
// Only add ? if there are remaining params
return sanitizedParams ? `${urlObj.pathname}?${sanitizedParams}` : urlObj.pathname;
}

// Parse absolute URL
const urlObj = new URL(urlString);
const sanitizedParams = sanitizeQueryParams(urlObj.searchParams);
urlObj.search = sanitizedParams || '';

// Return the sanitized URL
return urlObj.toString();
} catch {
// If URL parsing fails, try to sanitize query string manually
// This handles edge cases like malformed URLs
const queryIndex = urlString.indexOf('?');
if (queryIndex === -1) {
return urlString; // No query string, return as-is
}

const pathname = urlString.substring(0, queryIndex);
const queryString = urlString.substring(queryIndex + 1);
const sanitizedParams = sanitizeQueryString(queryString);

return pathname + (sanitizedParams ? `?${sanitizedParams}` : '');
}
}

/**
* Sanitizes URLSearchParams by removing sensitive parameters
*
* @param {URLSearchParams} searchParams - The search params to sanitize
* @returns {string} - The sanitized query string
*/
function sanitizeQueryParams(searchParams) {
const params = [];
for (const [key, value] of searchParams.entries()) {
if (!SENSITIVE_PARAMS.has(key)) {
params.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
return params.length > 0 ? params.join('&') : '';
}

/**
* Sanitizes a raw query string by removing sensitive parameters
* Used as fallback for malformed URLs
*
* @param {string} queryString - The raw query string to sanitize
* @returns {string} - The sanitized query string
*/
function sanitizeQueryString(queryString) {
if (!queryString) {
return '';
}

const params = queryString.split('&');
const sanitized = params
.map((param) => {
const equalIndex = param.indexOf('=');
let key;
if (equalIndex === -1) {
// Parameter without value
key = param;
} else {
key = param.substring(0, equalIndex);
}
try {
const decodedKey = decodeURIComponent(key);
if (SENSITIVE_PARAMS.has(decodedKey)) {
return null; // Remove sensitive param
}
} catch {
// If decoding fails, check the raw key
if (SENSITIVE_PARAMS.has(key)) {
return null; // Remove sensitive param
}
}
return param; // Keep non-sensitive param
})
.filter((param) => param !== null);

return sanitized.length > 0 ? sanitized.join('&') : '';
}

186 changes: 186 additions & 0 deletions test/url-sanitizer-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import assert from 'assert';
import { sanitizeUrl } from '../lib/url-sanitizer.js';

describe('URL Sanitizer', function () {
describe('sanitizeUrl', function () {
describe('absolute URLs', function () {
it('should remove all sensitive OAuth parameters', function () {
const url =
'https://example.com/auth?client_id=abc123&code_challenge=xyz&code_challenge_method=S256&state=state123&redirect_uri=https://callback.com&code=auth_code';
const result = sanitizeUrl(url);
assert.strictEqual(result, 'https://example.com/auth');
});

it('should preserve non-sensitive query parameters', function () {
const url = 'https://example.com/auth?scope=openid&response_type=code&nonce=test123';
const result = sanitizeUrl(url);
assert.strictEqual(result, 'https://example.com/auth?scope=openid&response_type=code&nonce=test123');
});

it('should remove sensitive parameters while preserving non-sensitive ones', function () {
const url =
'https://example.com/auth?client_id=abc123&scope=openid&state=state123&response_type=code&code_challenge=xyz';
const result = sanitizeUrl(url);
assert.strictEqual(result, 'https://example.com/auth?scope=openid&response_type=code');
});

it('should handle URLs without query strings', function () {
const url = 'https://example.com/auth';
const result = sanitizeUrl(url);
assert.strictEqual(result, 'https://example.com/auth');
});

it('should handle URLs with empty query strings', function () {
const url = 'https://example.com/auth?';
const result = sanitizeUrl(url);
assert.strictEqual(result, 'https://example.com/auth');
});

it('should handle URLs with only sensitive parameters', function () {
const url = 'https://example.com/auth?client_id=abc123&state=state123';
const result = sanitizeUrl(url);
assert.strictEqual(result, 'https://example.com/auth');
});
});

describe('relative URLs', function () {
it('should remove sensitive parameters from relative URLs', function () {
const url = '/auth?client_id=abc123&state=state123&scope=openid';
const result = sanitizeUrl(url);
assert.strictEqual(result, '/auth?scope=openid');
});

it('should handle relative URLs without query strings', function () {
const url = '/auth';
const result = sanitizeUrl(url);
assert.strictEqual(result, '/auth');
});

it('should handle relative URLs with pathname and query', function () {
const url = '/interaction/123?code=auth_code&redirect_uri=https://callback.com';
const result = sanitizeUrl(url);
assert.strictEqual(result, '/interaction/123');
});
});

describe('edge cases', function () {
it('should handle null input', function () {
const result = sanitizeUrl(null);
assert.strictEqual(result, '');
});

it('should handle undefined input', function () {
const result = sanitizeUrl(undefined);
assert.strictEqual(result, '');
});

it('should handle empty string', function () {
const result = sanitizeUrl('');
assert.strictEqual(result, '');
});

it('should handle malformed URLs gracefully', function () {
const url = 'not-a-valid-url?client_id=abc123&state=test';
const result = sanitizeUrl(url);
// Should attempt to sanitize and remove sensitive params
assert(!result.includes('client_id=abc123'), 'Should remove client_id');
assert(!result.includes('state=test'), 'Should remove state');
});

it('should handle URLs with encoded parameters', function () {
const url =
'https://example.com/auth?client_id=abc%20123&redirect_uri=https%3A%2F%2Fcallback.com&scope=openid';
const result = sanitizeUrl(url);
assert.strictEqual(result, 'https://example.com/auth?scope=openid');
});

it('should handle query parameters without values', function () {
const url = 'https://example.com/auth?client_id&state&scope=openid';
const result = sanitizeUrl(url);
// Sensitive parameters without values should be removed
assert(!result.includes('client_id'), 'Should remove sensitive client_id param');
assert(!result.includes('state'), 'Should remove sensitive state param');
assert(result.includes('scope=openid'), 'Should preserve non-sensitive param');
});

it('should handle multiple values for same parameter', function () {
const url = 'https://example.com/auth?client_id=abc123&client_id=def456&scope=openid';
const result = sanitizeUrl(url);
assert.strictEqual(result, 'https://example.com/auth?scope=openid');
});
});

describe('all sensitive parameters', function () {
it('should remove client_id', function () {
const url = 'https://example.com/auth?client_id=abc123&scope=openid';
const result = sanitizeUrl(url);
assert(!result.includes('client_id'), 'Should remove client_id');
assert(result.includes('scope=openid'), 'Should preserve scope');
});

it('should remove code_challenge', function () {
const url = 'https://example.com/auth?code_challenge=xyz789&scope=openid';
const result = sanitizeUrl(url);
assert(!result.includes('code_challenge'), 'Should remove code_challenge');
assert(result.includes('scope=openid'), 'Should preserve scope');
});

it('should remove code_challenge_method', function () {
const url = 'https://example.com/auth?code_challenge_method=S256&scope=openid';
const result = sanitizeUrl(url);
assert(!result.includes('code_challenge_method'), 'Should remove code_challenge_method');
assert(result.includes('scope=openid'), 'Should preserve scope');
});

it('should remove state', function () {
const url = 'https://example.com/auth?state=state123&scope=openid';
const result = sanitizeUrl(url);
assert(!result.includes('state='), 'Should remove state');
assert(result.includes('scope=openid'), 'Should preserve scope');
});

it('should remove redirect_uri', function () {
const url = 'https://example.com/auth?redirect_uri=https://callback.com&scope=openid';
const result = sanitizeUrl(url);
assert(!result.includes('redirect_uri'), 'Should remove redirect_uri');
assert(result.includes('scope=openid'), 'Should preserve scope');
});

it('should remove code (authorization code)', function () {
const url = 'https://example.com/auth?code=auth_code_123&scope=openid';
const result = sanitizeUrl(url);
assert(!result.includes('code='), 'Should remove code');
assert(result.includes('scope=openid'), 'Should preserve scope');
});
});

describe('real-world OAuth scenarios', function () {
it('should sanitize authorization request URL', function () {
const url =
'https://auth.example.com/authorize?client_id=my_client&response_type=code&redirect_uri=https://app.com/callback&scope=openid%20profile&state=random_state_123&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256';
const result = sanitizeUrl(url);
assert.strictEqual(
result,
'https://auth.example.com/authorize?response_type=code&scope=openid%20profile'
);
});

it('should sanitize callback URL with authorization code', function () {
const url = 'https://app.com/callback?code=4/0AeanS0dXyZ&state=random_state_123';
const result = sanitizeUrl(url);
assert.strictEqual(result, 'https://app.com/callback');
});

it('should sanitize error callback URL', function () {
const url =
'https://app.com/callback?error=access_denied&error_description=User%20denied&state=random_state_123';
const result = sanitizeUrl(url);
assert.strictEqual(
result,
'https://app.com/callback?error=access_denied&error_description=User%20denied'
);
});
});
});
});