Skip to content

Commit 6cd1e25

Browse files
committed
Switched to library version of strict-url-sanitise
1 parent 64eda02 commit 6cd1e25

File tree

5 files changed

+14
-177
lines changed

5 files changed

+14
-177
lines changed

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"remote",
1010
"oauth"
1111
],
12-
"author": "Glen Maddern <glen@cloudflare.com>",
12+
"author": "Glen Maddern <glen@glenmaddern.com>",
1313
"repository": "https://github.com/geelen/mcp-remote",
1414
"type": "module",
1515
"files": [
@@ -32,7 +32,8 @@
3232
},
3333
"dependencies": {
3434
"express": "^4.21.2",
35-
"open": "^10.1.0"
35+
"open": "^10.1.0",
36+
"strict-url-sanitise": "^0.0.1"
3637
},
3738
"devDependencies": {
3839
"@modelcontextprotocol/sdk": "https://pkg.pr.new/geelen/typescript-sdk/@modelcontextprotocol/sdk@cdf3508",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/node-oauth-client-provider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
import type { OAuthProviderOptions, StaticOAuthClientMetadata } from './types'
1010
import { readJsonFile, writeJsonFile, readTextFile, writeTextFile, deleteConfigFile } from './mcp-auth-config'
1111
import { StaticOAuthClientInformationFull } from './types'
12-
import { getServerUrlHash, log, debugLog, DEBUG, MCP_REMOTE_VERSION, sanitizeUrl } from './utils'
12+
import { getServerUrlHash, log, debugLog, DEBUG, MCP_REMOTE_VERSION } from './utils'
13+
import { sanitizeUrl } from 'strict-url-sanitise'
1314
import { randomUUID } from 'node:crypto'
1415

1516
/**

src/lib/utils.test.ts

Lines changed: 1 addition & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,3 @@
11
import { describe, it, expect } from 'vitest'
2-
import { sanitizeUrl } from './utils'
32

4-
describe('sanitizeUrl', () => {
5-
describe('should allow valid URLs', () => {
6-
const testCases = [
7-
{ input: 'http://example.com', expected: 'http://example.com/' },
8-
{ input: 'https://www.google.com', expected: 'https://www.google.com/' },
9-
{ input: 'http://test.com/path/to/resource', expected: 'http://test.com/path/to/resource' },
10-
{ input: 'https://api.github.com/users/octocat', expected: 'https://api.github.com/users/octocat' },
11-
{
12-
input: 'https://subdomain.example.com/page?param=value#section',
13-
expected: 'https://subdomain.example.com/page?param=value#section',
14-
},
15-
{ input: 'http://localhost:3000', expected: 'http://localhost:3000/' },
16-
{ input: 'https://127.0.0.1:8080', expected: 'https://127.0.0.1:8080/' },
17-
]
18-
19-
testCases.forEach(({ input, expected }) => {
20-
it(`should sanitize: ${input}`, () => {
21-
const result = sanitizeUrl(input)
22-
expect(result).toBe(expected)
23-
})
24-
})
25-
})
26-
27-
describe('should reject invalid protocols', () => {
28-
const invalidProtocolUrls = [
29-
'ftp://example.com/test',
30-
'ssh://test.com/whoami',
31-
'telnet://malicious.com/dir',
32-
'file:///C:/Windows/System32/calc.exe',
33-
'file://localhost/etc/passwd',
34-
'file://c:/windows/system32/calc.exe',
35-
'javascript:alert("XSS")',
36-
'javascript:eval("malicious code")',
37-
'javascript:$(calc.exe)?response_type=code.....',
38-
'javascript:$(cmd /c whoami > c:\\temp\\pwned.txt)',
39-
'data:text/html,<script>alert("XSS")</script>',
40-
]
41-
42-
invalidProtocolUrls.forEach((url) => {
43-
it(`should reject: ${url}`, () => {
44-
expect(() => sanitizeUrl(url)).toThrow('Invalid url to pass to open()')
45-
})
46-
})
47-
})
48-
49-
describe('should reject malicious hosts', () => {
50-
const invalidProtocolUrls = [
51-
'https://www.$(calc.exe).com/foo',
52-
'https://www.example.com:$(calc.exe)/foo',
53-
]
54-
55-
invalidProtocolUrls.forEach((url) => {
56-
it(`should reject: ${url}`, () => {
57-
expect(() => sanitizeUrl(url)).toThrow('Invalid url to pass to open()')
58-
})
59-
})
60-
})
61-
62-
describe('should properly encode URL components', () => {
63-
it('should reject URLs with spaces in hostname', () => {
64-
// URLs with spaces in hostname are invalid
65-
expect(() => sanitizeUrl('https://exam ple.com')).toThrow()
66-
})
67-
68-
it('should encode special characters in pathname', () => {
69-
const result = sanitizeUrl('https://example.com/path with spaces')
70-
expect(result).toBe('https://example.com/path%2520with%2520spaces')
71-
})
72-
73-
it('should encode query parameters', () => {
74-
const result = sanitizeUrl('https://example.com?key=value with spaces&another=test')
75-
expect(result).toBe('https://example.com/?key=value%20with%20spaces&another=test')
76-
})
77-
78-
it('should encode hash fragments', () => {
79-
const result = sanitizeUrl('https://example.com#section with spaces')
80-
expect(result).toBe('https://example.com/#section%2520with%2520spaces')
81-
})
82-
83-
it('should handle empty query parameter values', () => {
84-
const result = sanitizeUrl('https://example.com?empty&hasvalue=test')
85-
expect(result).toBe('https://example.com/?empty&hasvalue=test')
86-
})
87-
88-
it('should encode basic auth', () => {
89-
const result = sanitizeUrl('http://user$(calc)r:pass$(calc)[email protected]')
90-
expect(result).toBe('http://user%24(calc)r:pass%24(calc)[email protected]/')
91-
})
92-
})
93-
94-
describe('should handle complex URLs', () => {
95-
it('should handle URL with all components', () => {
96-
const complexUrl = 'https://user:[email protected]:8080/path/to/resource?param=value&other=test#fragment'
97-
const result = sanitizeUrl(complexUrl)
98-
expect(result).toBe('https://user:[email protected]:8080/path/to/resource?param=value&other=test#fragment')
99-
})
100-
101-
it('should preserve valid URL structure', () => {
102-
const url = 'https://api.example.com/v1/users?limit=10&offset=0#results'
103-
const result = sanitizeUrl(url)
104-
expect(result).toBe('https://api.example.com/v1/users?limit=10&offset=0#results')
105-
})
106-
})
107-
108-
describe('should handle edge cases', () => {
109-
it('should handle URLs with port numbers', () => {
110-
const result = sanitizeUrl('http://localhost:3000/api')
111-
expect(result).toBe('http://localhost:3000/api')
112-
})
113-
114-
it('should handle URLs with authentication info', () => {
115-
const result = sanitizeUrl('https://user:[email protected]/secure')
116-
expect(result).toBe('https://user:[email protected]/secure')
117-
})
118-
119-
it('should handle minimal URLs', () => {
120-
const result = sanitizeUrl('http://a.com')
121-
expect(result).toBe('http://a.com/')
122-
})
123-
})
124-
125-
describe('malformed or suspicious URLs', () => {
126-
it('should handle URLs with suspicious query parameters', () => {
127-
// These should be encoded properly, not blocked
128-
const result = sanitizeUrl('https://example.com?param=$(calc.exe)')
129-
expect(result).toBe('https://example.com/?param=%24(calc.exe)')
130-
})
131-
132-
it('should handle URLs with suspicious fragments', () => {
133-
const result = sanitizeUrl('https://example.com#$(calc)')
134-
expect(result).toBe('https://example.com/#%24(calc)')
135-
})
136-
137-
it('should handle URLs with command injection attempts in path', () => {
138-
const result = sanitizeUrl('https://example.com/$(calc.exe)')
139-
expect(result).toBe('https://example.com/%24(calc.exe)')
140-
})
141-
142-
it('should handle specific malicious URL with calc.exe in path', () => {
143-
const result = sanitizeUrl('http://www.a.com/$(calc.exe)')
144-
expect(result).toBe('http://www.a.com/%24(calc.exe)')
145-
})
146-
})
147-
})
3+
// All sanitizeUrl tests have been moved to the strict-url-sanitise package

src/lib/utils.ts

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -704,32 +704,3 @@ export function setupSignalHandlers(cleanup: () => Promise<void>) {
704704
export function getServerUrlHash(serverUrl: string): string {
705705
return crypto.createHash('md5').update(serverUrl).digest('hex')
706706
}
707-
708-
export function sanitizeUrl(raw: string) {
709-
const abort = () => {
710-
throw new Error(`Invalid url to pass to open(): ${raw}`)
711-
}
712-
let url!: URL
713-
try {
714-
url = new URL(raw)
715-
} catch (_) {
716-
abort()
717-
}
718-
719-
// Don't allow any other scheme than http(s)
720-
if (url.protocol !== 'https:' && url.protocol !== 'http:') abort()
721-
// Hostnames can't be updated, but let's reject if they contain anything suspicious
722-
if (url.hostname !== encodeURIComponent(url.hostname)) abort()
723-
724-
// Forcibly sanitise all the pieces of the URL
725-
if (url.username) url.username = encodeURIComponent(url.username)
726-
if (url.password) url.password = encodeURIComponent(url.password)
727-
url.pathname = url.pathname.slice(0, 1) + encodeURIComponent(url.pathname.slice(1)).replace(/%2f/ig,'/')
728-
url.search = url.search.slice(0, 1) + Array.from(url.searchParams.entries()).map(sanitizeParam).join('&')
729-
url.hash = url.hash.slice(0, 1) + encodeURIComponent(url.hash.slice(1))
730-
return url.href
731-
}
732-
733-
function sanitizeParam([k, v]: [string, string]) {
734-
return `${encodeURIComponent(k)}${v.length > 0 ? `=${encodeURIComponent(v)}` : ''}`
735-
}

0 commit comments

Comments
 (0)