11import { featureFlagsMock } from '@sim/testing'
2- import { describe , expect , it , vi } from 'vitest'
2+ import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
33import {
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'
2629import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
2730import { 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