diff --git a/packages/vite/src/node/__tests__/utils.spec.ts b/packages/vite/src/node/__tests__/utils.spec.ts index 519d179143b4ff..bd52e6b13d65e4 100644 --- a/packages/vite/src/node/__tests__/utils.spec.ts +++ b/packages/vite/src/node/__tests__/utils.spec.ts @@ -7,6 +7,7 @@ import { asyncFlatten, bareImportRE, combineSourcemaps, + extractHostnamesFromCerts, extractHostnamesFromSubjectAltName, flattenId, generateCodeFrame, @@ -22,10 +23,44 @@ import { posToNumber, processSrcSetSync, resolveHostname, + resolveServerUrls, } from '../utils' import { isWindows } from '../../shared/utils' import type { CommonServerOptions, ResolvedServerUrls } from '..' +// Test certificate for SAN parsing (localhost, foo.localhost, *.vite.localhost) +// Generate once: +// openssl req -x509 -nodes -newkey rsa:2048 -days 365 -subj "/CN=example.org" \ +// -addext "subjectAltName=DNS:localhost,DNS:foo.localhost,DNS:*.vite.localhost" \ +// -keyout /tmp/test.key -out /tmp/test.crt +// Paste /tmp/test.crt below. +const WORKING_TEST_CERT = ` +-----BEGIN CERTIFICATE----- +MIID7zCCAtegAwIBAgIJS9D2rIN7tA8mMA0GCSqGSIb3DQEBCwUAMGkxFDASBgNV +BAMTC2V4YW1wbGUub3JnMQswCQYDVQQGEwJVUzERMA8GA1UECBMIVmlyZ2luaWEx +EzARBgNVBAcTCkJsYWNrc2J1cmcxDTALBgNVBAoTBFRlc3QxDTALBgNVBAsTBFRl +c3QwHhcNMjUwMTMwMDQxNTI1WhcNMjUwMzAxMDQxNTI1WjBpMRQwEgYDVQQDEwtl +eGFtcGxlLm9yZzELMAkGA1UEBhMCVVMxETAPBgNVBAgTCFZpcmdpbmlhMRMwEQYD +VQQHEwpCbGFja3NidXJnMQ0wCwYDVQQKEwRUZXN0MQ0wCwYDVQQLEwRUZXN0MIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxNPlCqTmUZ7/F7GyFWDopqZ6 +w19Y7/98B10JEeFGTAQIj/RP2UgZNcTABQDUvtkF7y+bOeoVJW7Zz8ozQYhRaDp8 +CN2gXMcYeTUku/pKLXyCzHHVrOPAXTeU7sMRgLvPCrrJtx5OjvndW+O/PhohPRi3 +iEpPvpM8gi7MVRGhnWVSx0/Ynx5c0+/vqyBTzrM2OX7Ufg8Nv7LaTXpCAnmIQp+f +Sqq7HZ7t6Y7laS4RApityvlnFHZ4f2cEibAKv/vXLED7bgAlGb8R1viPRdMtAPuI +MYvHBgGFjyX1fmq6Mz3aqlAscJILtbQlwty1oYyaENE0lq8+nZXQ+t6I+CIVLQID +AQABo4GZMIGWMAsGA1UdDwQEAwIC9DAxBgNVHSUEKjAoBggrBgEFBQcDAQYIKwYB +BQUHAwIGCCsGAQUFBwMDBggrBgEFBQcDCDBUBgNVHREETTBLgglsb2NhbGhvc3SC +DWZvby5sb2NhbGhvc3SCECoudml0ZS5sb2NhbGhvc3SCBVs6OjFdhwR/AAABhxD+ +gAAAAAAAAAAAAAAAAAABMA0GCSqGSIb3DQEBCwUAA4IBAQBi302qLCgxWsUalgc2 +olFxVKob1xCciS8yUVX6HX0vza0WJ7oGW6qZsBbQtfgDwB/dHv7rwsfpjRWvFhmq +gEUrewa1h0TIC+PPTYYz4M0LOwcLIWZLZr4am1eI7YP9NDgRdhfAfM4hw20vjf2a +kYLKyRTC5+3/ly5opMq+CGLQ8/gnFxhP3ho8JYrRnqLeh3KCTGen3kmbAhD4IOJ9 +lxMwFPTTWLFFjxbXjXmt5cEiL2mpcq13VCF2HmheCen37CyYIkrwK9IfLhBd5QQh +WEIBLwjKCAscrtyayXWp6zUTmgvb8PQf//3Mh2DiEngAi3WI/nL+8Y0RkqbvxBar +X2JN +-----END CERTIFICATE----- +`.trim() + describe('bareImportRE', () => { test('should work with normal package name', () => { expect(bareImportRE.test('vite')).toBe(true) @@ -218,33 +253,7 @@ describe('extractHostnamesFromSubjectAltName', () => { } test('should extract names from actual certificate', () => { - const certText = ` ------BEGIN CERTIFICATE----- -MIID7zCCAtegAwIBAgIJS9D2rIN7tA8mMA0GCSqGSIb3DQEBCwUAMGkxFDASBgNV -BAMTC2V4YW1wbGUub3JnMQswCQYDVQQGEwJVUzERMA8GA1UECBMIVmlyZ2luaWEx -EzARBgNVBAcTCkJsYWNrc2J1cmcxDTALBgNVBAoTBFRlc3QxDTALBgNVBAsTBFRl -c3QwHhcNMjUwMTMwMDQxNTI1WhcNMjUwMzAxMDQxNTI1WjBpMRQwEgYDVQQDEwtl -eGFtcGxlLm9yZzELMAkGA1UEBhMCVVMxETAPBgNVBAgTCFZpcmdpbmlhMRMwEQYD -VQQHEwpCbGFja3NidXJnMQ0wCwYDVQQKEwRUZXN0MQ0wCwYDVQQLEwRUZXN0MIIB -IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxNPlCqTmUZ7/F7GyFWDopqZ6 -w19Y7/98B10JEeFGTAQIj/RP2UgZNcTABQDUvtkF7y+bOeoVJW7Zz8ozQYhRaDp8 -CN2gXMcYeTUku/pKLXyCzHHVrOPAXTeU7sMRgLvPCrrJtx5OjvndW+O/PhohPRi3 -iEpPvpM8gi7MVRGhnWVSx0/Ynx5c0+/vqyBTzrM2OX7Ufg8Nv7LaTXpCAnmIQp+f -Sqq7HZ7t6Y7laS4RApityvlnFHZ4f2cEibAKv/vXLED7bgAlGb8R1viPRdMtAPuI -MYvHBgGFjyX1fmq6Mz3aqlAscJILtbQlwty1oYyaENE0lq8+nZXQ+t6I+CIVLQID -AQABo4GZMIGWMAsGA1UdDwQEAwIC9DAxBgNVHSUEKjAoBggrBgEFBQcDAQYIKwYB -BQUHAwIGCCsGAQUFBwMDBggrBgEFBQcDCDBUBgNVHREETTBLgglsb2NhbGhvc3SC -DWZvby5sb2NhbGhvc3SCECoudml0ZS5sb2NhbGhvc3SCBVs6OjFdhwR/AAABhxD+ -gAAAAAAAAAAAAAAAAAABMA0GCSqGSIb3DQEBCwUAA4IBAQBi302qLCgxWsUalgc2 -olFxVKob1xCciS8yUVX6HX0vza0WJ7oGW6qZsBbQtfgDwB/dHv7rwsfpjRWvFhmq -gEUrewa1h0TIC+PPTYYz4M0LOwcLIWZLZr4am1eI7YP9NDgRdhfAfM4hw20vjf2a -kYLKyRTC5+3/ly5opMq+CGLQ8/gnFxhP3ho8JYrRnqLeh3KCTGen3kmbAhD4IOJ9 -lxMwFPTTWLFFjxbXjXmt5cEiL2mpcq13VCF2HmheCen37CyYIkrwK9IfLhBd5QQh -WEIBLwjKCAscrtyayXWp6zUTmgvb8PQf//3Mh2DiEngAi3WI/nL+8Y0RkqbvxBar -X2JN ------END CERTIFICATE----- - `.trim() - const cert = new crypto.X509Certificate(certText) + const cert = new crypto.X509Certificate(WORKING_TEST_CERT) expect( extractHostnamesFromSubjectAltName(cert.subjectAltName ?? ''), ).toStrictEqual([ @@ -908,3 +917,145 @@ describe('getServerUrlByHost', () => { }) } }) + +describe('extractHostnamesFromCerts', () => { + test('should extract hostnames from certificate', () => { + const certs = [WORKING_TEST_CERT] + const result = extractHostnamesFromCerts(certs) + expect(result).toStrictEqual([ + 'localhost', + 'foo.localhost', + 'vite.vite.localhost', + ]) + }) + + test('should extract hostnames from multiple certificates', () => { + const certs = [WORKING_TEST_CERT, WORKING_TEST_CERT] + const result = extractHostnamesFromCerts(certs) + expect(result).toStrictEqual([ + 'localhost', + 'foo.localhost', + 'vite.vite.localhost', + ]) + }) +}) + +describe('resolveServerUrls', () => { + const createMockServer = ( + family: 'IPv4' | 'IPv6' = 'IPv4', + address: string = '127.0.0.1', + ) => + ({ + address: () => ({ port: 3000, address, family }), + }) as any + + const createTestConfig = () => ({ + options: { https: true } as any, + hostname: { host: '127.0.0.1', name: 'localhost' } as any, + config: { rawBase: '/' } as any, + }) + + test('should handle no certificate', () => { + const mockServer = createMockServer() + const { options, hostname, config } = createTestConfig() + const httpsOptions = {} + + const result = resolveServerUrls( + mockServer, + options, + hostname, + httpsOptions, + config, + ) + + expect(result.local).toContain('https://localhost:3000/') + }) + + test('should handle IPv4 single certificate', () => { + const mockServer = createMockServer() + const { options, hostname, config } = createTestConfig() + const httpsOptions = { cert: [WORKING_TEST_CERT] } + + const result = resolveServerUrls( + mockServer, + options, + hostname, + httpsOptions, + config, + ) + + expect(result.local).toContain('https://localhost:3000/') + expect(result.local).toContain('https://foo.localhost:3000/') + expect(result.local).toContain('https://vite.vite.localhost:3000/') + }) + + test('should handle IPv4 multiple certificates', () => { + const mockServer = createMockServer() + const { options, hostname, config } = createTestConfig() + const httpsOptions = { cert: [WORKING_TEST_CERT, WORKING_TEST_CERT] } + + const result = resolveServerUrls( + mockServer, + options, + hostname, + httpsOptions, + config, + ) + + expect(result.local).toContain('https://localhost:3000/') + expect(result.local).toContain('https://foo.localhost:3000/') + expect(result.local).toContain('https://vite.vite.localhost:3000/') + }) + + test('should handle IPv6 single certificate', () => { + const mockServer = createMockServer('IPv6', '::1') + const { options, hostname, config } = createTestConfig() + const httpsOptions = { cert: [WORKING_TEST_CERT] } + + const result = resolveServerUrls( + mockServer, + options, + hostname, + httpsOptions, + config, + ) + + expect(result.local).toContain('https://localhost:3000/') + expect(result.local).toContain('https://foo.localhost:3000/') + expect(result.local).toContain('https://vite.vite.localhost:3000/') + }) + + test('should handle IPv6 multiple certificates', () => { + const mockServer = createMockServer('IPv6', '::1') + const { options, hostname, config } = createTestConfig() + const httpsOptions = { cert: [WORKING_TEST_CERT, WORKING_TEST_CERT] } + + const result = resolveServerUrls( + mockServer, + options, + hostname, + httpsOptions, + config, + ) + + expect(result.local).toContain('https://localhost:3000/') + expect(result.local).toContain('https://foo.localhost:3000/') + expect(result.local).toContain('https://vite.vite.localhost:3000/') + }) + + test('should handle invalid certificate', () => { + const mockServer = createMockServer() + const { options, hostname, config } = createTestConfig() + const httpsOptions = { cert: ['invalid-cert'] } + + const result = resolveServerUrls( + mockServer, + options, + hostname, + httpsOptions, + config, + ) + + expect(result.local).toContain('https://localhost:3000/') + }) +}) diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 937c4cc8c2300d..d9981e72c20757 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -993,6 +993,29 @@ export async function resolveHostname( return { host, name } } +export function extractHostnamesFromCerts( + certs: HttpsServerOptions['cert'] | undefined, +): string[] { + const certList = certs ? arraify(certs) : [] + if (certList.length === 0) return [] + + const hostnames = certList + .map((cert) => { + try { + return new crypto.X509Certificate(cert) + } catch { + return null + } + }) + .flatMap((cert) => + cert?.subjectAltName + ? extractHostnamesFromSubjectAltName(cert.subjectAltName) + : [], + ) + + return unique(hostnames) +} + export function resolveServerUrls( server: Server, options: CommonServerOptions, @@ -1045,19 +1068,12 @@ export function resolveServerUrls( }) } - const cert = - httpsOptions?.cert && !Array.isArray(httpsOptions.cert) - ? new crypto.X509Certificate(httpsOptions.cert) - : undefined - const hostnameFromCert = cert?.subjectAltName - ? extractHostnamesFromSubjectAltName(cert.subjectAltName) - : [] - - if (hostnameFromCert.length > 0) { + const hostnamesFromCert = extractHostnamesFromCerts(httpsOptions?.cert) + if (hostnamesFromCert.length > 0) { const existings = new Set([...local, ...network]) local.push( - ...hostnameFromCert - .map((hostname) => `https://${hostname}:${port}${base}`) + ...hostnamesFromCert + .map((hostname) => `${protocol}://${hostname}:${port}${base}`) .filter((url) => !existings.has(url)), ) }