From 53a6a9ad2ad46ffa51cfa80fa4e3b6ca64ffddea Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 27 Nov 2024 14:25:06 +0000 Subject: [PATCH] Wildcard and error improvements with tests --- .../form/fields/TlsCertsField.spec.tsx | 68 +++++++++++++++++++ app/components/form/fields/TlsCertsField.tsx | 60 ++++++++++++++-- 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 app/components/form/fields/TlsCertsField.spec.tsx diff --git a/app/components/form/fields/TlsCertsField.spec.tsx b/app/components/form/fields/TlsCertsField.spec.tsx new file mode 100644 index 000000000..2251bdbef --- /dev/null +++ b/app/components/form/fields/TlsCertsField.spec.tsx @@ -0,0 +1,68 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { describe, expect, it } from 'vitest' + +import { matchesDomain, parseCertificate } from './TlsCertsField' + +describe('matchesDomain', () => { + it('matches wildcard subdomains', () => { + expect(matchesDomain('*.example.com', 'sub.example.com')).toBe(true) + expect(matchesDomain('*.example.com', 'example.com')).toBe(false) + expect(matchesDomain('*', 'any.domain')).toBe(false) + }) + + it('matches exact matches', () => { + expect(matchesDomain('example.com', 'example.com')).toBe(true) + expect(matchesDomain('example.com', 'www.example.com')).toBe(false) + }) + + it('matches multiple subdomains', () => { + expect(matchesDomain('*.example.com', 'sub.sub.example.com')).toBe(true) + expect(matchesDomain('*.example.com', 'sub.sub.sub.example.com')).toBe(true) + }) + + it('matches with case insensitivity', () => { + expect(matchesDomain('EXAMPLE.COM', 'example.com')).toBe(true) + expect(matchesDomain('example.com', 'EXAMPLE.COM')).toBe(true) + }) + + it('does not match incorrect wildcards', () => { + expect(matchesDomain('test.*', 'test.com')).toBe(false) + expect(matchesDomain('test.*', 'test.net')).toBe(false) + }) +}) + +describe('parseCertificate', () => { + const validCert = `-----BEGIN CERTIFICATE-----\nMIIDbjCCAlagAwIBAgIUVF36cv2UevtKOGWP3GNV1h+TpScwDQYJKoZIhvcNAQEL\nBQAwGzEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLmNvbTAeFw0yNDExMjcxNDE4MTha\nFw0yNTExMjcxNDE4MThaMBsxGTAXBgNVBAMMEHRlc3QuZXhhbXBsZS5jb20wggEi\nMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0cBavU9cnrTY7CaOsHdfzr7e4\nmT7eRCGJa1jmuGeADGIs1IcMr/7jgiKS/1P69SehfqpFWXKAYn5OH+ickZfs55AB\nuyfh+KogmTkX6I40CnP9GohfgAaDVr119a2kdJNvinsCjNGfulMBYiw+sJBp4l/c\nzQRYMXaMk1ARKBgUuVZHZXnkWQKjp/GAQjVsUjl/dnBVeUuS4/0OVTLL8U6mGzdy\nf5s03bpBLOOJ9Owg1We5urYA6glCvvMh1VhBPsCnHFj6aYLnnWpJkVuJEKA+znEU\nU2n6T0bQorzVnn5ROtAn3ao4sGIVMbMeIaEvUt3zyVk+gtUvqSTPChFde6/LAgMB\nAAGjgakwgaYwHQYDVR0OBBYEFFzp73YRPxxu4bTQvmJy5rqHNXh7MB8GA1UdIwQY\nMBaAFFzp73YRPxxu4bTQvmJy5rqHNXh7MA8GA1UdEwEB/wQFMAMBAf8wUwYDVR0R\nBEwwSoIQdGVzdC5leGFtcGxlLmNvbYISKi50ZXN0LmV4YW1wbGUuY29tghEqLmRl\ndi5leGFtcGxlLmNvbYIJbG9jYWxob3N0hwR/AAABMA0GCSqGSIb3DQEBCwUAA4IB\nAQCstbMiTwHuSlwuUslV9SxewdxTtKAjNgUnCn1Jv7hs44wNTBqvMzDq2HB26wRR\nOnbt6gReOj9GdSRmJPNcgouaAGJWCXuaZPs34LgRJir6Z0FVcK7/O3SqfTOg3tJg\ngzg4xmtzXc7Im4VgvaLS5iXCOvUaKf/rXeYDa3r37EF+vyzcETt5bXwtU8BBFvVT\nJfPDla5lYv0h9Z+XsYEAqtbChdy+fVuHnF+EygZCT9KVFBPWQrsaF1Qc/CvP/+LM\nCrdLoB+2pkWbX075tv8LIbL2dW5Gzyw+lU6lzPL9Vikm3QXGRklKHA4SVuZ3F9tr\nwPRLWb4aPmo1COkgvg3Moqdw\n-----END CERTIFICATE-----` + + const invalidCert = 'not-a-certificate' + + it('parses valid certificate', () => { + const result = parseCertificate(validCert) + expect(result).toEqual({ + commonName: ['test.example.com'], + subjectAltNames: [ + 'test.example.com', + '*.test.example.com', + '*.dev.example.com', + 'localhost', + '127.0.0.1', + ], + isValid: true, + }) + }) + + it('returns invalid for invalid certificate', () => { + const result = parseCertificate(invalidCert) + expect(result).toEqual({ + commonName: [], + subjectAltNames: [], + isValid: false, + }) + }) +}) diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 462e7c1e1..bf32dd3d9 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -181,41 +181,89 @@ const validateCertificate = async (file: File) => { return parseCertificate(await file.text()) } -function parseCertificate(certPem: string) { +export function parseCertificate(certPem: string) { try { const cert = new X509Certificate(certPem) const nameItems = cert.getExtension(SubjectAlternativeNameExtension)?.names.items || [] return { commonName: cert.subjectName.getField('CN') || [], subjectAltNames: nameItems.map((item) => item.value) || [], + isValid: true, } } catch { - return null + return { + commonName: [], + subjectAltNames: [], + isValid: false, + } } } -function matchesDomain(pattern: string, domain: string): boolean { +export function matchesDomain(pattern: string, domain: string): boolean { const patternParts = pattern.split('.') const domainParts = domain.split('.') - if (patternParts.length !== domainParts.length) return false + // unsure if this would be an issue but we reject it anyway + if (pattern === '*') { + return false + } + + if (patternParts[0] === '*') { + // if the pattern starts with a wildcard + const patternSuffix = patternParts.slice(1).join('.') + // we want wildcard domains to have same number or more parts + // and must match the pattern suffix exactly + return domainParts.length >= patternParts.length && domain.endsWith(patternSuffix) + } - return patternParts.every( - (part, i) => part === '*' || part.toLowerCase() === domainParts[i].toLowerCase() + // parts must match exactly for non-wildcard patterns + return ( + patternParts.length === domainParts.length && + patternParts.every((part, i) => part.toLowerCase() === domainParts[i].toLowerCase()) ) } function CertDomainNotice({ commonName = [], subjectAltNames = [], + isValid = true, siloName, domain, }: { commonName?: string[] subjectAltNames?: string[] + isValid?: boolean siloName: string domain: string }) { + if (!isValid) { + return ( + +
+ Certificate may not be valid, a silo expects a X.509 cert in PEM format. +
+
+ Learn more about{' '} + + silo certs + + +
+ + } + /> + ) + } + if (commonName.length === 0 && subjectAltNames.length === 0) { return null }