diff --git a/backend/package-lock.json b/backend/package-lock.json index 6f7cfee04..f22fd0b7b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -63,6 +63,7 @@ "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", "axios": "^1.11.0", + "axios-ntlm": "^1.4.4", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "botbuilder": "^4.23.2", @@ -78,7 +79,6 @@ "googleapis": "^137.1.0", "handlebars": "^4.7.8", "hdb": "^0.19.10", - "httpntlm": "^1.8.13", "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", @@ -14978,6 +14978,18 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-ntlm": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/axios-ntlm/-/axios-ntlm-1.4.4.tgz", + "integrity": "sha512-kpCRdzMfL8gi0Z0o96P3QPAK4XuC8iciGgxGXe+PeQ4oyjI2LZN8WSOKbu0Y9Jo3T/A7pB81n6jYVPIpglEuRA==", + "license": "MIT", + "dependencies": { + "axios": "^1.8.4", + "des.js": "^1.1.0", + "dev-null": "^0.1.1", + "js-md4": "^0.3.2" + } + }, "node_modules/axios-retry": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.0.0.tgz", @@ -16774,6 +16786,12 @@ "node": ">=8" } }, + "node_modules/dev-null": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dev-null/-/dev-null-0.1.1.tgz", + "integrity": "sha512-nMNZG0zfMgmdv8S5O0TM5cpwNbGKRGPCxVsr0SmA3NZZy9CYBbuNLL0PD3Acx9e5LIUgwONXtM9kM6RlawPxEQ==", + "license": "MIT" + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -19605,39 +19623,6 @@ "node": ">=0.10" } }, - "node_modules/httpntlm": { - "version": "1.8.13", - "resolved": "https://registry.npmjs.org/httpntlm/-/httpntlm-1.8.13.tgz", - "integrity": "sha512-2F2FDPiWT4rewPzNMg3uPhNkP3NExENlUGADRUDPQvuftuUTGW98nLZtGemCIW3G40VhWZYgkIDcQFAwZ3mf2Q==", - "funding": [ - { - "type": "paypal", - "url": "https://www.paypal.com/donate/?hosted_button_id=2CKNJLZJBW8ZC" - }, - { - "type": "buymeacoffee", - "url": "https://www.buymeacoffee.com/samdecrock" - } - ], - "dependencies": { - "des.js": "^1.0.1", - "httpreq": ">=0.4.22", - "js-md4": "^0.3.2", - "underscore": "~1.12.1" - }, - "engines": { - "node": ">=10.4.0" - } - }, - "node_modules/httpreq": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/httpreq/-/httpreq-1.1.1.tgz", - "integrity": "sha512-uhSZLPPD2VXXOSN8Cni3kIsoFHaU2pT/nySEU/fHr/ePbqHYr0jeiQRmUKLEirC09SFPsdMoA7LU7UXMd/w0Kw==", - "license": "MIT", - "engines": { - "node": ">= 6.15.1" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -30362,12 +30347,6 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, - "node_modules/underscore": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", - "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", - "license": "MIT" - }, "node_modules/undici": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", diff --git a/backend/package.json b/backend/package.json index e5dcdd1cc..cc02186e9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -183,6 +183,7 @@ "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", "axios": "^1.11.0", + "axios-ntlm": "^1.4.4", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "botbuilder": "^4.23.2", @@ -198,7 +199,6 @@ "googleapis": "^137.1.0", "handlebars": "^4.7.8", "hdb": "^0.19.10", - "httpntlm": "^1.8.13", "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 76ad706cd..c5d3ccabe 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2314,9 +2314,13 @@ export const AppConnections = { apiToken: "The API token used to authenticate with Okta." }, AZURE_ADCS: { - adcsUrl: "The URL of the Azure ADCS instance to connect with.", - username: "The username used to access Azure ADCS.", - password: "The password used to access Azure ADCS." + adcsUrl: + "The HTTPS URL of the Azure ADCS instance to connect with (e.g., 'https://adcs.yourdomain.com/certsrv').", + username: "The username used to access Azure ADCS (format: 'DOMAIN\\username' or 'username@domain.com').", + password: "The password used to access Azure ADCS.", + sslRejectUnauthorized: + "Whether or not to reject unauthorized SSL certificates (true/false). Set to false only in test environments with self-signed certificates.", + sslCertificate: "The SSL certificate (PEM format) to use for secure connection with a self-signed certificate." } } }; diff --git a/backend/src/server/routes/v1/pki-subscriber-router.ts b/backend/src/server/routes/v1/pki-subscriber-router.ts index 029ed8010..d9f6ff6f0 100644 --- a/backend/src/server/routes/v1/pki-subscriber-router.ts +++ b/backend/src/server/routes/v1/pki-subscriber-router.ts @@ -1,3 +1,4 @@ +import RE2 from "re2"; import { z } from "zod"; import { CertificatesSchema } from "@app/db/schemas"; @@ -116,12 +117,81 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => properties: z .object({ azureTemplateType: z.string().optional().describe("Azure ADCS Certificate Template Type"), - organization: z.string().optional().describe("Organization (O)"), - organizationalUnit: z.string().optional().describe("Organizational Unit (OU)"), - country: z.string().length(2).optional().describe("Country (C) - Two letter country code"), - state: z.string().optional().describe("State/Province (ST)"), - locality: z.string().optional().describe("Locality (L)"), - emailAddress: z.string().email().optional().describe("Email Address") + organization: z + .string() + .trim() + .min(1) + .max(64, "Organization cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organization contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Organization cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organization (O) - Maximum 64 characters, no special DN characters"), + organizationalUnit: z + .string() + .trim() + .min(1) + .max(64, "Organizational Unit cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organizational Unit contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + /^[^\s-_.]+.*[^\s-_.]+$|^[^\s-_.]{1}$/, + "Organizational Unit cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organizational Unit (OU) - Maximum 64 characters, no special DN characters"), + country: z + .string() + .trim() + .length(2, "Country must be exactly 2 characters") + .regex(new RE2("^[A-Z]{2}$"), "Country must be exactly 2 uppercase letters") + .optional() + .describe("Country (C) - Two uppercase letter country code (e.g., US, CA, GB)"), + state: z + .string() + .trim() + .min(1) + .max(64, "State cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'State contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + /^[^\s-_.]+.*[^\s-_.]+$|^[^\s-_.]{1}$/, + "State cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("State/Province (ST) - Maximum 64 characters, no special DN characters"), + locality: z + .string() + .trim() + .min(1) + .max(64, "Locality cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Locality contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + /^[^\s-_.]+.*[^\s-_.]+$|^[^\s-_.]{1}$/, + "Locality cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Locality (L) - Maximum 64 characters, no special DN characters"), + emailAddress: z + .string() + .trim() + .email("Email Address must be a valid email format") + .min(6, "Email Address must be at least 6 characters") + .max(64, "Email Address cannot exceed 64 characters") + .optional() + .describe("Email Address - Valid email format between 6 and 64 characters") }) .optional() .describe("Additional subscriber properties and subject fields") @@ -215,12 +285,81 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => properties: z .object({ azureTemplateType: z.string().optional().describe("Azure ADCS Certificate Template Type"), - organization: z.string().optional().describe("Organization (O)"), - organizationalUnit: z.string().optional().describe("Organizational Unit (OU)"), - country: z.string().length(2).optional().describe("Country (C) - Two letter country code"), - state: z.string().optional().describe("State/Province (ST)"), - locality: z.string().optional().describe("Locality (L)"), - emailAddress: z.string().email().optional().describe("Email Address") + organization: z + .string() + .trim() + .min(1) + .max(64, "Organization cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organization contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Organization cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organization (O) - Maximum 64 characters, no special DN characters"), + organizationalUnit: z + .string() + .trim() + .min(1) + .max(64, "Organizational Unit cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organizational Unit contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + /^[^\s-_.]+.*[^\s-_.]+$|^[^\s-_.]{1}$/, + "Organizational Unit cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organizational Unit (OU) - Maximum 64 characters, no special DN characters"), + country: z + .string() + .trim() + .length(2, "Country must be exactly 2 characters") + .regex(new RE2("^[A-Z]{2}$"), "Country must be exactly 2 uppercase letters") + .optional() + .describe("Country (C) - Two uppercase letter country code (e.g., US, CA, GB)"), + state: z + .string() + .trim() + .min(1) + .max(64, "State cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'State contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + /^[^\s-_.]+.*[^\s-_.]+$|^[^\s-_.]{1}$/, + "State cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("State/Province (ST) - Maximum 64 characters, no special DN characters"), + locality: z + .string() + .trim() + .min(1) + .max(64, "Locality cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Locality contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + /^[^\s-_.]+.*[^\s-_.]+$|^[^\s-_.]{1}$/, + "Locality cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Locality (L) - Maximum 64 characters, no special DN characters"), + emailAddress: z + .string() + .trim() + .email("Email Address must be a valid email format") + .min(6, "Email Address must be at least 6 characters") + .max(64, "Email Address cannot exceed 64 characters") + .optional() + .describe("Email Address - Valid email format between 6 and 64 characters") }) .optional() .describe("Additional subscriber properties and subject fields") diff --git a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-fns.ts b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-fns.ts index b0d0e18da..4e6d466bc 100644 --- a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-fns.ts +++ b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-fns.ts @@ -1,8 +1,7 @@ /* eslint-disable no-case-declarations, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-var-requires, no-await-in-loop, no-continue */ -// @ts-expect-error: No types available for httpntlm -import * as httpntlm from "httpntlm"; +import { NtlmClient } from "axios-ntlm"; +import https from "https"; -import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url"; import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; @@ -13,23 +12,24 @@ import { AppConnection } from "../app-connection-enums"; import { AzureADCSConnectionMethod } from "./azure-adcs-connection-enums"; import { TAzureADCSConnectionConfig } from "./azure-adcs-connection-types"; -// Type definitions for httpntlm module -interface HttpNtlmRequestOptions { +// Type definitions for axios-ntlm +interface AxiosNtlmConfig { + ntlm: { + domain: string; + username: string; + password: string; + }; + httpsAgent?: https.Agent; url: string; - username: string; - password: string; - domain: string; - workstation?: string; method?: string; - body?: string; + data?: string; headers?: Record; - rejectUnauthorized?: boolean; } -interface HttpNtlmResponse { - statusCode: number; - body: string; - headers: Record; +interface AxiosNtlmResponse { + status: number; + data: string; + headers: unknown; } // Types for credential parsing @@ -104,31 +104,65 @@ const normalizeAdcsUrl = (url: string): string => { }; // NTLM request wrapper -const ntlmRequest = (options: HttpNtlmRequestOptions): Promise => { - return new Promise((resolve, reject) => { - const method = options.method || "GET"; +const createHttpsAgent = (sslRejectUnauthorized: boolean, sslCertificate?: string): https.Agent => { + const agentOptions: https.AgentOptions = { + rejectUnauthorized: sslRejectUnauthorized, + keepAlive: true // axios-ntlm needs keepAlive for NTLM handshake + }; - if (method.toLowerCase() === "get") { - httpntlm.get(options, (err: Error | null, res: HttpNtlmResponse) => { - if (err) reject(err); - else resolve(res); - }); - } else if (method.toLowerCase() === "post") { - httpntlm.post(options, (err: Error | null, res: HttpNtlmResponse) => { - if (err) reject(err); - else resolve(res); - }); - } else { - reject(new Error(`Unsupported HTTP method: ${method}`)); - } - }); + if (sslCertificate && sslCertificate.trim()) { + agentOptions.ca = [sslCertificate.trim()]; + } + + return new https.Agent(agentOptions); +}; + +const axiosNtlmRequest = async (config: AxiosNtlmConfig): Promise => { + const method = config.method || "GET"; + + const credentials = { + username: config.ntlm.username, + password: config.ntlm.password, + domain: config.ntlm.domain || "", + workstation: "" + }; + + const axiosConfig = { + httpsAgent: config.httpsAgent, + timeout: 30000 + }; + + const client = NtlmClient(credentials, axiosConfig); + + const requestOptions: { url: string; method: string; data?: string; headers?: Record } = { + url: config.url, + method + }; + + if (config.data) { + requestOptions.data = config.data; + } + + if (config.headers) { + requestOptions.headers = config.headers; + } + + const response = await client(requestOptions); + + return { + status: response.status, + data: response.data, + headers: response.headers + }; }; // Test ADCS connectivity and authentication using NTLM const testAdcsConnection = async ( credentials: ParsedCredentials, password: string, - baseUrl: string + baseUrl: string, + sslRejectUnauthorized: boolean = true, + sslCertificate?: string ): Promise => { // Test endpoints in order of preference const testEndpoints = [ @@ -142,18 +176,24 @@ const testAdcsConnection = async ( try { const testUrl = `${baseUrl}${endpoint}`; - const response = await ntlmRequest({ + const shouldRejectUnauthorized = sslRejectUnauthorized; + + const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate); + + const response = await axiosNtlmRequest({ url: testUrl, - username: credentials.username, - password, - domain: credentials.domain, - workstation: "", - rejectUnauthorized: !getConfig().isDevelopmentMode // Only allow unauthorized SSL in development + method: "GET", + httpsAgent, + ntlm: { + domain: credentials.domain, + username: credentials.username, + password + } }); // Check if we got a successful response - if (response.statusCode === 200) { - const responseText = response.body; + if (response.status === 200) { + const responseText = response.data; // Verify this is actually an ADCS server by checking content const adcsIndicators = [ @@ -175,14 +215,13 @@ const testAdcsConnection = async ( } } - // Handle authentication failures - if (response.statusCode === 401) { + if (response.status === 401) { throw new BadRequestError({ - message: "Authentication failed. Please verify your username, password, and domain are correct." + message: "Authentication failed. Please verify your credentials are correct." }); } - if (response.statusCode === 403) { + if (response.status === 403) { throw new BadRequestError({ message: "Access denied. Your account may not have permission to access ADCS web enrollment." }); @@ -204,11 +243,27 @@ const testAdcsConnection = async ( message: "Connection refused by ADCS server. Please verify the server is running and accessible." }); } - if (error.message.includes("ETIMEDOUT")) { + if (error.message.includes("ETIMEDOUT") || error.message.includes("timeout")) { throw new BadRequestError({ message: "Connection timeout. Please verify the server is accessible and not blocked by firewall." }); } + if (error.message.includes("certificate") || error.message.includes("SSL") || error.message.includes("TLS")) { + throw new BadRequestError({ + message: `SSL/TLS certificate error: ${error.message}. This may indicate a certificate verification failure.` + }); + } + if (error.message.includes("DEPTH_ZERO_SELF_SIGNED_CERT")) { + throw new BadRequestError({ + message: + "Self-signed certificate detected. Either provide the server's certificate or set 'sslRejectUnauthorized' to false." + }); + } + if (error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE")) { + throw new BadRequestError({ + message: "Unable to verify certificate signature. Please provide the correct CA certificate." + }); + } } // Continue to next endpoint for other errors @@ -223,37 +278,53 @@ const testAdcsConnection = async ( }; // Create authenticated NTLM client for ADCS operations -const createNtlmClient = (username: string, password: string, baseUrl: string) => { +const createNtlmClient = ( + username: string, + password: string, + baseUrl: string, + sslRejectUnauthorized: boolean = true, + sslCertificate?: string +) => { const parsedCredentials = parseCredentials(username); const normalizedUrl = normalizeAdcsUrl(baseUrl); return { - get: (endpoint: string, additionalOptions: Partial = {}) => { - return ntlmRequest({ + get: async (endpoint: string, additionalHeaders: Record = {}) => { + const shouldRejectUnauthorized = sslRejectUnauthorized; + + const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate); + + return axiosNtlmRequest({ url: `${normalizedUrl}${endpoint}`, - username: parsedCredentials.username, - password, - domain: parsedCredentials.domain, - workstation: "", - rejectUnauthorized: !getConfig().isDevelopmentMode, // Only allow unauthorized SSL in development, - ...additionalOptions + method: "GET", + httpsAgent, + headers: additionalHeaders, + ntlm: { + domain: parsedCredentials.domain, + username: parsedCredentials.username, + password + } }); }, - post: (endpoint: string, body: string, additionalOptions: Partial = {}) => { - return ntlmRequest({ - method: "POST", + post: async (endpoint: string, body: string, additionalHeaders: Record = {}) => { + const shouldRejectUnauthorized = sslRejectUnauthorized; + + const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate); + + return axiosNtlmRequest({ url: `${normalizedUrl}${endpoint}`, - username: parsedCredentials.username, - password, - domain: parsedCredentials.domain, - workstation: "", - rejectUnauthorized: !getConfig().isDevelopmentMode, // Only allow unauthorized SSL in development, - body, + method: "POST", + httpsAgent, + data: body, headers: { "Content-Type": "application/x-www-form-urlencoded", - ...additionalOptions.headers + ...additionalHeaders }, - ...additionalOptions + ntlm: { + domain: parsedCredentials.domain, + username: parsedCredentials.username, + password + } }); }, baseUrl: normalizedUrl, @@ -282,12 +353,20 @@ export const getAzureADCSConnectionCredentials = async ( orgId: appConnection.orgId, kmsService, encryptedCredentials: appConnection.encryptedCredentials - })) as { username: string; password: string; adcsUrl: string }; + })) as { + username: string; + password: string; + adcsUrl: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + }; return { username: credentials.username, password: credentials.password, - adcsUrl: credentials.adcsUrl + adcsUrl: credentials.adcsUrl, + sslRejectUnauthorized: credentials.sslRejectUnauthorized ?? true, + sslCertificate: credentials.sslCertificate }; default: @@ -309,13 +388,21 @@ export const validateAzureADCSConnectionCredentials = async (appConnection: TAzu await blockLocalAndPrivateIpAddresses(normalizedUrl); // Test the connection using NTLM - await testAdcsConnection(parsedCredentials, credentials.password, normalizedUrl); + await testAdcsConnection( + parsedCredentials, + credentials.password, + normalizedUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); // If we get here, authentication was successful return { username: credentials.username, password: credentials.password, - adcsUrl: credentials.adcsUrl + adcsUrl: credentials.adcsUrl, + sslRejectUnauthorized: credentials.sslRejectUnauthorized ?? true, + sslCertificate: credentials.sslCertificate }; } catch (error) { if (error instanceof BadRequestError) { @@ -334,9 +421,11 @@ export const validateAzureADCSConnectionCredentials = async (appConnection: TAzu } else if ( error.message.includes("certificate") || error.message.includes("SSL") || - error.message.includes("TLS") + error.message.includes("TLS") || + error.message.includes("DEPTH_ZERO_SELF_SIGNED_CERT") || + error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE") ) { - errorMessage = "SSL/TLS certificate error. The server certificate may be self-signed or invalid."; + errorMessage = `SSL/TLS certificate error: ${error.message}. The server certificate may be self-signed or the CA certificate may be incorrect.`; } } @@ -355,6 +444,12 @@ export const getAzureADCSConnectionListItem = () => ({ }); // Export helper functions for use in certificate ordering -export const createAdcsHttpClient = (username: string, password: string, baseUrl: string) => { - return createNtlmClient(username, password, baseUrl); +export const createAdcsHttpClient = ( + username: string, + password: string, + baseUrl: string, + sslRejectUnauthorized: boolean = true, + sslCertificate?: string +) => { + return createNtlmClient(username, password, baseUrl, sslRejectUnauthorized, sslCertificate); }; diff --git a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts index 8fab35ea3..a43bb5954 100644 --- a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts @@ -10,12 +10,13 @@ import { import { AzureADCSConnectionMethod } from "./azure-adcs-connection-enums"; -export const AzureADCSConnectionAccessTokenCredentialsSchema = z.object({ +export const AzureADCSUsernamePasswordCredentialsSchema = z.object({ adcsUrl: z .string() .trim() .min(1, "ADCS URL required") .max(255) + .refine((value) => value.startsWith("https://"), "ADCS URL must use HTTPS") .describe(AppConnections.CREDENTIALS.AZURE_ADCS.adcsUrl), username: z .string() @@ -28,21 +29,31 @@ export const AzureADCSConnectionAccessTokenCredentialsSchema = z.object({ .trim() .min(1, "Password required") .max(255) - .describe(AppConnections.CREDENTIALS.AZURE_ADCS.password) + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.password), + sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.AZURE_ADCS.sslRejectUnauthorized), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.sslCertificate) }); const BaseAzureADCSConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AzureADCS) }); export const AzureADCSConnectionSchema = BaseAzureADCSConnectionSchema.extend({ method: z.literal(AzureADCSConnectionMethod.UsernamePassword), - credentials: AzureADCSConnectionAccessTokenCredentialsSchema + credentials: AzureADCSUsernamePasswordCredentialsSchema }); export const SanitizedAzureADCSConnectionSchema = z.discriminatedUnion("method", [ BaseAzureADCSConnectionSchema.extend({ method: z.literal(AzureADCSConnectionMethod.UsernamePassword), - credentials: AzureADCSConnectionAccessTokenCredentialsSchema.pick({ - username: true + credentials: AzureADCSUsernamePasswordCredentialsSchema.pick({ + username: true, + adcsUrl: true, + sslRejectUnauthorized: true, + sslCertificate: true }) }) ]); @@ -52,7 +63,7 @@ export const ValidateAzureADCSConnectionCredentialsSchema = z.discriminatedUnion method: z .literal(AzureADCSConnectionMethod.UsernamePassword) .describe(AppConnections.CREATE(AppConnection.AzureADCS).method), - credentials: AzureADCSConnectionAccessTokenCredentialsSchema.describe( + credentials: AzureADCSUsernamePasswordCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureADCS).credentials ) }) @@ -64,7 +75,7 @@ export const CreateAzureADCSConnectionSchema = ValidateAzureADCSConnectionCreden export const UpdateAzureADCSConnectionSchema = z .object({ - credentials: AzureADCSConnectionAccessTokenCredentialsSchema.optional().describe( + credentials: AzureADCSUsernamePasswordCredentialsSchema.optional().describe( AppConnections.UPDATE(AppConnection.AzureADCS).credentials ) }) diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-enums.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-enums.ts deleted file mode 100644 index d442dad30..000000000 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-enums.ts +++ /dev/null @@ -1,12 +0,0 @@ -export enum AzureAdCsTemplateType { - WEB_SERVER = "WebServer", - COMPUTER = "Computer", - USER = "User", - DOMAIN_CONTROLLER = "DomainController", - SUBORDINATE_CA = "SubordinateCA" -} - -export enum AzureAdCsAuthMethod { - CLIENT_CERTIFICATE = "client-certificate", - KERBEROS = "kerberos" -} diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts index 527332b82..92f99cede 100644 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts @@ -78,71 +78,106 @@ const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberPropertie throw new BadRequestError({ message: "Common Name is required and cannot be empty" }); } - const sanitizedCN = commonName.trim().replace(new RE2("[,=+<>#;\\\\\\]]", "g"), ""); - if (!sanitizedCN) { - throw new BadRequestError({ message: "Common Name contains only invalid characters" }); + const trimmedCN = commonName.trim(); + + const invalidCharsRegex = new RE2("[,=+<>#;\\\\\\]]"); + if (invalidCharsRegex.test(trimmedCN)) { + throw new BadRequestError({ + message: "Common Name contains invalid characters: , = + < > # ; \\ ]" + }); } - let subject = `CN=${sanitizedCN}`; + let subject = `CN=${trimmedCN}`; - // Helper function to validate and sanitize DN component values - const sanitizeComponent = (value: string | undefined): string | null => { + const validateComponent = (value: string | undefined, componentName: string): string | null => { if (!value || typeof value !== "string") return null; const trimmed = value.trim(); if (!trimmed) return null; - // Remove or escape problematic characters for DN components - // Be more aggressive in removing characters that can cause OID issues - const sanitized = trimmed.replace(new RE2('[,=+<>#;\\\\"\\/\\r\\n\\t]', "g"), "").trim(); + const componentInvalidCharsRegex = new RE2('[,=+<>#;\\\\"\\/\\r\\n\\t]'); + if (componentInvalidCharsRegex.test(trimmed)) { + throw new BadRequestError({ + message: `${componentName} contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t` + }); + } - // Additional validation to prevent empty components that cause OID errors - if (sanitized.length === 0) return null; + const problematicCharsRegex = new RE2("^[\\\\s\\\\-_.]+|[\\\\s\\\\-_.]+$"); + if (problematicCharsRegex.test(trimmed)) { + throw new BadRequestError({ + message: `${componentName} cannot start or end with spaces, hyphens, underscores, or periods` + }); + } - // Ensure the component doesn't start or end with spaces or problematic chars - const finalSanitized = sanitized.replace(new RE2("^[\\\\s\\\\-_.]+|[\\\\s\\\\-_.]+$", "g"), ""); - - return finalSanitized.length > 0 ? finalSanitized : null; + return trimmed; }; - // Build DN components in proper order for ADCS compatibility - // Order matters for Microsoft ADCS - follow X.500 standard ordering - - const emailAddress = sanitizeComponent(properties?.emailAddress); + const emailAddress = validateComponent(properties?.emailAddress, "Email Address"); if (emailAddress) { - // Enhanced email validation for DN usage const emailRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/); - if (emailRegex.test(emailAddress) && emailAddress.length > 5 && emailAddress.length < 64) { - subject += `,E=${emailAddress}`; + if (!emailRegex.test(emailAddress) || emailAddress.length <= 5 || emailAddress.length >= 64) { + throw new BadRequestError({ + message: "Email Address must be a valid email format between 5 and 64 characters" + }); } + subject += `,E=${emailAddress}`; } - const organizationalUnit = sanitizeComponent(properties?.organizationalUnit); - if (organizationalUnit && organizationalUnit.length <= 64) { + const organizationalUnit = validateComponent(properties?.organizationalUnit, "Organizational Unit"); + if (organizationalUnit) { + if (organizationalUnit.length > 64) { + throw new BadRequestError({ + message: "Organizational Unit cannot exceed 64 characters" + }); + } subject += `,OU=${organizationalUnit}`; } - const organization = sanitizeComponent(properties?.organization); - if (organization && organization.length <= 64) { + const organization = validateComponent(properties?.organization, "Organization"); + if (organization) { + if (organization.length > 64) { + throw new BadRequestError({ + message: "Organization cannot exceed 64 characters" + }); + } subject += `,O=${organization}`; } - const locality = sanitizeComponent(properties?.locality); - if (locality && locality.length <= 64) { + const locality = validateComponent(properties?.locality, "Locality"); + if (locality) { + if (locality.length > 64) { + throw new BadRequestError({ + message: "Locality cannot exceed 64 characters" + }); + } subject += `,L=${locality}`; } - const state = sanitizeComponent(properties?.state); - if (state && state.length <= 64) { + const state = validateComponent(properties?.state, "State"); + if (state) { + if (state.length > 64) { + throw new BadRequestError({ + message: "State cannot exceed 64 characters" + }); + } subject += `,ST=${state}`; } - const country = sanitizeComponent(properties?.country); + const country = validateComponent(properties?.country, "Country"); if (country) { // Country code must be exactly 2 uppercase letters - const countryCode = country.toUpperCase().replace(new RE2("[^A-Z]", "g"), ""); - if (countryCode.length === 2) { - subject += `,C=${countryCode}`; + const countryCode = country.toUpperCase(); + const invalidCountryRegex = new RE2("[^A-Z]"); + if (invalidCountryRegex.test(countryCode)) { + throw new BadRequestError({ + message: "Country must contain only uppercase letters" + }); } + if (countryCode.length !== 2) { + throw new BadRequestError({ + message: "Country must be exactly 2 characters" + }); + } + subject += `,C=${countryCode}`; } return subject; @@ -155,6 +190,12 @@ export const castDbEntryToAzureAdCsCertificateAuthority = ( throw new BadRequestError({ message: "Malformed Azure AD Certificate Service certificate authority" }); } + if (!ca.externalCa.dnsAppConnectionId) { + throw new BadRequestError({ + message: "Azure ADCS connection ID is missing from certificate authority configuration" + }); + } + return { id: ca.id, type: CaType.AZURE_AD_CS, @@ -163,19 +204,25 @@ export const castDbEntryToAzureAdCsCertificateAuthority = ( projectId: ca.projectId, credentials: ca.externalCa.credentials, configuration: { - azureAdcsConnectionId: ca.externalCa.dnsAppConnectionId as string + azureAdcsConnectionId: ca.externalCa.dnsAppConnectionId }, status: ca.status as CaStatus }; }; const submitCertificateRequest = async ( - credentials: { username: string; password: string }, + credentials: { username: string; password: string; sslRejectUnauthorized?: boolean; sslCertificate?: string }, caServiceUrl: string, certificateRequest: AzureCertificateRequest ): Promise => { try { - const adcsClient = createAdcsHttpClient(credentials.username, credentials.password, caServiceUrl); + const adcsClient = createAdcsHttpClient( + credentials.username, + credentials.password, + caServiceUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); // Clean CSR by removing headers and newlines for ADCS submission const cleanCsr = certificateRequest.csr @@ -203,14 +250,22 @@ const submitCertificateRequest = async ( // Add ExpirationDate attribute (requires EDITF_ATTRIBUTEENDDATE flag on CA) certAttribParts.push(`ExpirationDate:${rfc2616Date}`); } catch (error) { - // Invalid TTL format - will use template default validity period + throw new BadRequestError({ + message: "Invalid validity period format" + }); } } // Join all attributes with proper CRLF ending const certAttrib = certAttribParts.length > 0 ? `${certAttribParts.join("\r\n")}\r\n` : ""; - // Prepare form data for ADCS web interface - correct format based on Microsoft docs + // Prepare form data for ADCS web interface - these parameters are required by Microsoft ADCS + // Mode: "newreq" indicates a new certificate request + // CertRequest: the base64-encoded CSR without headers + // CertAttrib: certificate template and other attributes in CRLF format + // FriendlyType: display name for the certificate type + // TargetStoreFlags: certificate store flags (0 = default) + // SaveCert: "yes" to save the certificate to the server const formData = new URLSearchParams({ Mode: "newreq", CertRequest: cleanCsr, @@ -222,7 +277,7 @@ const submitCertificateRequest = async ( const response = await adcsClient.post("/certsrv/certfnsh.asp", formData.toString()); - const responseText = response.body; + const responseText = response.data; // Parse the HTML response to extract certificate information let requestId: string | undefined; @@ -254,15 +309,8 @@ const submitCertificateRequest = async ( // Validate the certificate format before using it try { - const testCert = new x509.X509Certificate(certificate); - // If we get here, the certificate is valid status = "issued"; - // Use testCert if needed for validation - if (testCert) { - // Certificate is valid - } } catch (error) { - // If the extracted certificate is invalid, treat as pending certificate = ""; status = "pending"; } @@ -348,17 +396,17 @@ const submitCertificateRequest = async ( // Clean up HTML entities and tags from error message errorMessage = errorMessage - .replace(/"/g, '"') - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/&/g, "&") - .replace(/<[^>]*>/g, "") // Remove HTML tags - .replace(/\r\n/g, " ") - .replace(/\n/g, " ") - .replace(/\r/g, " ") - .replace(/\s+/g, " ") - .replace(/\s*[".]?\s*[".]?\s*$/, "") // Remove trailing quotes and periods - .replace(/^\s*[".]?\s*/, "") // Remove leading quotes and periods + .replace(new RE2(""", "g"), '"') + .replace(new RE2("<", "g"), "<") + .replace(new RE2(">", "g"), ">") + .replace(new RE2("&", "g"), "&") + .replace(new RE2("<[^>]*>", "g"), "") // Remove HTML tags + .replace(new RE2("\\r\\n", "g"), " ") + .replace(new RE2("\\n", "g"), " ") + .replace(new RE2("\\r", "g"), " ") + .replace(new RE2("\\s+", "g"), " ") + .replace(new RE2('\\s*[".]*\\s*[".]*\\s*$'), "") // Remove trailing quotes and periods + .replace(new RE2('^\\s*[".]*\\s*'), "") // Remove leading quotes and periods .trim(); // Handle specific Microsoft ADCS OID-related errors @@ -437,20 +485,24 @@ const submitCertificateRequest = async ( }; const retrieveCertificate = async ( - credentials: { username: string; password: string }, + credentials: { username: string; password: string; sslRejectUnauthorized?: boolean; sslCertificate?: string }, caServiceUrl: string, certificateId: string ): Promise => { try { - const adcsClient = createAdcsHttpClient(credentials.username, credentials.password, caServiceUrl); + const adcsClient = createAdcsHttpClient( + credentials.username, + credentials.password, + caServiceUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); const response = await adcsClient.get(`/certsrv/certnew.cer?ReqID=${certificateId}&Enc=b64`, { - headers: { - Accept: "application/pkix-cert,application/x-x509-ca-cert,application/octet-stream,*/*" - } + Accept: "application/pkix-cert,application/x-x509-ca-cert,application/octet-stream,*/*" }); - const certData = response.body; + const certData = response.data; // Check if the response contains HTML indicating the certificate is not ready if (certData.includes("") || certData.includes("taken under submission") || certData.includes("pending")) { @@ -468,7 +520,7 @@ const retrieveCertificate = async ( let cleanCertData = certData.trim(); // Remove any HTML artifacts or unwanted characters, keeping only base64 - cleanCertData = cleanCertData.replace(/[^A-Za-z0-9+/=\s]/g, "").replace(/\s/g, ""); + cleanCertData = cleanCertData.replace(new RE2("[^A-Za-z0-9+/=\\s]", "g"), "").replace(new RE2("\\s", "g"), ""); if (cleanCertData.length < 100) { throw new BadRequestError({ @@ -477,7 +529,7 @@ const retrieveCertificate = async ( } // Format as proper PEM certificate with 64 character lines - const formattedCert = cleanCertData.replace(/(.{64})/g, "$1\n").trim(); + const formattedCert = cleanCertData.replace(new RE2("(.{64})", "g"), "$1\n").trim(); const pemCert = `-----BEGIN CERTIFICATE-----\n${formattedCert}\n-----END CERTIFICATE-----`; // Validate the constructed PEM before returning @@ -730,15 +782,23 @@ export const AzureAdCsCertificateAuthorityFns = ({ }); // Get credentials from the Azure ADCS connection - const { username, password, adcsUrl } = await getAzureADCSConnectionCredentials( - azureCa.configuration.azureAdcsConnectionId, - appConnectionDAL, - kmsService - ); + const { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } = + await getAzureADCSConnectionCredentials( + azureCa.configuration.azureAdcsConnectionId, + appConnectionDAL, + kmsService + ); - const credentials = { + const credentials: { + username: string; + password: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + } = { username, - password + password, + sslRejectUnauthorized, + sslCertificate }; const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); @@ -774,11 +834,7 @@ export const AzureAdCsCertificateAuthorityFns = ({ }); } - let templateValue = templateInput; - - // Always use just the template display name for ADCS submission - // ADCS expects format like "CertificateTemplate:WebServer" not the full template value - templateValue = templateInput; + const templateValue = templateInput; const certificateRequest: AzureCertificateRequest = { csr: csrPem, @@ -867,7 +923,7 @@ export const AzureAdCsCertificateAuthorityFns = ({ if (retryCount === maxRetries) { throw new BadRequestError({ - message: `Certificate request submitted with ID ${submissionResponse.certificateId} but failed to retrieve after ${maxRetries} attempts. The certificate may still be pending approval or processing. Last error: ${lastError?.message || "Unknown error"}` + message: `Certificate request submitted with ID ${submissionResponse.certificateId} but failed to retrieve after ${maxRetries} attempts. The certificate may still be pending approval or processing. Last error: ${lastError?.message || "Unknown error"}. For manual approval scenarios, consider implementing a background polling mechanism.` }); } } @@ -890,7 +946,10 @@ export const AzureAdCsCertificateAuthorityFns = ({ } // Remove any extra whitespace and ensure proper line endings - cleanedCertificatePem = cleanedCertificatePem.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); + cleanedCertificatePem = cleanedCertificatePem + .replace(new RE2("\\r\\n", "g"), "\n") + .replace(new RE2("\\r", "g"), "\n") + .trim(); // Validate that we have both begin and end markers if (!cleanedCertificatePem.includes("-----END CERTIFICATE-----")) { @@ -984,18 +1043,28 @@ export const AzureAdCsCertificateAuthorityFns = ({ } // Get credentials from the Azure ADCS connection - const { username, password, adcsUrl } = await getAzureADCSConnectionCredentials( - azureAdcsConnectionId, - appConnectionDAL, - kmsService - ); + const { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } = + await getAzureADCSConnectionCredentials(azureAdcsConnectionId, appConnectionDAL, kmsService); - const credentials = { + const credentials: { + username: string; + password: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + } = { username, - password + password, + sslRejectUnauthorized, + sslCertificate }; - const client = createAdcsHttpClient(credentials.username, credentials.password, adcsUrl); + const client = createAdcsHttpClient( + credentials.username, + credentials.password, + adcsUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); try { // Get available templates from ADCS web interface and filter to only usable ones @@ -1003,7 +1072,7 @@ export const AzureAdCsCertificateAuthorityFns = ({ try { const requestFormResponse = await client.get("/certsrv/certrqxt.asp"); - const responseText = requestFormResponse.body; + const responseText = requestFormResponse.data; // ADCS returns JavaScript-based template info instead of HTML options // Look for patterns like: getTemplateStringInfo(CTINFO_INDEX_REALNAME, null) and sRealName assignments diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts index 89e5ea3fc..2c2dfe484 100644 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts @@ -11,17 +11,6 @@ export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({ azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID") }); -export const AzureAdCsCertificateAuthorityCredentialsSchema = z - .object({ - clientId: z.string(), - clientSecret: z.string().optional(), - certificateThumbprint: z.string().optional() - }) - .refine((data) => data.clientSecret || data.certificateThumbprint, { - message: "At least one authentication method (clientSecret or certificateThumbprint) must be provided", - path: ["clientSecret"] - }); - export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({ type: z.literal(CaType.AZURE_AD_CS), configuration: AzureAdCsCertificateAuthorityConfigurationSchema diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 656b25bfe..6cf55fc52 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -2,11 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType, TableName } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { - ProjectPermissionActions, - ProjectPermissionCertificateActions, - ProjectPermissionSub -} from "@app/ee/services/permission/project-permission"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; @@ -446,49 +442,6 @@ export const certificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "Invalid certificate authority type" }); }; - const orderSubscriberCertificate = async (subscriberId: string, actor: OrgServiceActor) => { - const subscriber = await pkiSubscriberDAL.findById(subscriberId); - - if (!subscriber.caId) { - throw new BadRequestError({ message: "Subscriber does not have a CA" }); - } - - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); - - const { permission } = await permissionService.getProjectPermission({ - actor: actor.type, - actorId: actor.id, - projectId: ca.projectId, - actorAuthMethod: actor.authMethod, - actorOrgId: actor.orgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, - ProjectPermissionSub.Certificates - ); - - if (!ca.externalCa && !ca.internalCa) { - throw new BadRequestError({ message: "Certificate authority configuration not found" }); - } - - if (ca.externalCa?.type === CaType.ACME) { - return acmeFns.orderSubscriberCertificate(subscriberId); - } - - if (ca.externalCa?.type === CaType.AZURE_AD_CS) { - return azureAdCsFns.orderSubscriberCertificate(subscriberId); - } - - if (ca.internalCa) { - // Handle internal CA certificate ordering - this would need to be implemented - throw new BadRequestError({ message: "Internal CA certificate ordering not yet supported" }); - } - - throw new BadRequestError({ message: "Unsupported certificate authority type" }); - }; - const getAzureAdcsTemplates = async ({ caId, projectId, @@ -530,7 +483,6 @@ export const certificateAuthorityServiceFactory = ({ listCertificateAuthoritiesByProjectId, updateCertificateAuthority, deleteCertificateAuthority, - orderSubscriberCertificate, getAzureAdcsTemplates }; }; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 39af971b5..9f1a5e5c2 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -8,8 +8,6 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; -import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; -import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -21,12 +19,9 @@ import { TAltNameMapping } from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; -import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; -import { AzureAdCsCertificateAuthorityFns } from "../azure-ad-cs/azure-ad-cs-certificate-authority-fns"; import { TCertificateAuthorityCertDALFactory } from "../certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal"; import { CaStatus } from "../certificate-authority-enums"; @@ -38,7 +33,6 @@ import { } from "../certificate-authority-fns"; import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; import { validateAndMapAltNameType } from "../certificate-authority-validators"; -import { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal"; import { TIssueCertWithTemplateDTO } from "./internal-certificate-authority-types"; type TInternalCertificateAuthorityFnsDeps = { @@ -57,83 +51,6 @@ type TInternalCertificateAuthorityFnsDeps = { certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; - appConnectionDAL?: Pick; - appConnectionService?: Pick; - externalCertificateAuthorityDAL?: Pick; - pkiSubscriberDAL?: Pick; -}; - -const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberProperties): string => { - // Validate and sanitize common name - it's required and cannot be empty - if (!commonName || !commonName.trim()) { - throw new BadRequestError({ message: "Common Name is required and cannot be empty" }); - } - - const sanitizedCN = commonName.trim().replace(/[,=+<>#;\\]/g, ""); // Remove problematic characters - if (!sanitizedCN) { - throw new BadRequestError({ message: "Common Name contains only invalid characters" }); - } - - let subject = `CN=${sanitizedCN}`; - - // Helper function to validate and sanitize DN component values - const sanitizeComponent = (value: string | undefined): string | null => { - if (!value || typeof value !== "string") return null; - const trimmed = value.trim(); - if (!trimmed) return null; - - // Remove problematic characters for DN components - const sanitized = trimmed.replace(/[,=+<>#;\\"/\r\n\t]/g, "").trim(); - - // Additional validation to prevent empty components - if (sanitized.length === 0) return null; - - // Ensure the component doesn't start or end with spaces or problematic chars - const finalSanitized = sanitized.replace(/^[\s-_.]+|[\s-_.]+$/g, ""); - - return finalSanitized.length > 0 ? finalSanitized : null; - }; - - // Build DN components in proper X.500 ordering - const emailAddress = sanitizeComponent(properties?.emailAddress); - if (emailAddress) { - // Enhanced email validation for DN usage - const emailRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/); - if (emailRegex.test(emailAddress) && emailAddress.length > 5 && emailAddress.length < 64) { - subject += `,E=${emailAddress}`; - } - } - - const organizationalUnit = sanitizeComponent(properties?.organizationalUnit); - if (organizationalUnit && organizationalUnit.length <= 64) { - subject += `,OU=${organizationalUnit}`; - } - - const organization = sanitizeComponent(properties?.organization); - if (organization && organization.length <= 64) { - subject += `,O=${organization}`; - } - - const locality = sanitizeComponent(properties?.locality); - if (locality && locality.length <= 64) { - subject += `,L=${locality}`; - } - - const state = sanitizeComponent(properties?.state); - if (state && state.length <= 64) { - subject += `,ST=${state}`; - } - - const country = sanitizeComponent(properties?.country); - if (country) { - // Country code must be exactly 2 uppercase letters - const countryCode = country.toUpperCase().replace(/[^A-Z]/g, ""); - if (countryCode.length === 2) { - subject += `,C=${countryCode}`; - } - } - - return subject; }; export const InternalCertificateAuthorityFns = ({ @@ -145,11 +62,7 @@ export const InternalCertificateAuthorityFns = ({ certificateAuthorityCrlDAL, certificateDAL, certificateBodyDAL, - certificateSecretDAL, - appConnectionDAL, - appConnectionService, - externalCertificateAuthorityDAL, - pkiSubscriberDAL + certificateSecretDAL }: TInternalCertificateAuthorityFnsDeps) => { const issueCertificate = async ( subscriber: TPkiSubscribers, @@ -195,7 +108,7 @@ export const InternalCertificateAuthorityFns = ({ const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ - name: buildSubjectDN(subscriber.commonName, subscriber.properties as TPkiSubscriberProperties | undefined), + name: `CN=${subscriber.commonName}`, keys: leafKeys, signingAlgorithm: alg, extensions: [ @@ -611,30 +524,8 @@ export const InternalCertificateAuthorityFns = ({ }; }; - const issueCertificateWithAzureAdCs = async (subscriberId: string) => { - if (!appConnectionDAL || !appConnectionService || !externalCertificateAuthorityDAL || !pkiSubscriberDAL) { - throw new BadRequestError({ message: "Azure AD CS dependencies not available" }); - } - - const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ - appConnectionDAL, - appConnectionService, - certificateAuthorityDAL, - externalCertificateAuthorityDAL, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - kmsService, - projectDAL, - pkiSubscriberDAL - }); - - return azureAdCsFns.orderSubscriberCertificate(subscriberId); - }; - return { issueCertificate, - issueCertificateWithTemplate, - issueCertificateWithAzureAdCs + issueCertificateWithTemplate }; }; diff --git a/docs/documentation/platform/pki/azure-adcs.mdx b/docs/documentation/platform/pki/azure-adcs.mdx index 3231b4cdf..8ce48345f 100644 --- a/docs/documentation/platform/pki/azure-adcs.mdx +++ b/docs/documentation/platform/pki/azure-adcs.mdx @@ -13,6 +13,9 @@ Before setting up ADCS integration, ensure you have: - Domain administrator account with certificate management permissions - ADCS web enrollment enabled on your server - Network connectivity from Infisical to the ADCS server +- **IP whitelisting**: Your ADCS server must allow connections from Infisical's IP addresses + - For Infisical Cloud instances, see [Networking Configuration](/documentation/setup/networking) for the list of IPs to whitelist + - For self-hosted instances, whitelist your Infisical server's IP address - Azure ADCS app connection configured (see [Azure ADCS Connection](/integrations/app-connections/azure-adcs)) ## Complete Workflow: From Setup to Certificate Issuance @@ -112,6 +115,31 @@ Ensure your ADCS templates are configured with: **Dynamic Template Discovery**: Infisical queries your ADCS server in real-time to populate available templates. Only templates you have permission to use will be displayed during certificate issuance. +## Certificate Issuance Limitations + +### Immediate Issuance Only + + +**Manual Approval Not Supported**: Infisical currently supports only **immediate certificate issuance**. Certificates that require manual approval or are held by ADCS policies cannot be issued through Infisical yet. + + +For successful certificate issuance, ensure your ADCS templates and policies are configured to: +- **Auto-approve** certificate requests without manual intervention +- **Not require** administrator approval for the templates you plan to use +- **Allow** the connection account to request and receive certificates immediately + +### What Happens with Manual Approval + +If a certificate request requires manual approval: +1. The request will be submitted to ADCS successfully +2. Infisical will attempt to retrieve the certificate with exponential backoff (up to 5 retries over ~1 minute) +3. If the certificate is not approved within this timeframe, the request will **fail** +4. **No background polling**: Currently, Infisical does not check for certificates that might be approved hours or days later + + +**Future Enhancement**: Background polling for delayed certificate approvals is planned for future releases. For now, configure your ADCS templates for immediate issuance to ensure smooth certificate provisioning. + + ### Certificate Revocation @@ -153,7 +181,27 @@ This allows Infisical to control certificate expiration dates directly. - Ensure the template is properly configured and available in the ADCS web enrollment interface - Templates are dynamically loaded - refresh the PKI Subscriber form if templates don't appear +**Certificate Request Pending/Timeout** +- Check if your ADCS template requires manual approval - Infisical only supports immediate issuance +- Verify the certificate template is configured for auto-approval +- Ensure your connection account has sufficient permissions to request certificates without approval +- Review ADCS server policies that might be holding the certificate request + +**Network Connectivity Issues** +- Verify your ADCS server's firewall allows connections from Infisical +- For Infisical Cloud: Ensure Infisical's IP addresses are whitelisted (see [Networking Configuration](/documentation/setup/networking)) +- For self-hosted: Whitelist your Infisical server's IP address on the ADCS server +- Test HTTPS connectivity to the ADCS web enrollment endpoint +- Check for any network security appliances blocking the connection + **Authentication Failures** - Verify ADCS connection credentials - Check domain account permissions - Ensure network connectivity to ADCS server + +**SSL/TLS Certificate Errors** +- For ADCS servers with self-signed certificates: disable "Reject Unauthorized" in the SSL tab of your Azure ADCS app connection, or provide the certificate in PEM format +- For production environments, consider installing proper SSL certificates on your ADCS server instead of disabling SSL verification +- Common SSL errors: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `SELF_SIGNED_CERT_IN_CHAIN`, `CERT_HAS_EXPIRED` +- The SSL configuration applies to all HTTPS communications between Infisical and your ADCS server +- Only HTTPS URLs are supported - HTTP connections are not allowed for security reasons diff --git a/docs/integrations/app-connections/azure-adcs.mdx b/docs/integrations/app-connections/azure-adcs.mdx index 7403604dc..e5936c5ff 100644 --- a/docs/integrations/app-connections/azure-adcs.mdx +++ b/docs/integrations/app-connections/azure-adcs.mdx @@ -24,11 +24,15 @@ Connect Infisical to Microsoft Active Directory Certificate Services (ADCS) for ![Select Azure ADCS Connection](/images/app-connections/azure-adcs/azure-adcs-select-connection.png) - Fill in the following information: + **Configuration Tab:** - **Name**: Friendly name for this ADCS connection (e.g., "Production ADCS") - - **ADCS URL**: Your ADCS web enrollment URL (e.g., `https://adcs.yourdomain.com/certsrv`) + - **ADCS URL**: Your ADCS HTTPS URL (e.g., `https://adcs.yourdomain.com/certsrv`) - only HTTPS is supported - **Username**: Domain administrator username (format: `DOMAIN\username` or `username@domain.com`) - **Password**: Password for the domain administrator account + + **SSL Tab (for HTTPS connections):** + - **SSL Certificate**: Optional PEM certificate for custom CA certificates or self-signed certificates + - **Reject Unauthorized**: Whether to reject connections with invalid SSL certificates (recommended: keep enabled for production) And click **Connect to ADCS** to establish the connection. ![Connect to ADCS](/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png) diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 43941da41..778656da4 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,15 +1,7 @@ import { AppConnection } from "../appConnections/enums"; import { SshCaStatus } from "../sshCa"; import { SshCertTemplateStatus } from "../sshCertificateTemplates"; -import { - AcmeDnsProvider, - AzureAdCsAuthMethod, - AzureAdCsTemplateType, - CaCapability, - CaStatus, - CaType, - InternalCaType -} from "./enums"; +import { AcmeDnsProvider, CaCapability, CaStatus, CaType, InternalCaType } from "./enums"; export const caTypeToNameMap: { [K in InternalCaType]: string } = { [InternalCaType.ROOT]: "Root", @@ -32,19 +24,6 @@ export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record = { - [AzureAdCsTemplateType.WEB_SERVER]: "Web Server", - [AzureAdCsTemplateType.COMPUTER]: "Computer", - [AzureAdCsTemplateType.USER]: "User", - [AzureAdCsTemplateType.DOMAIN_CONTROLLER]: "Domain Controller", - [AzureAdCsTemplateType.SUBORDINATE_CA]: "Subordinate CA" -}; - -export const AZURE_AD_CS_AUTH_METHOD_NAME_MAP: Record = { - [AzureAdCsAuthMethod.CLIENT_CERTIFICATE]: "Client Certificate", - [AzureAdCsAuthMethod.KERBEROS]: "Kerberos" -}; - export const CA_TYPE_CAPABILITIES_MAP: Record = { [CaType.INTERNAL]: [ CaCapability.ISSUE_CERTIFICATES, @@ -56,11 +35,7 @@ export const CA_TYPE_CAPABILITIES_MAP: Record = { CaCapability.REVOKE_CERTIFICATES, CaCapability.RENEW_CERTIFICATES ], - [CaType.AZURE_AD_CS]: [ - CaCapability.ISSUE_CERTIFICATES, - CaCapability.RENEW_CERTIFICATES - // Note: REVOKE_CERTIFICATES intentionally omitted - not supported by ADCS connector - ] + [CaType.AZURE_AD_CS]: [CaCapability.ISSUE_CERTIFICATES, CaCapability.RENEW_CERTIFICATES] }; /** diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index 95a83ab28..a68f3e862 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -24,19 +24,6 @@ export enum AcmeDnsProvider { Cloudflare = "cloudflare" } -export enum AzureAdCsTemplateType { - WEB_SERVER = "WebServer", - COMPUTER = "Computer", - USER = "User", - DOMAIN_CONTROLLER = "DomainController", - SUBORDINATE_CA = "SubordinateCA" -} - -export enum AzureAdCsAuthMethod { - CLIENT_CERTIFICATE = "client-certificate", - KERBEROS = "kerberos" -} - export enum CaCapability { ISSUE_CERTIFICATES = "issue-certificates", REVOKE_CERTIFICATES = "revoke-certificates", diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 92af5c2e0..36526ec18 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -1,12 +1,4 @@ -export { - AcmeDnsProvider, - AzureAdCsAuthMethod, - AzureAdCsTemplateType, - CaRenewalType, - CaStatus, - CaType, - InternalCaType -} from "./enums"; +export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums"; export { useCreateCa, useCreateCertificate, diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 60b78e7cd..8dd874a75 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -1,13 +1,5 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "../certificates/enums"; -import { - AcmeDnsProvider, - AzureAdCsAuthMethod, - AzureAdCsTemplateType, - CaRenewalType, - CaStatus, - CaType, - InternalCaType -} from "./enums"; +import { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums"; export type TAcmeCertificateAuthority = { id: string; @@ -36,8 +28,7 @@ export type TAzureAdCsCertificateAuthority = { enableDirectIssuance: boolean; configuration: { azureAdcsConnectionId: string; - templateName: AzureAdCsTemplateType; - authMethod: AzureAdCsAuthMethod; + templateName: string; }; }; diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx index 546768523..f6deb97cb 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx @@ -79,14 +79,79 @@ const schema = z enableAutoRenewal: z.boolean().optional().default(false), renewalBefore: z.number().min(1).optional(), renewalUnit: z.nativeEnum(TimeUnit).optional(), - // Properties for Azure ADCS and additional subject fields + // Properties for Azure ADCS only azureTemplateType: z.string().optional(), - organization: z.string().optional(), - organizationalUnit: z.string().optional(), - country: z.string().length(2).optional().or(z.literal("")), - state: z.string().optional(), - locality: z.string().optional(), - emailAddress: z.string().email().optional().or(z.literal("")) + organization: z + .string() + .trim() + .max(64, "Organization cannot exceed 64 characters") + .regex( + /^[^,=+<>#;\\"/\r\n\t]*$/, + 'Organization contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + /^[^\s\-_.]+.*[^\s\-_.]+$|^[^\s\-_.]{1}$/, + "Organization cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .or(z.literal("")), + organizationalUnit: z + .string() + .trim() + .max(64, "Organizational Unit cannot exceed 64 characters") + .regex( + /^[^,=+<>#;\\"/\r\n\t]*$/, + 'Organizational Unit contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + /^[^\s\-_.]+.*[^\s\-_.]+$|^[^\s\-_.]{1}$/, + "Organizational Unit cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .or(z.literal("")), + country: z + .string() + .trim() + .length(2, "Country must be exactly 2 characters") + .regex(/^[A-Z]{2}$/, "Country must be exactly 2 uppercase letters") + .optional() + .or(z.literal("")), + state: z + .string() + .trim() + .max(64, "State cannot exceed 64 characters") + .regex( + /^[^,=+<>#;\\"/\r\n\t]*$/, + 'State contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + /^[^\s\-_.]+.*[^\s\-_.]+$|^[^\s\-_.]{1}$/, + "State cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .or(z.literal("")), + locality: z + .string() + .trim() + .max(64, "Locality cannot exceed 64 characters") + .regex( + /^[^,=+<>#;\\"/\r\n\t]*$/, + 'Locality contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + /^[^\s\-_.]+.*[^\s\-_.]+$|^[^\s\-_.]{1}$/, + "Locality cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .or(z.literal("")), + emailAddress: z + .string() + .trim() + .email("Email Address must be a valid email format") + .min(6, "Email Address must be at least 6 characters") + .max(64, "Email Address cannot exceed 64 characters") + .optional() + .or(z.literal("")) }) .required(); @@ -444,100 +509,118 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> - {/* Additional Subject Fields - Available for all CA types */} - - - -
Additional Subject Fields
-
- -
- ( - - - - )} - /> - ( - - - - )} - /> -
+ {/* Additional Subject Fields - Only for Azure ADCS CAs */} + {selectedCa?.type === CaType.AZURE_AD_CS && ( + + + +
Additional Subject Fields
+
+ +
( - + )} /> ( - + )} /> +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
-
- ( - - - - )} - /> - ( - - - - )} - /> -
-
- - - + + + + )} {selectedCa?.type !== CaType.ACME && ( value.startsWith("https://"), "ADCS URL must use HTTPS"), username: z.string().trim().min(1, "Username required"), - password: z.string().trim().min(1, "Password required") + password: z.string().trim().min(1, "Password required"), + sslRejectUnauthorized: z.boolean().optional(), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() }) }) ]); @@ -44,6 +61,7 @@ type FormData = z.infer; export const AzureADCSConnectionForm = ({ appConnection, onSubmit }: Props) => { const isUpdate = Boolean(appConnection); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); const form = useForm({ resolver: zodResolver(formSchema), @@ -55,7 +73,9 @@ export const AzureADCSConnectionForm = ({ appConnection, onSubmit }: Props) => { credentials: { adcsUrl: "", username: "", - password: "" + password: "", + sslRejectUnauthorized: true, + sslCertificate: undefined } } }); @@ -63,12 +83,20 @@ export const AzureADCSConnectionForm = ({ appConnection, onSubmit }: Props) => { const { handleSubmit, control, - formState: { isSubmitting, isDirty } + formState: { isSubmitting, isDirty }, + watch } = form; + const sslEnabled = watch("credentials.adcsUrl")?.startsWith("https://") ?? false; + return ( -
+ { + setSelectedTabIndex(0); + handleSubmit(onSubmit)(e); + }} + > {!isUpdate && } { )} /> - ( - + + + `w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } > - - + Configuration + + + `w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + > + SSL ({sslEnabled ? "Enabled" : "Disabled"}) + + + {selectedTabIndex === 1 && ( +
+ SSL configuration for HTTPS connections +
)} - /> -
- ( - - - - )} - /> - ( - - onChange(e.target.value)} + + + ( + + + + )} + /> +
+ ( + + + + )} /> - - )} - /> -
+ ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + ( + +