diff --git a/packages/validators/src/__tests__/url.test.ts b/packages/validators/src/__tests__/url.test.ts index 3c739c4..b11ef3f 100644 --- a/packages/validators/src/__tests__/url.test.ts +++ b/packages/validators/src/__tests__/url.test.ts @@ -74,6 +74,48 @@ describe('UrlValidator with custom host whitelist', () => { }) }) +describe('UrlValidator with disallowHostnames', () => { + const validator = new UrlValidator({ + whitelist: { + protocols: ['http', 'https'], + disallowHostnames: true, + }, + }) + + it('should not throw an error with a proper domain', () => { + expect(() => validator.parse('https://example.com')).not.toThrow() + }) + + it('should throw an error with a hostname', () => { + expect(() => validator.parse('https://tld')).toThrow(UrlValidationError) + expect(() => validator.parse('https://.tld')).toThrow(UrlValidationError) + expect(() => validator.parse('https://tld.')).toThrow(UrlValidationError) + expect(() => validator.parse('https://.tld.')).toThrow(UrlValidationError) + expect(() => validator.parse('https://tld/')).toThrow(UrlValidationError) + expect(() => validator.parse('https://.tld/')).toThrow(UrlValidationError) + expect(() => validator.parse('https://tld./')).toThrow(UrlValidationError) + expect(() => validator.parse('https://.tld./')).toThrow(UrlValidationError) + }) +}) + +describe('UrlValidator with both hosts and disallowHostnames', () => { + const validator = new UrlValidator({ + whitelist: { + protocols: ['http', 'https'], + hosts: ['example.com', 'localhost'], + disallowHostnames: true, + }, + }) + + it('should not throw an error when the host is on the whitelist', () => { + expect(() => validator.parse('https://example.com')).not.toThrow() + }) + + it('should ignore the disallowHostnames option', () => { + expect(() => validator.parse('https://localhost')).not.toThrow() + }) +}) + describe('UrlValidator with base URL', () => { const validator = new UrlValidator({ baseOrigin: 'https://example.com', @@ -144,6 +186,7 @@ describe('createUrlSchema', () => { whitelist: { protocols: ['http', 'https'], hosts: ['example.com'], + disallowHostnames: true, }, }), ).not.toThrow() diff --git a/packages/validators/src/url/options.ts b/packages/validators/src/url/options.ts index 63d0e89..e44961d 100644 --- a/packages/validators/src/url/options.ts +++ b/packages/validators/src/url/options.ts @@ -3,6 +3,7 @@ import { z } from 'zod' export const defaultOptions = { whitelist: { protocols: ['http', 'https'], + disallowHostnames: false, }, } @@ -19,6 +20,14 @@ export const whitelistSchema = z.object({ * It is recommended to provide a list of allowed hostnames to prevent open redirects. */ hosts: z.array(z.string()).optional(), + /** + * Whether to disallow hostnames as valid URLs. + * For example, if disallowHostnames is set to `true`, https://localhost/somepath will be invalid. + * This option is IGNORED if hosts is provided. + * + * @defaultValue false + */ + disallowHostnames: z.boolean().optional(), }) /** diff --git a/packages/validators/src/url/utils.ts b/packages/validators/src/url/utils.ts index 95b3268..4541eac 100644 --- a/packages/validators/src/url/utils.ts +++ b/packages/validators/src/url/utils.ts @@ -2,6 +2,7 @@ import { UrlValidationError } from '@/url/errors' import { UrlValidatorWhitelist } from '@/url/options' const DYNAMIC_ROUTE_SEGMENT_REGEX = /\[\[?([^\]]+)\]?\]/g +const IS_NOT_HOSTNAME_REGEX = /[^.]+\.[^.]+/g export const resolveRelativeUrl = (url: string, baseOrigin?: URL): URL => { if (!baseOrigin) { @@ -45,10 +46,18 @@ export const isSafeUrl = (url: URL, whitelist: UrlValidatorWhitelist) => { if (!whitelist.protocols.some(protocol => url.protocol === `${protocol}:`)) { return false } - // only allow whitelisted hosts - if (whitelist.hosts && !whitelist.hosts.some(host => url.host === host)) { - return false + if (whitelist.hosts) { + // only allow whitelisted hosts + if (!whitelist.hosts.some(host => url.host === host)) { + return false + } + } else { + // no hosts provided + if (whitelist.disallowHostnames && !url.host.match(IS_NOT_HOSTNAME_REGEX)) { + return false + } } + // don't allow dynamic routes if (resolveNextDynamicRoute(url).href !== url.href) { return false