Skip to content
205 changes: 178 additions & 27 deletions packages/vite/src/node/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
asyncFlatten,
bareImportRE,
combineSourcemaps,
extractHostnamesFromCerts,
extractHostnamesFromSubjectAltName,
flattenId,
generateCodeFrame,
Expand All @@ -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)
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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/')
})
})
38 changes: 27 additions & 11 deletions packages/vite/src/node/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we log a warning instead of completely ignoring an invalid cert?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume the error would be shown when starting the server. Since this is an optional feature, I think it's fine to ignore the error here.

}
})
.flatMap((cert) =>
cert?.subjectAltName

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For whatever reason this took me a minute to parse.

Have you considered moving from the ternary .filter((cert) => Boolean(cert?.subjectAltName))?

Quick test to show that should cover all the cases:

let c = [{subjectAltName: 'asdf'}, {}, null, {subjectAltName: null}, {subjectAltName: ''}];
c.filter((cert) => Boolean(cert?.subjectAltName)).length === 1

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript cannot infer the types with

    .filter((cert) => Boolean(cert?.subjectAltName))
    .flatMap((cert) => extractHostnamesFromSubjectAltName(cert.subjectAltName)) // cert may be null

so I think it's better to keep it as-is.

? extractHostnamesFromSubjectAltName(cert.subjectAltName)
: [],
)

return unique(hostnames)
}

export function resolveServerUrls(
server: Server,
options: CommonServerOptions,
Expand Down Expand Up @@ -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)),
)
}
Expand Down
Loading