diff --git a/packages/insomnia-sdk/src/objects/insomnia.ts b/packages/insomnia-sdk/src/objects/insomnia.ts index eb41bd02c42..04de6173602 100644 --- a/packages/insomnia-sdk/src/objects/insomnia.ts +++ b/packages/insomnia-sdk/src/objects/insomnia.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import type { ClientCertificate } from 'insomnia/src/models/client-certificate'; import type { RequestHeader } from 'insomnia/src/models/request'; import type { Settings } from 'insomnia/src/models/settings'; +import { filterClientCertificates } from 'insomnia/src/network/certificate'; import { toPreRequestAuth } from './auth'; import { CookieObject } from './cookies'; @@ -167,16 +168,31 @@ export async function initInsomniaObject( localVars: localVariables, }); - const existClientCert = rawObj.clientCertificates != null && rawObj.clientCertificates.length > 0; - const certificate = existClientCert && rawObj.clientCertificates[0] ? + const reqUrl = toUrlObject(rawObj.request.url); + reqUrl.addQueryParams( + rawObj.request.parameters + .filter(param => !param.disabled) + .map(param => ({ key: param.name, value: param.value })) + ); + + // hack: sanitize a template reference if it exists in the hostname + // so we can filter certificates without a full render on the request + const host = reqUrl.getHost().replace(/{{\s*\_\./g, '{{'); + const renderedHost = variables.replaceIn(host); + + const renderedBaseUrl = toUrlObject(`${reqUrl.protocol}//${renderedHost}`); + + const filteredCerts = filterClientCertificates(rawObj.clientCertificates || [], renderedBaseUrl.toString()); + const existingClientCert = filteredCerts != null && filteredCerts.length > 0 && filteredCerts[0]; + const certificate = existingClientCert ? { - disabled: rawObj.clientCertificates[0].disabled, + disabled: existingClientCert.disabled, name: 'The first certificate from Settings', - matches: [rawObj.clientCertificates[0].host], - key: { src: rawObj.clientCertificates[0].key || '' }, - cert: { src: rawObj.clientCertificates[0].cert || '' }, - passphrase: rawObj.clientCertificates[0].passphrase || undefined, - pfx: { src: rawObj.clientCertificates[0].pfx || '' }, // PFX or PKCS12 Certificate + matches: [existingClientCert.host], + key: { src: existingClientCert.key || '' }, + cert: { src: existingClientCert.cert || '' }, + passphrase: existingClientCert.passphrase || undefined, + pfx: { src: existingClientCert.pfx || '' }, // PFX or PKCS12 Certificate } : { disabled: true }; @@ -187,13 +203,6 @@ export async function initInsomniaObject( rawObj.settings.noProxy, ); - const reqUrl = toUrlObject(rawObj.request.url); - reqUrl.addQueryParams( - rawObj.request.parameters - .filter(param => !param.disabled) - .map(param => ({ key: param.name, value: param.value })) - ); - const reqOpt: RequestOptions = { name: rawObj.request.name, url: reqUrl, diff --git a/packages/insomnia-sdk/src/objects/request.ts b/packages/insomnia-sdk/src/objects/request.ts index 019bc4b927d..88ae7d7aac8 100644 --- a/packages/insomnia-sdk/src/objects/request.ts +++ b/packages/insomnia-sdk/src/objects/request.ts @@ -515,6 +515,8 @@ export function mergeClientCertificates( ...baseCertificate, key: null, cert: null, + name: updatedReq.certificate.name || '', + disabled: updatedReq.certificate.disabled || false, passphrase: updatedReq.certificate.passphrase || null, pfx: updatedReq.certificate.pfx?.src, }]; @@ -534,10 +536,8 @@ export function mergeClientCertificates( modified: 0, created: 0, isPrivate: false, - name: updatedReq.name || '', - host: updatedReq.url.getHost() || '', - disabled: updatedReq.disabled || false, - + name: updatedReq.certificate.name || '', + disabled: updatedReq.certificate.disabled || false, key: updatedReq.certificate.key?.src, cert: updatedReq.certificate.cert?.src, passphrase: updatedReq.certificate.passphrase || null, diff --git a/packages/insomnia-smoke-test/fixtures/client-certs.yaml b/packages/insomnia-smoke-test/fixtures/client-certs.yaml new file mode 100644 index 00000000000..f6a606defb1 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/client-certs.yaml @@ -0,0 +1,91 @@ +_type: export +__export_format: 4 +__export_date: 2024-12-11T21:49:50.826Z +__export_source: insomnia.desktop.app:v10.2.1-beta.1 +resources: + - _id: req_23f466953bff448faaf482e59624b17d + parentId: wrk_78f64a950d1248599ddbfcb2dbe4a4e0 + modified: 1733953779068 + created: 1733930967254 + url: https://localhost:4011/protected/pets/2 + name: pet 2 + description: "" + method: GET + body: {} + parameters: [] + headers: + - name: User-Agent + value: insomnia/10.2.1-beta.1 + authentication: {} + preRequestScript: console.log('yippee') + metaSortKey: -1733930967254 + isPrivate: false + pathParameters: [] + settingStoreCookies: true + settingSendCookies: true + settingDisableRenderRequestBody: false + settingEncodeUrl: true + settingRebuildPath: true + settingFollowRedirects: global + _type: request + - _id: wrk_78f64a950d1248599ddbfcb2dbe4a4e0 + parentId: null + modified: 1733930502550 + created: 1733930502550 + name: client-certs + description: "" + scope: collection + _type: workspace + - _id: req_01d6681fd6434cc7a75c8c4e3deee713 + parentId: wrk_78f64a950d1248599ddbfcb2dbe4a4e0 + modified: 1733953775398 + created: 1733953597632 + url: "{{_.srvr}}/protected/pets/2" + name: pet 2 with url var + description: "" + method: GET + body: {} + parameters: [] + headers: + - name: User-Agent + value: insomnia/10.2.1-beta.1 + authentication: {} + preRequestScript: console.log("yeehaw") + metaSortKey: -1732678181446 + isPrivate: false + pathParameters: [] + settingStoreCookies: true + settingSendCookies: true + settingDisableRenderRequestBody: false + settingEncodeUrl: true + settingRebuildPath: true + settingFollowRedirects: global + _type: request + - _id: env_1140e4f10f8a7e3ae858474a594d0bc440e35c99 + parentId: wrk_78f64a950d1248599ddbfcb2dbe4a4e0 + modified: 1733953690814 + created: 1733930502551 + name: Base Environment + data: + srvr: https://localhost:4011 + dataPropertyOrder: + "&": + - srvr + color: null + isPrivate: false + metaSortKey: 1733930502551 + environmentType: kv + kvPairData: + - id: envPair_6117101ea3704c85bc3deab101603717 + name: srvr + value: https://localhost:4011 + type: str + enabled: true + _type: environment + - _id: jar_1140e4f10f8a7e3ae858474a594d0bc440e35c99 + parentId: wrk_78f64a950d1248599ddbfcb2dbe4a4e0 + modified: 1733953690813 + created: 1733930502552 + name: Default Jar + cookies: [] + _type: cookie_jar diff --git a/packages/insomnia-smoke-test/fixtures/pre-request-collection.yaml b/packages/insomnia-smoke-test/fixtures/pre-request-collection.yaml index 79c34af8fe9..3694f789137 100644 --- a/packages/insomnia-smoke-test/fixtures/pre-request-collection.yaml +++ b/packages/insomnia-smoke-test/fixtures/pre-request-collection.yaml @@ -300,7 +300,7 @@ resources: cert: {src: 'invalid.cert'}, passphrase: '', pfx: {src: ''}, - }); + }); _type: request - _id: req_89dade2ee9ee42fbb22d588783a9df3e parentId: fld_01de564274824ecaad272330339ea6b2 diff --git a/packages/insomnia-smoke-test/server/index.ts b/packages/insomnia-smoke-test/server/index.ts index 0a7bd923952..d4ba7eabf7d 100644 --- a/packages/insomnia-smoke-test/server/index.ts +++ b/packages/insomnia-smoke-test/server/index.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import * as bodyParser from 'body-parser'; -import * as cookieParser from 'cookie-parser'; +import cookieParser from 'cookie-parser'; import express from 'express'; import { readFileSync } from 'fs'; import { createHandler } from 'graphql-http/lib/use/http'; @@ -14,11 +14,12 @@ import gitlabApi from './gitlab-api'; import { schema } from './graphql'; import { startGRPCServer } from './grpc'; import insomniaApi from './insomnia-api'; +import { mtlsRouter } from './mtls'; import { oauthRoutes } from './oauth'; import { startWebSocketServer } from './websocket'; const app = express(); -app.use(cookieParser.default()); +app.use(cookieParser()); const port = 4010; const httpsPort = 4011; const grpcPort = 50051; @@ -64,6 +65,7 @@ app.get('/cookies', (_req, res) => { app.use('/file', express.static('fixtures/files')); app.use('/auth/basic', basicAuthRouter); +app.use('/protected', mtlsRouter); githubApi(app); gitlabApi(app); @@ -129,6 +131,9 @@ startWebSocketServer(app.listen(port, () => { startWebSocketServer(createServer({ cert: readFileSync(join(__dirname, '../fixtures/certificates/localhost.pem')), key: readFileSync(join(__dirname, '../fixtures/certificates/localhost-key.pem')), + ca: readFileSync(join(__dirname, '../fixtures/certificates/rootCA.pem')), + requestCert: true, + rejectUnauthorized: false, }, app).listen(httpsPort, () => { console.log(`Listening at https://localhost:${httpsPort}`); console.log(`Listening at wss://localhost:${httpsPort}`); diff --git a/packages/insomnia-smoke-test/server/mtls.ts b/packages/insomnia-smoke-test/server/mtls.ts new file mode 100644 index 00000000000..e50b6efa904 --- /dev/null +++ b/packages/insomnia-smoke-test/server/mtls.ts @@ -0,0 +1,17 @@ +import express, { NextFunction, Response } from 'express'; + +export const mtlsRouter = express.Router(); + +mtlsRouter.use(clientCertificateAuth); + +async function clientCertificateAuth(req: any, res: Response, next: NextFunction) { + if (!req.client.authorized) { + return res.status(401).send({ error: 'Client certificate required' }); + } + + next(); +} + +mtlsRouter.get('/pets/:id', (req, res) => { + res.status(200).send({ id: req.params.id }); +}); diff --git a/packages/insomnia-smoke-test/tests/smoke/mtls.test.ts b/packages/insomnia-smoke-test/tests/smoke/mtls.test.ts new file mode 100644 index 00000000000..9e3ee4d2ff7 --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/mtls.test.ts @@ -0,0 +1,73 @@ + +import path from 'node:path'; + +import { expect } from '@playwright/test'; + +import { getFixturePath, loadFixture } from '../../playwright/paths'; +import { test } from '../../playwright/test'; + +test('can use client certificate for mTLS', async ({ app, page }) => { + const statusTag = page.locator('[data-testid="response-status-tag"]:visible'); + const responseBody = page.locator('[data-testid="response-pane"] >> [data-testid="CodeEditor"]:visible', { + has: page.locator('.CodeMirror-activeline'), + }); + + const clientCertsCollectionText = await loadFixture('client-certs.yaml'); + await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), clientCertsCollectionText); + + await page.getByLabel('Import').click(); + await page.locator('[data-test-id="import-from-clipboard"]').click(); + await page.getByRole('button', { name: 'Scan' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click(); + await page.getByLabel('client-certs').click(); + + await page.getByLabel('Request Collection').getByTestId('pet 2 with url var').press('Enter'); + + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await page.getByText('Error: SSL peer certificate or SSH remote key was not OK').click(); + + const fixturePath = getFixturePath('certificates'); + + await page.getByRole('button', { name: 'Add Certificates' }).click(); + + let fileChooser = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Add CA Certificate' }).click(); + await (await fileChooser).setFiles(path.join(fixturePath, 'rootCA.pem')); + + await page.getByRole('button', { name: 'Done' }).click(); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + + await expect(statusTag).toContainText('401 Unauthorized'); + await expect(responseBody).toContainText('Client certificate required'); + + await page.getByRole('button', { name: 'Add Certificates' }).click(); + await page.getByRole('button', { name: 'Add client certificate' }).click(); + await page.locator('[name="host"]').fill('localhost'); + + fileChooser = page.waitForEvent('filechooser'); + await page.locator('[data-test-id="add-client-certificate-file-chooser"]').click(); + await (await fileChooser).setFiles(path.join(fixturePath, 'client.crt')); + + fileChooser = page.waitForEvent('filechooser'); + await page.locator('[data-test-id="add-client-certificate-key-file-chooser"]').click(); + await (await fileChooser).setFiles(path.join(fixturePath, 'client.key')); + + await page.getByRole('button', { name: 'Add certificate' }).click(); + await page.getByRole('button', { name: 'Done' }).click(); + + await page.getByRole('button', { name: 'Send', exact: true }).click(); + + await expect(statusTag).toContainText('200 OK'); + await expect(responseBody).toContainText('"id": "2"'); + + // ensure disabling the cert actually disables it + await page.getByRole('button', { name: 'Add Certificates' }).click(); + await page.locator('[data-test-id="client-certificate-toggle"]').click(); + await page.getByRole('button', { name: 'Done' }).click(); + await page.getByLabel('Request Collection').getByTestId('pet 2').press('Enter'); + + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(statusTag).toContainText('401 Unauthorized'); + await expect(responseBody).toContainText('Client certificate required'); + +}); diff --git a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts index 7768f789cb7..5b1f9aad1bc 100644 --- a/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/pre-request-script-features.test.ts @@ -381,7 +381,7 @@ test.describe('pre-request features tests', async () => { // update proxy configuration await page.locator('text=Add Certificates').click(); await page.locator('text=Add client certificate').click(); - await page.locator('[name="host"]').fill('a.com'); + await page.locator('[name="host"]').fill('127.0.0.1'); await page.locator('[data-key="pfx"]').click(); const fileChooserPromise = page.waitForEvent('filechooser'); diff --git a/packages/insomnia/src/network/certificate.ts b/packages/insomnia/src/network/certificate.ts index c67c8ebcb58..02904dacf86 100644 --- a/packages/insomnia/src/network/certificate.ts +++ b/packages/insomnia/src/network/certificate.ts @@ -2,7 +2,7 @@ import type { ClientCertificate } from '../models/client-certificate'; import { setDefaultProtocol } from '../utils/url/protocol'; import { urlMatchesCertHost } from './url-matches-cert-host'; -export function filterClientCertificates(clientCertificates: ClientCertificate[], requestUrl: string, protocol: string) { +export function filterClientCertificates(clientCertificates: ClientCertificate[], requestUrl: string, protocol?: string) { const res = clientCertificates.filter(c => !c.disabled && urlMatchesCertHost(setDefaultProtocol(c.host, protocol), requestUrl, true)); // If didn't get a matching certificate at the first time, ignore the port check and try again if (!res.length) { diff --git a/packages/insomnia/src/network/network.ts b/packages/insomnia/src/network/network.ts index 8efd115bf70..c0528b4d6a2 100644 --- a/packages/insomnia/src/network/network.ts +++ b/packages/insomnia/src/network/network.ts @@ -205,6 +205,7 @@ export const tryToExecutePreRequestScript = async ( globals: activeGlobalEnvironment, userUploadEnvironment, requestTestResults: new Array(), + transientVariables, }; } const joinedScript = [...folderScripts].join('\n'); @@ -550,8 +551,8 @@ export const tryToInterpolateRequest = async ({ purpose?: RenderPurpose; extraInfo?: ExtraRenderInfo; baseEnvironment?: Environment; - userUploadEnvironment?: UserUploadEnvironment; - transientVariables?: Environment; + userUploadEnvironment?: UserUploadEnvironment; + transientVariables?: Environment; ignoreUndefinedEnvVariable?: boolean; } ) => { diff --git a/packages/insomnia/src/ui/components/modals/workspace-certificates-modal.tsx b/packages/insomnia/src/ui/components/modals/workspace-certificates-modal.tsx index 2c219ca6a9c..9763f476a0f 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-certificates-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-certificates-modal.tsx @@ -233,6 +233,7 @@ const ClientCertificateGridListItem = ({ certificate }: { )}
{ updateClientCertificateFetcher.submit({ ...certificate, disabled: !isSelected }, { action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/clientcert/update`, diff --git a/packages/insomnia/src/ui/routes/request.tsx b/packages/insomnia/src/ui/routes/request.tsx index cfa0e94e3b1..681dcf3f78b 100644 --- a/packages/insomnia/src/ui/routes/request.tsx +++ b/packages/insomnia/src/ui/routes/request.tsx @@ -483,14 +483,14 @@ export const sendActionImplementation = async ({ iterationCount, transientVariables, }: { - requestId: string; + requestId: string; shouldPromptForPathAfterResponse: boolean | undefined; ignoreUndefinedEnvVariable: boolean | undefined; testResultCollector?: RunnerContextForRequest; - iteration?: number; - iterationCount?: number; - userUploadEnvironment?: UserUploadEnvironment; - transientVariables?: Environment; + iteration?: number; + iterationCount?: number; + userUploadEnvironment?: UserUploadEnvironment; + transientVariables?: Environment; }) => { window.main.startExecution({ requestId }); const requestData = await fetchRequestData(requestId);