Skip to content

Commit eb2b5d6

Browse files
committed
fix(mcp): stateful regex lastIndex bug, RFC 3986 authority parsing
- Remove /g flag from module-level ENV_VAR_PATTERN to avoid lastIndex state - Create fresh regex instances per call in server-side hasEnvVarInHostname - Fix authority extraction to terminate at /, ?, or # per RFC 3986 - Prevents bypass via https://evil.com?token={{SECRET}} (no path) - Add test cases for query-only and fragment-only env var URLs (53 total)
1 parent 89b83df commit eb2b5d6

File tree

3 files changed

+31
-10
lines changed

3 files changed

+31
-10
lines changed

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,19 @@ const logger = createLogger('McpSettings')
113113
* can't be determined until resolution — but env vars only in the path/query
114114
* do NOT bypass the check.
115115
*/
116-
const ENV_VAR_PATTERN = /\{\{[^}]+\}\}/g
116+
const ENV_VAR_PATTERN = /\{\{[^}]+\}\}/
117117

118118
function hasEnvVarInHostname(url: string): boolean {
119119
// If the entire URL is an env var, hostname is unknown
120-
if (url.trim().replace(ENV_VAR_PATTERN, '').trim() === '') return true
120+
const globalPattern = new RegExp(ENV_VAR_PATTERN.source, 'g')
121+
if (url.trim().replace(globalPattern, '').trim() === '') return true
121122
const protocolEnd = url.indexOf('://')
122123
if (protocolEnd === -1) return ENV_VAR_PATTERN.test(url)
124+
// Extract authority per RFC 3986 (terminated by /, ?, or #)
123125
const afterProtocol = url.substring(protocolEnd + 3)
124-
const authorityEnd = afterProtocol.indexOf('/')
126+
const authorityEnd = afterProtocol.search(/[/?#]/)
125127
const authority = authorityEnd === -1 ? afterProtocol : afterProtocol.substring(0, authorityEnd)
126-
return new RegExp(ENV_VAR_PATTERN.source).test(authority)
128+
return ENV_VAR_PATTERN.test(authority)
127129
}
128130

129131
function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean {

apps/sim/lib/mcp/domain-check.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ describe('isMcpDomainAllowed', () => {
178178
false
179179
)
180180
})
181+
182+
it('rejects disallowed domain with env var in query but no path', () => {
183+
expect(isMcpDomainAllowed('https://evil.com?token={{SECRET}}')).toBe(false)
184+
})
185+
186+
it('rejects disallowed domain with env var in fragment but no path', () => {
187+
expect(isMcpDomainAllowed('https://evil.com#{{SECTION}}')).toBe(false)
188+
})
181189
})
182190

183191
describe('env var security edge cases', () => {
@@ -276,6 +284,18 @@ describe('validateMcpDomain', () => {
276284
it('does not throw for allowed URL with env var in path', () => {
277285
expect(() => validateMcpDomain('https://allowed.com/{{PATH}}')).not.toThrow()
278286
})
287+
288+
it('throws for disallowed URL with env var in query but no path', () => {
289+
expect(() => validateMcpDomain('https://evil.com?token={{SECRET}}')).toThrow(
290+
McpDomainNotAllowedError
291+
)
292+
})
293+
294+
it('throws for disallowed URL with env var in fragment but no path', () => {
295+
expect(() => validateMcpDomain('https://evil.com#{{SECTION}}')).toThrow(
296+
McpDomainNotAllowedError
297+
)
298+
})
279299
})
280300
})
281301
})

apps/sim/lib/mcp/domain-check.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,18 @@ function checkMcpDomain(url: string): string | null {
3030
* env vars in the path/query do NOT bypass the domain check.
3131
*/
3232
function hasEnvVarInHostname(url: string): boolean {
33-
const envVarPattern = createEnvVarPattern()
3433
// If the entire URL is an env var reference, hostname is unknown
35-
if (url.trim().replace(envVarPattern, '').trim() === '') return true
34+
if (url.trim().replace(createEnvVarPattern(), '').trim() === '') return true
3635
try {
37-
// Extract the authority portion (between :// and the next /)
36+
// Extract the authority portion (between :// and the first /, ?, or # per RFC 3986)
3837
const protocolEnd = url.indexOf('://')
39-
if (protocolEnd === -1) return envVarPattern.test(url)
38+
if (protocolEnd === -1) return createEnvVarPattern().test(url)
4039
const afterProtocol = url.substring(protocolEnd + 3)
41-
const authorityEnd = afterProtocol.indexOf('/')
40+
const authorityEnd = afterProtocol.search(/[/?#]/)
4241
const authority = authorityEnd === -1 ? afterProtocol : afterProtocol.substring(0, authorityEnd)
4342
return createEnvVarPattern().test(authority)
4443
} catch {
45-
return envVarPattern.test(url)
44+
return createEnvVarPattern().test(url)
4645
}
4746
}
4847

0 commit comments

Comments
 (0)