Skip to content

Commit 2442551

Browse files
committed
fix(workday): validate tenantUrl to prevent SSRF in SOAP client
1 parent 5cf7e8d commit 2442551

File tree

3 files changed

+418
-9
lines changed

3 files changed

+418
-9
lines changed

apps/sim/lib/core/security/input-validation.test.ts

Lines changed: 272 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { featureFlagsMock } from '@sim/testing'
2-
import { describe, expect, it, vi } from 'vitest'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33
import {
44
validateAirtableId,
55
validateAlphanumericId,
66
validateAwsRegion,
7+
validateCallbackUrl,
78
validateEnum,
89
validateExternalUrl,
910
validateFileExtension,
@@ -21,7 +22,9 @@ import {
2122
validatePathSegment,
2223
validateProxyUrl,
2324
validateS3BucketName,
25+
validateServiceNowInstanceUrl,
2426
validateSupabaseProjectId,
27+
validateWorkdayTenantUrl,
2528
} from '@/lib/core/security/input-validation'
2629
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
2730
import { sanitizeForLogging } from '@/lib/core/security/redaction'
@@ -1837,3 +1840,271 @@ describe('validateMondayColumnId', () => {
18371840
})
18381841
})
18391842
})
1843+
1844+
describe('validateCallbackUrl', () => {
1845+
const ORIGIN = 'https://sim.app'
1846+
const originalWindow = (globalThis as { window?: unknown }).window
1847+
1848+
beforeEach(() => {
1849+
;(globalThis as { window?: unknown }).window = {
1850+
location: { origin: ORIGIN },
1851+
}
1852+
})
1853+
1854+
afterEach(() => {
1855+
if (originalWindow === undefined) {
1856+
;(globalThis as { window?: unknown }).window = undefined
1857+
} else {
1858+
;(globalThis as { window?: unknown }).window = originalWindow
1859+
}
1860+
})
1861+
1862+
describe('accepts legitimate same-origin URLs', () => {
1863+
it.each([
1864+
['/workspace'],
1865+
['/invite/abc-123'],
1866+
['/invite/abc?foo=bar&baz=qux'],
1867+
['/workspace#section'],
1868+
['/credential-account/456'],
1869+
['?reset=true'],
1870+
['/'],
1871+
['https://sim.app/workspace'],
1872+
['https://sim.app/'],
1873+
['HTTPS://SIM.APP/foo'],
1874+
])('accepts %s', (url) => {
1875+
expect(validateCallbackUrl(url)).toBe(true)
1876+
})
1877+
})
1878+
1879+
describe('rejects open-redirect payloads', () => {
1880+
it.each([
1881+
['', 'empty string'],
1882+
['//evil.com', 'protocol-relative'],
1883+
['/\\evil.com', 'backslash protocol-relative'],
1884+
['\\\\evil.com', 'double backslash'],
1885+
['/\t/evil.com', 'tab-stripped protocol-relative'],
1886+
['/\n/evil.com', 'newline-stripped protocol-relative'],
1887+
['/\r/evil.com', 'CR-stripped protocol-relative'],
1888+
['https://evil.com', 'cross-origin absolute URL'],
1889+
['https://sim.app@evil.com', 'userinfo smuggling'],
1890+
['https://sim.app.evil.com', 'subdomain confusion'],
1891+
['https://sim.app:3001/foo', 'different port'],
1892+
['http://sim.app/foo', 'different protocol'],
1893+
['javascript:alert(1)', 'javascript scheme'],
1894+
['data:text/html,<script>alert(1)</script>', 'data scheme'],
1895+
['vbscript:msgbox', 'vbscript scheme'],
1896+
])('rejects %s (%s)', (url) => {
1897+
expect(validateCallbackUrl(url)).toBe(false)
1898+
})
1899+
})
1900+
1901+
describe('server-side (no window)', () => {
1902+
beforeEach(() => {
1903+
;(globalThis as { window?: unknown }).window = undefined
1904+
})
1905+
1906+
it('falls back to placeholder origin and still rejects cross-origin URLs', () => {
1907+
expect(validateCallbackUrl('/workspace')).toBe(true)
1908+
expect(validateCallbackUrl('//evil.com')).toBe(false)
1909+
expect(validateCallbackUrl('https://evil.com')).toBe(false)
1910+
expect(validateCallbackUrl('javascript:alert(1)')).toBe(false)
1911+
})
1912+
})
1913+
})
1914+
1915+
describe('validateServiceNowInstanceUrl', () => {
1916+
describe('valid ServiceNow instance URLs', () => {
1917+
it.concurrent('should accept *.service-now.com', () => {
1918+
const result = validateServiceNowInstanceUrl('https://acme.service-now.com')
1919+
expect(result.isValid).toBe(true)
1920+
expect(result.sanitized).toBe('https://acme.service-now.com')
1921+
})
1922+
1923+
it.concurrent('should accept *.servicenow.com', () => {
1924+
const result = validateServiceNowInstanceUrl('https://acme.servicenow.com')
1925+
expect(result.isValid).toBe(true)
1926+
})
1927+
1928+
it.concurrent('should accept *.servicenowservices.com (GovCloud)', () => {
1929+
const result = validateServiceNowInstanceUrl('https://acme.servicenowservices.com')
1930+
expect(result.isValid).toBe(true)
1931+
})
1932+
1933+
it.concurrent('should accept URLs with paths', () => {
1934+
const result = validateServiceNowInstanceUrl('https://acme.service-now.com/api/now/table')
1935+
expect(result.isValid).toBe(true)
1936+
})
1937+
1938+
it.concurrent('should accept multi-level subdomains', () => {
1939+
const result = validateServiceNowInstanceUrl('https://dev.acme.service-now.com')
1940+
expect(result.isValid).toBe(true)
1941+
})
1942+
})
1943+
1944+
describe('invalid hosts — allowlist rejection', () => {
1945+
it.concurrent('should reject attacker-controlled domains', () => {
1946+
const result = validateServiceNowInstanceUrl('https://evil.com')
1947+
expect(result.isValid).toBe(false)
1948+
expect(result.error).toContain('ServiceNow-hosted domain')
1949+
})
1950+
1951+
it.concurrent('should reject lookalike suffixes', () => {
1952+
const result = validateServiceNowInstanceUrl('https://acme.service-now.com.evil.com')
1953+
expect(result.isValid).toBe(false)
1954+
})
1955+
1956+
it.concurrent('should reject embedded substrings', () => {
1957+
const result = validateServiceNowInstanceUrl('https://service-now.com.evil.com')
1958+
expect(result.isValid).toBe(false)
1959+
})
1960+
1961+
it.concurrent('should reject vanity CNAME hosts (Custom URL plugin)', () => {
1962+
const result = validateServiceNowInstanceUrl('https://support.acme.com')
1963+
expect(result.isValid).toBe(false)
1964+
expect(result.error).toContain('ServiceNow-hosted domain')
1965+
})
1966+
1967+
it.concurrent('should reject userinfo smuggling', () => {
1968+
const result = validateServiceNowInstanceUrl('https://acme.service-now.com@evil.com')
1969+
expect(result.isValid).toBe(false)
1970+
})
1971+
})
1972+
1973+
describe('invalid URLs — delegated to validateExternalUrl', () => {
1974+
it.concurrent('should reject null', () => {
1975+
const result = validateServiceNowInstanceUrl(null)
1976+
expect(result.isValid).toBe(false)
1977+
})
1978+
1979+
it.concurrent('should reject empty string', () => {
1980+
const result = validateServiceNowInstanceUrl('')
1981+
expect(result.isValid).toBe(false)
1982+
})
1983+
1984+
it.concurrent('should reject http:// protocol', () => {
1985+
const result = validateServiceNowInstanceUrl('http://acme.service-now.com')
1986+
expect(result.isValid).toBe(false)
1987+
expect(result.error).toContain('https://')
1988+
})
1989+
1990+
it.concurrent('should reject private IPs', () => {
1991+
const result = validateServiceNowInstanceUrl('https://192.168.1.1')
1992+
expect(result.isValid).toBe(false)
1993+
expect(result.error).toContain('private IP')
1994+
})
1995+
1996+
it.concurrent('should reject link-local metadata IP', () => {
1997+
const result = validateServiceNowInstanceUrl('https://169.254.169.254')
1998+
expect(result.isValid).toBe(false)
1999+
})
2000+
2001+
it.concurrent('should reject blocked ports', () => {
2002+
const result = validateServiceNowInstanceUrl('https://acme.service-now.com:22')
2003+
expect(result.isValid).toBe(false)
2004+
expect(result.error).toContain('blocked port')
2005+
})
2006+
2007+
it.concurrent('should reject malformed URLs', () => {
2008+
const result = validateServiceNowInstanceUrl('not-a-url')
2009+
expect(result.isValid).toBe(false)
2010+
})
2011+
})
2012+
})
2013+
2014+
describe('validateWorkdayTenantUrl', () => {
2015+
describe('valid Workday tenant URLs', () => {
2016+
it.concurrent('should accept *.workday.com implementation tenants', () => {
2017+
const result = validateWorkdayTenantUrl('https://wd2-impl-services1.workday.com')
2018+
expect(result.isValid).toBe(true)
2019+
expect(result.sanitized).toBe('https://wd2-impl-services1.workday.com')
2020+
})
2021+
2022+
it.concurrent('should accept *.workday.com production tenants', () => {
2023+
const result = validateWorkdayTenantUrl('https://wd5-services1.workday.com')
2024+
expect(result.isValid).toBe(true)
2025+
})
2026+
2027+
it.concurrent('should accept *.myworkday.com production tenants', () => {
2028+
const result = validateWorkdayTenantUrl('https://wd5-services1.myworkday.com')
2029+
expect(result.isValid).toBe(true)
2030+
})
2031+
2032+
it.concurrent('should accept URLs with trailing slash', () => {
2033+
const result = validateWorkdayTenantUrl('https://wd2-impl-services1.workday.com/')
2034+
expect(result.isValid).toBe(true)
2035+
})
2036+
2037+
it.concurrent('should be case-insensitive for hostname', () => {
2038+
const result = validateWorkdayTenantUrl('https://WD5-Services1.Workday.com')
2039+
expect(result.isValid).toBe(true)
2040+
})
2041+
})
2042+
2043+
describe('invalid hosts — allowlist rejection', () => {
2044+
it.concurrent('should reject attacker-controlled domains', () => {
2045+
const result = validateWorkdayTenantUrl('https://evil.com')
2046+
expect(result.isValid).toBe(false)
2047+
expect(result.error).toContain('Workday-hosted domain')
2048+
})
2049+
2050+
it.concurrent('should reject lookalike suffixes', () => {
2051+
const result = validateWorkdayTenantUrl('https://wd5.workday.com.evil.com')
2052+
expect(result.isValid).toBe(false)
2053+
})
2054+
2055+
it.concurrent('should reject embedded substrings', () => {
2056+
const result = validateWorkdayTenantUrl('https://workday.com.evil.com')
2057+
expect(result.isValid).toBe(false)
2058+
})
2059+
2060+
it.concurrent('should reject near-miss domains', () => {
2061+
const result = validateWorkdayTenantUrl('https://evilworkday.com')
2062+
expect(result.isValid).toBe(false)
2063+
})
2064+
2065+
it.concurrent('should reject userinfo smuggling', () => {
2066+
const result = validateWorkdayTenantUrl('https://wd5.workday.com@evil.com')
2067+
expect(result.isValid).toBe(false)
2068+
})
2069+
})
2070+
2071+
describe('invalid URLs — delegated to validateExternalUrl', () => {
2072+
it.concurrent('should reject null', () => {
2073+
const result = validateWorkdayTenantUrl(null)
2074+
expect(result.isValid).toBe(false)
2075+
})
2076+
2077+
it.concurrent('should reject empty string', () => {
2078+
const result = validateWorkdayTenantUrl('')
2079+
expect(result.isValid).toBe(false)
2080+
})
2081+
2082+
it.concurrent('should reject http:// protocol', () => {
2083+
const result = validateWorkdayTenantUrl('http://wd2-impl-services1.workday.com')
2084+
expect(result.isValid).toBe(false)
2085+
expect(result.error).toContain('https://')
2086+
})
2087+
2088+
it.concurrent('should reject private IPs', () => {
2089+
const result = validateWorkdayTenantUrl('https://192.168.1.1')
2090+
expect(result.isValid).toBe(false)
2091+
expect(result.error).toContain('private IP')
2092+
})
2093+
2094+
it.concurrent('should reject link-local metadata IP (SSRF classic)', () => {
2095+
const result = validateWorkdayTenantUrl('https://169.254.169.254')
2096+
expect(result.isValid).toBe(false)
2097+
})
2098+
2099+
it.concurrent('should reject blocked ports', () => {
2100+
const result = validateWorkdayTenantUrl('https://wd2-impl-services1.workday.com:22')
2101+
expect(result.isValid).toBe(false)
2102+
expect(result.error).toContain('blocked port')
2103+
})
2104+
2105+
it.concurrent('should reject malformed URLs', () => {
2106+
const result = validateWorkdayTenantUrl('not-a-url')
2107+
expect(result.isValid).toBe(false)
2108+
})
2109+
})
2110+
})

0 commit comments

Comments
 (0)