diff --git a/.gitignore b/.gitignore index 76b8e798a7c..f791bf7bcac 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ packages/insomnia-smoke-test/traces dist .history *.node +rootCA2.* diff --git a/packages/insomnia-smoke-test/fixtures/certificates/client.crt b/packages/insomnia-smoke-test/fixtures/certificates/client.crt new file mode 100644 index 00000000000..3d299add43a --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/certificates/client.crt @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEmzCCAwOgAwIBAgIUYaCYPYGdDPU5uHxEyNgDBFGvkPAwDQYJKoZIhvcNAQEL +BQAwgbUxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTFFMEMGA1UECww8 +amFjay5rYXZhbmFnaEBKYWNrcy1NYWNCb29rLVByby0xNi1pbmNoLTIwMTkgKEph +Y2sgS2F2YW5hZ2gpMUwwSgYDVQQDDENta2NlcnQgamFjay5rYXZhbmFnaEBKYWNr +cy1NYWNCb29rLVByby0xNi1pbmNoLTIwMTkgKEphY2sgS2F2YW5hZ2gpMB4XDTI0 +MTIwNTIwMTMyM1oXDTM0MTIwMzIwMTMyM1owgZQxCzAJBgNVBAYTAlVTMRMwEQYD +VQQIDApTb21lLVdoZXJlMQ0wCwYDVQQHDARFbHNlMREwDwYDVQQKDAhJbnNvbW5p +YTENMAsGA1UECwwEVGVhbTEWMBQGA1UEAwwNSW5zb21uaWEgVGVhbTEnMCUGCSqG +SIb3DQEJARYYdGVhbS1pbnNvbW5pYUBrb25naHEuY29tMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAkdevG8sMbwflVGtkW8mUHYI5Vdl6g7dlV2XVYW5S +WYRAlof7oEn62k5+9DimykZZwiYz2uNyuZRcNTwFvbnkNoAza3t1oEFnieFDWBXw +Ta7gEh2BF9PTtON3S7+ZXLleSXVs/vbY2dXSChe2kPWKukOA0SZO047wYV+NZc/A +pyURQFLNa2RrrpdCutpmj5vvdhNTTHCCdIaDdVkUcw6+o7G3KGYggH/8ODuSiKpV +sPVdlwqEcEHHYGHpHBRr2CLtZmmjvqL85Qv8iT+mJwaucVM3u6Lh5cPxtTgezUtr +gqEW5waAb6QyFGlhInGHy1wkdA+KQusN5sy/5J1dMYs/YwIDAQABo0IwQDAdBgNV +HQ4EFgQUkWmd2DMJ9mUTHMh9I8L2VnyVYEIwHwYDVR0jBBgwFoAUPFHjYSvgmQ5l +NZBrC4e6lJedguwwDQYJKoZIhvcNAQELBQADggGBAJC2q/HsCBti6eJGsx1Sa8bA +lLg86nVwxk03Xzl/slkamgMrqRAbJxZHv4VtzblwiIQsGNOSMeM9Er6XvpKm7Yll +bh5DLWa6h7Tp7vE+GufE4PK6rJCL3p7PwtlJyrrOmUnvHyiGKW5lG3VVeWaizqj+ +1teN//Y13538n0qyyk6t+KSRsEmR3x4shvdC8j7kqvaWe/beRbgb4KUvRkGPV/nN +dVoXiLnarqqwfUo1KWU210vULF/AA//DnMMt3KG1IaL74iDM+BbWbDff+6SRbRtf +y7RT2Tukv42F5b9bLww1EnysF7DKh9kbMj4E+YgP0bHuQFd6pateJWjZPXxAlmEv +eBPwupjW6co1otXoDvuHOtf14nMDjWx6am1Qhfw0ag0jkQ2KB54Lzhi6TJUhwFJr +lTK3t4I0TGpL+OIp9MrDVYaGLMOVyFky7WMAZJ+QHbWaPBZC0wFdY7FZXGBWbY/I +cMCTDebmifkHZSVD8c+KU3rtdUMsG2NtTLi7ZFJolw== +-----END CERTIFICATE----- diff --git a/packages/insomnia-smoke-test/fixtures/certificates/client.csr b/packages/insomnia-smoke-test/fixtures/certificates/client.csr new file mode 100644 index 00000000000..99b6694fbe2 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/certificates/client.csr @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIC9DCCAdwCAQAwgZQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApTb21lLVdoZXJl +MQ0wCwYDVQQHDARFbHNlMREwDwYDVQQKDAhJbnNvbW5pYTENMAsGA1UECwwEVGVh +bTEWMBQGA1UEAwwNSW5zb21uaWEgVGVhbTEnMCUGCSqGSIb3DQEJARYYdGVhbS1p +bnNvbW5pYUBrb25naHEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAkdevG8sMbwflVGtkW8mUHYI5Vdl6g7dlV2XVYW5SWYRAlof7oEn62k5+9Dim +ykZZwiYz2uNyuZRcNTwFvbnkNoAza3t1oEFnieFDWBXwTa7gEh2BF9PTtON3S7+Z +XLleSXVs/vbY2dXSChe2kPWKukOA0SZO047wYV+NZc/ApyURQFLNa2RrrpdCutpm +j5vvdhNTTHCCdIaDdVkUcw6+o7G3KGYggH/8ODuSiKpVsPVdlwqEcEHHYGHpHBRr +2CLtZmmjvqL85Qv8iT+mJwaucVM3u6Lh5cPxtTgezUtrgqEW5waAb6QyFGlhInGH +y1wkdA+KQusN5sy/5J1dMYs/YwIDAQABoBowGAYJKoZIhvcNAQkCMQsMCUtvbmcs +IEluYzANBgkqhkiG9w0BAQsFAAOCAQEAiXALDfLmzi7qj4K837dZeP961G90a7Nk +m7VeBb8RpphM7hQ/2ZhnRfGU5TOo/QskN0Bddhnc6EYrzpo5iH+upmdz/jCpUaOR +k7i/RhC3NOemxCJJ64KR8bNfYJ1q4C9NEBTRfZns73nIE9wuu0Ue/j5KebnkfWz/ +anY4bFJfJNZVdpWDRV0dRlgJBlsIHyDlSZ0v/K+3/gh+K3X5z/jOsF7uwFvyzd+j +3DXhgtJESsdqHMeD17wtUX9UytqHX8H0f0JNiZr/BURDUoH9z4zJxeCBHPBQ7Wd2 +lvY04vVHKMlI2C52yNUyYtZsXlRCwK5MO9oBt3j8XAga1VHypPdpHA== +-----END CERTIFICATE REQUEST----- diff --git a/packages/insomnia-smoke-test/fixtures/certificates/client.key b/packages/insomnia-smoke-test/fixtures/certificates/client.key new file mode 100644 index 00000000000..b71992c0cca --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/certificates/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCR168bywxvB+VU +a2RbyZQdgjlV2XqDt2VXZdVhblJZhECWh/ugSfraTn70OKbKRlnCJjPa43K5lFw1 +PAW9ueQ2gDNre3WgQWeJ4UNYFfBNruASHYEX09O043dLv5lcuV5JdWz+9tjZ1dIK +F7aQ9Yq6Q4DRJk7TjvBhX41lz8CnJRFAUs1rZGuul0K62maPm+92E1NMcIJ0hoN1 +WRRzDr6jsbcoZiCAf/w4O5KIqlWw9V2XCoRwQcdgYekcFGvYIu1maaO+ovzlC/yJ +P6YnBq5xUze7ouHlw/G1OB7NS2uCoRbnBoBvpDIUaWEicYfLXCR0D4pC6w3mzL/k +nV0xiz9jAgMBAAECggEACv6pH13GSrD1y4PEtZzAebcH21G0oeBxSrTzik+J9GCu +VwraX+7XDNH7hS4OLCENaQyxpRaEX1Q9du/wknYrEibF4AKXNaUooph+ZLiGr+/E +MByhG+qmwuc3gHKDcJ3/uACa+BLw+mWOjgWhCFQFvBDhuCLJfe6Ab1huRv1YdBR2 +QDV2/xY7/w6NgU1QtXMETcJcTh9BMQI3dkfBpyZmDAbED2dSZL8uKw9kkG3useIS +bgE8Bs1gMjAtXqK/OlzZg6fazxeTI+K5uiqnCxSS7cMly2nKGBt6bRThapr5/G+E +AfFJHtogZTj+aU9o+tjhnavRVPZYOK6Tvh4tbJfE4QKBgQDIwqtfvX1ezvSxsTWu +XaCDjzLmzBnpXAaWw6r/98MQrgSEAGUkpswm+XIBVu09umZI6VnGheQEToSIMpgl +MPdx/10ewvCuS9DOaNBSnUNVg399vEB/wWtpwvgMumVdl+jy8MZMw4w2UYv4t0wf +a/KpuZDG7qS8JxeyqFJtQ5LfoQKBgQC5+KkvRsJLP5PSdgXXECAL4rfBp3nSIpEc +7FVEqI7gwIzrijF7efNaBJNmJUhZW4AgXnkYu+y+hwtwTOANSeT+qKcixYJ9rJH0 +CwQLOV3k4iI890wBGUZFFkArTZQ2ubE7OpLiWkK1DXPS3GQNTkNwaNVwLFEJETd7 +ra1p6bDQgwKBgQCIEoodhk3naNapabc8y/DzuqeBj20wolNE9LJ+c+we7hPZdHsZ +cISHFiOTjNuYLzMM0D0tNarq0F08Ay7kZmARQEhZhT+ko3aPsLMii70LxOkwy9vb +u7mWIbQGzH/QBBq8Lz344cGzP2pj1bkewwfiyge7Dh1R+h0x1qynqllugQKBgQCc +oIz9QD8LWTSKoNQyWWZkHEJfW0oCbRKCpZj7BmuvZvxItE8Qi5klS41JDkVs3N+A +Y5DiAARYQyyjHFvnVUEEKc76Pha0+c7DFzQEsAAgcUQczLq6girorODGl8QogyR9 +mctz0Ek2a2oNQYkSm8O4vNJmcyyc0l9ETvYqqaM9OQKBgQCTy+yC7PcCSO3+pSW2 +uIFizSh8MK4fSAi/A9/qMQObuD64lLkgKAamBRrwj7kgrMY750F60xrHz6hMnFOY +NcKDzR6KSAcZiqFp/s/RL4ZLIRNl+axiNfymIxzFEl3bolpEvkIkKOmbSaq3ZH2P +6Vq+ac3PcUGaZx3Pcml2o8vX3w== +-----END PRIVATE KEY----- diff --git a/packages/insomnia-smoke-test/fixtures/certificates/rootCA.srl b/packages/insomnia-smoke-test/fixtures/certificates/rootCA.srl new file mode 100644 index 00000000000..ecc57aaf7a6 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/certificates/rootCA.srl @@ -0,0 +1 @@ +61A0983D819D0CF539B87C44C8D8030451AF90F0 diff --git a/packages/insomnia-smoke-test/fixtures/grpc-mtls.yaml b/packages/insomnia-smoke-test/fixtures/grpc-mtls.yaml new file mode 100644 index 00000000000..877553fba18 --- /dev/null +++ b/packages/insomnia-smoke-test/fixtures/grpc-mtls.yaml @@ -0,0 +1,64 @@ +_type: export +__export_format: 4 +__export_date: 2024-12-05T20:16:41.919Z +__export_source: insomnia.desktop.app:v10.2.0 +resources: + - _id: greq_7ef089deea314ab88dd86aa434838efd + parentId: wrk_5dac47ec0e6144c694f21b29740e9267 + modified: 1733429659428 + created: 1732118004917 + url: grpcs://localhost:50052 + name: grpcs + description: "" + protoFileId: "" + protoMethodName: /routeguide.RouteGuide/GetFeature + metadata: [] + body: + text: |- + { + "lo": { + "latitude": "400000000", + "longitude": "-750000000" + }, + "hi": { + "latitude": "420000000", + "longitude": "-730000000" + }, + "latitude": "409146138", + "longitude": "-746188906" + } + metaSortKey: -1732118004917 + isPrivate: false + reflectionApi: + enabled: false + url: https://buf.build + apiKey: "" + module: buf.build/connectrpc/eliza + _type: grpc_request + - _id: wrk_5dac47ec0e6144c694f21b29740e9267 + parentId: null + modified: 1732118001110 + created: 1732118001110 + name: grpc + description: "" + scope: collection + _type: workspace + - _id: env_d8277fde45580529bdde2cee008f33ba3e3a8873 + parentId: wrk_5dac47ec0e6144c694f21b29740e9267 + modified: 1732118001112 + created: 1732118001112 + name: Base Environment + data: {} + dataPropertyOrder: null + color: null + isPrivate: false + metaSortKey: 1732118001112 + environmentType: kv + _type: environment + - _id: jar_d8277fde45580529bdde2cee008f33ba3e3a8873 + parentId: wrk_5dac47ec0e6144c694f21b29740e9267 + modified: 1732118001113 + created: 1732118001113 + name: Default Jar + cookies: [] + _type: cookie_jar diff --git a/packages/insomnia-smoke-test/server/grpc.ts b/packages/insomnia-smoke-test/server/grpc.ts index 943d025ec69..2b63dc12e3e 100644 --- a/packages/insomnia-smoke-test/server/grpc.ts +++ b/packages/insomnia-smoke-test/server/grpc.ts @@ -167,16 +167,13 @@ const routeChat: HandleCall = (call: any) => { /* For each note sent, respond with all previous notes that correspond to * the same point */ if (routeNotes.hasOwnProperty(key)) { - // @ts-expect-error typescript routeNotes[key].forEach(function(note: any) { call.write(note); }); } else { - // @ts-expect-error typescript routeNotes[key] = []; } // Then add the new note to the list - // @ts-expect-error typescript routeNotes[key].push(JSON.parse(JSON.stringify(note))); }); call.on('end', function() { @@ -192,8 +189,7 @@ export const startGRPCServer = (port: number) => { const server = new grpc.Server(); // Enable reflection - const descriptorSet = '../../packages/insomnia-smoke-test/fixtures/route_guide.bin'; - addReflection(server, descriptorSet); + addReflection(server, '../../packages/insomnia-smoke-test/fixtures/route_guide.bin'); // @ts-expect-error generated from proto file server.addService(routeguide.RouteGuide.service, { @@ -218,5 +214,48 @@ export const startGRPCServer = (port: number) => { resolve(); }); }); + + const serverWithTLS = new grpc.Server(); + + // Enable reflection + addReflection(serverWithTLS, '../../packages/insomnia-smoke-test/fixtures/route_guide.bin'); + + // @ts-expect-error generated from proto file + serverWithTLS.addService(routeguide.RouteGuide.service, { + getFeature: getFeature, + listFeatures: listFeatures, + recordRoute: recordRoute, + routeChat: routeChat, + }); + const rootCert = fs.readFileSync(path.join(__dirname, '../fixtures/certificates/rootCA.pem')); + const serverCert = fs.readFileSync(path.join(__dirname, '../fixtures/certificates/localhost.pem')); + const serverKey = fs.readFileSync(path.join(__dirname, '../fixtures/certificates/localhost-key.pem')); + const serverCredentials = grpc.ServerCredentials.createSsl( + rootCert, + [ + { + cert_chain: serverCert, + private_key: serverKey, + }, + ], + true // mTLS enabled, temporarily change to false for local testing if needed + ); + serverWithTLS.bindAsync('localhost:50052', serverCredentials, error => { + if (error) { + console.error(error); + return reject(error); + } + + const dbPath = '../../packages/insomnia/src/network/grpc/__fixtures__/library/route_guide_db.json'; + fs.readFile(path.resolve(dbPath), (err, data) => { + if (err) { + throw err; + } + featureList = JSON.parse(data.toString()); + console.log('Listening at grpcs://localhost:50052 for route_guide.proto'); + serverWithTLS.start(); + resolve(); + }); + }); }); }; diff --git a/packages/insomnia-smoke-test/tests/smoke/grpc-mtls.test.ts b/packages/insomnia-smoke-test/tests/smoke/grpc-mtls.test.ts new file mode 100644 index 00000000000..4f6e12a7df7 --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/grpc-mtls.test.ts @@ -0,0 +1,62 @@ +import path from 'node:path'; + +import { expect } from '@playwright/test'; + +import { getFixturePath, loadFixture } from '../../playwright/paths'; +import { test } from '../../playwright/test'; + +test('can send gRPC requests using mTLS requests (with reflection)', async ({ app, page }) => { + test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); + 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 text = await loadFixture('grpc-mtls.yaml'); + await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text); + + 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('grpc').click(); + + await page.getByLabel('Request Collection').getByTestId('grpcs').press('Enter'); + await expect(page.getByRole('button', { name: 'Select Method' })).toBeDisabled(); + + // add root CA and client certificate + const fixturePath = getFixturePath('certificates'); + + await page.getByRole('button', { name: 'Add Certificates' }).click(); + let fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Add CA Certificate' }).click(); + await (await fileChooserPromise).setFiles(path.join(fixturePath, 'rootCA.pem')); + + await page.getByRole('button', { name: 'Add client certificate' }).click(); + await page.locator('[name="host"]').fill('localhost'); + + fileChooserPromise = page.waitForEvent('filechooser'); + await page.locator('[data-test-id="add-client-certificate-file-chooser"]').click(); + await (await fileChooserPromise).setFiles(path.join(fixturePath, 'client.crt')); + + fileChooserPromise = page.waitForEvent('filechooser'); + await page.locator('[data-test-id="add-client-certificate-key-file-chooser"]').click(); + await (await fileChooserPromise).setFiles(path.join(fixturePath, 'client.key')); + + await page.getByRole('button', { name: 'Add certificate' }).click(); + await page.getByRole('button', { name: 'Done' }).click(); + + // initiates an mtls connection with the given certificates + await page.getByTestId('button-server-reflection').click(); + + await page.getByRole('button', { name: 'Select Method' }).click(); + await page.getByRole('option', { name: 'RouteGuide/GetFeature' }).click(); + + await page.getByRole('tab', { name: 'Unary' }).click(); + await page.getByRole('button', { name: 'Send' }).click(); + + // Check for the single Unary response + await page.getByRole('tab', { name: 'Response 1' }).click(); + await expect(statusTag).toContainText('0 OK'); + await expect(responseBody).toContainText('Berkshire Valley Management Area Trail'); +}); diff --git a/packages/insomnia/src/main/ipc/grpc.ts b/packages/insomnia/src/main/ipc/grpc.ts index e29bc5ffd5a..0edadbceff7 100644 --- a/packages/insomnia/src/main/ipc/grpc.ts +++ b/packages/insomnia/src/main/ipc/grpc.ts @@ -8,9 +8,9 @@ import { Code, ConnectError, createPromiseClient } from '@connectrpc/connect'; import { createConnectTransport } from '@connectrpc/connect-node'; import { type Call, + ChannelCredentials, type ClientDuplexStream, type ClientReadableStream, - credentials, makeGenericClientConstructor, Metadata, type ServiceError, @@ -42,6 +42,10 @@ const grpcCalls = new Map(); export interface GrpcIpcRequestParams { request: RenderedGrpcRequest; + clientCert?: string; + clientKey?: string; + caCertificate?: string; + rejectUnauthorized: boolean; } export interface GrpcIpcMessageParams { @@ -200,16 +204,19 @@ const getMethodsFromReflectionServer = async ( const getMethodsFromReflection = async ( host: string, metadata: GrpcRequestHeader[], - reflectionApi: GrpcRequest['reflectionApi'] + rejectUnauthorized: boolean, + reflectionApi: GrpcRequest['reflectionApi'], + clientCert?: string, + clientKey?: string, + caCertificate?: string, ): Promise => { if (reflectionApi.enabled) { return getMethodsFromReflectionServer(reflectionApi); } - try { - const { url, enableTls } = parseGrpcUrl(host); + const { url } = parseGrpcUrl(host); const client = new grpcReflection.Client( url, - enableTls ? credentials.createSsl() : credentials.createInsecure(), + getChannelCredentials({ url: host, caCertificate, clientCert, clientKey, rejectUnauthorized }), grpcOptions, filterDisabledMetaData(metadata) ); @@ -255,20 +262,25 @@ const getMethodsFromReflection = async ( return methods; }); return (await Promise.all(methodsPromises)).flat(); - } catch (error) { - throw error; - } }; export const loadMethodsFromReflection = async (options: { url: string; metadata: GrpcRequestHeader[]; + rejectUnauthorized: boolean; reflectionApi: GrpcRequest['reflectionApi']; + clientCert?: string; + clientKey?: string; + caCertificate?: string; }): Promise => { invariant(options.url, 'gRPC request url not provided'); const methods = await getMethodsFromReflection( options.url, options.metadata, - options.reflectionApi + options.rejectUnauthorized, + options.reflectionApi, + options.clientCert, + options.clientKey, + options.caCertificate, ); return methods.map(method => ({ type: getMethodType(method), @@ -300,7 +312,8 @@ export const getMethodType = ({ }; export const getSelectedMethod = async ( - request: GrpcRequest + request: GrpcRequest, + ipcParams: GrpcIpcRequestParams, ): Promise => { if (request.protoFileId) { const protoFile = await models.protoFile.getById(request.protoFileId); @@ -313,10 +326,15 @@ export const getSelectedMethod = async ( invariant(methods, 'No methods found'); return methods.find(c => c.path === request.protoMethodName); } + const settings = await models.settings.getOrCreate(); const methods = await getMethodsFromReflection( request.url, request.metadata, - request.reflectionApi + settings.validateSSL, + request.reflectionApi, + ipcParams.clientCert, + ipcParams.clientKey, + ipcParams.caCertificate, ); invariant(methods, 'No reflection methods found'); return methods.find(c => c.path === request.protoMethodName); @@ -343,26 +361,44 @@ const isEnumDefinition = (definition: AnyDefinition): definition is EnumTypeDefi return (definition as EnumTypeDefinition).format === 'Protocol Buffer 3 EnumDescriptorProto'; }; +const getChannelCredentials = ({ url, rejectUnauthorized, clientCert, clientKey, caCertificate }: { url: string; rejectUnauthorized: boolean; clientCert?: string; clientKey?: string; caCertificate?: string }): ChannelCredentials => { + if (url.toLowerCase().startsWith('grpcs:')) { + if (caCertificate && clientKey && clientCert) { + return ChannelCredentials.createSsl(Buffer.from(caCertificate, 'utf8'), Buffer.from(clientKey, 'utf8'), Buffer.from(clientCert, 'utf8'), { rejectUnauthorized }); + } + if (clientKey && clientCert) { + return ChannelCredentials.createSsl(null, Buffer.from(clientKey, 'utf8'), Buffer.from(clientCert, 'utf8'), { rejectUnauthorized }); + } + if (caCertificate) { + return ChannelCredentials.createSsl(Buffer.from(caCertificate, 'utf8'), null, null, { rejectUnauthorized }); + } + return ChannelCredentials.createSsl(null, null, null, { rejectUnauthorized }); + } + return ChannelCredentials.createInsecure(); +}; + export const start = ( event: IpcMainEvent, - { request }: GrpcIpcRequestParams, + ipcParams: GrpcIpcRequestParams, ) => { - getSelectedMethod(request)?.then(method => { + const { request, rejectUnauthorized, clientCert, clientKey, caCertificate } = ipcParams; + getSelectedMethod(request, ipcParams)?.then(method => { if (!method) { event.reply('grpc.error', request._id, new Error(`The gRPC method ${request.protoMethodName} could not be found`)); return; } const methodType = getMethodType(method); // Create client - const { url, enableTls } = parseGrpcUrl(request.url); + const { url } = parseGrpcUrl(request.url); + if (!url) { event.reply('grpc.error', request._id, new Error('URL not specified')); return undefined; } - console.log(`[gRPC] connecting to url=${url} ${enableTls ? 'with' : 'without'} TLS`); // @ts-expect-error -- TSCONVERSION second argument should be provided, send an empty string? Needs testing const Client = makeGenericClientConstructor({}); - const client = new Client(url, enableTls ? credentials.createSsl() : credentials.createInsecure()); + const creds = getChannelCredentials({ url: request.url, rejectUnauthorized, clientCert, clientKey, caCertificate }); + const client = new Client(url, creds); if (!client) { return; } @@ -425,6 +461,8 @@ export const start = ( event.reply('grpc.error', request._id, error); } return; + }).catch(error => { + event.reply('grpc.error', request._id, error); }); }; diff --git a/packages/insomnia/src/ui/components/modals/error-modal.tsx b/packages/insomnia/src/ui/components/modals/error-modal.tsx index 91a693a8832..5c600cf6a8e 100644 --- a/packages/insomnia/src/ui/components/modals/error-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/error-modal.tsx @@ -39,7 +39,7 @@ export const ErrorModal = forwardRef((_, ref) => { {title || 'Uh Oh!'} - {message ?
{message}
: null} + {message ?
{message}
: null} {error && (
Stack trace 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 842edaa2edd..2c219ca6a9c 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-certificates-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-certificates-modal.tsx @@ -137,7 +137,7 @@ const AddClientCertificateModal = ({ onClose }: { onClose: () => void }) => { setCertificatePath(file.path); }} > - @@ -164,7 +164,7 @@ const AddClientCertificateModal = ({ onClose }: { onClose: () => void }) => { setKeyPath(file.path); }} > - diff --git a/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx b/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx index f10f6f984d8..aaed7b24c9f 100644 --- a/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/grpc-request-pane.tsx @@ -1,3 +1,4 @@ +import { readFile } from 'fs/promises'; import React, { type FunctionComponent, useRef, useState } from 'react'; import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'; import { useParams, useRouteLoaderData } from 'react-router-dom'; @@ -11,11 +12,15 @@ import type { GrpcMethodType } from '../../../main/ipc/grpc'; import * as models from '../../../models'; import type { GrpcRequestHeader } from '../../../models/grpc-request'; import { queryAllWorkspaceUrls } from '../../../models/helpers/query-all-workspace-urls'; +import { urlMatchesCertHost } from '../../../network/url-matches-cert-host'; +import { getGrpcConnectionErrorDetails } from '../../../utils/grpc'; import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../../utils/try-interpolate'; +import { setDefaultProtocol } from '../../../utils/url/protocol'; import { useRequestPatcher } from '../../hooks/use-request'; import { useActiveRequestSyncVCSVersion, useGitVCSVersion } from '../../hooks/use-vcs-version'; import type { GrpcRequestState } from '../../routes/debug'; import type { GrpcRequestLoaderData } from '../../routes/request'; +import { useRootLoaderData } from '../../routes/root'; import type { WorkspaceLoaderData } from '../../routes/workspace'; import { GrpcSendButton } from '../buttons/grpc-send-button'; import { CodeEditor, type CodeEditorHandle } from '../codemirror/code-editor'; @@ -53,7 +58,7 @@ export const GrpcRequestPane: FunctionComponent = ({ reloadRequests, }) => { const { activeRequest } = useRouteLoaderData('request/:requestId') as GrpcRequestLoaderData; - + const { settings } = useRootLoaderData(); const [isProtoModalOpen, setIsProtoModalOpen] = useState(false); const { requestMessages, running, methods } = grpcState; useMount(async () => { @@ -98,7 +103,19 @@ export const GrpcRequestPane: FunctionComponent = ({ purpose: 'send', skipBody: canClientStream(methodType), }); - window.main.grpc.start({ request }); + const workspaceClientCertificates = await models.clientCertificate.findByParentId(workspaceId); + const clientCertificate = workspaceClientCertificates.find(c => !c.disabled && urlMatchesCertHost(setDefaultProtocol(c.host, 'grpc:'), request.url, false)); + const caCertificate = (await models.caCertificate.findByParentId(workspaceId)); + const caCertificatePath = caCertificate && !caCertificate.disabled ? caCertificate.path : undefined; + window.main.grpc.start({ + request, + rejectUnauthorized: settings.validateSSL, + ...(request.url.toLowerCase().startsWith('grpcs:') ? { + clientCert: clientCertificate?.cert ? await readFile(clientCertificate?.cert || '', 'utf8') : undefined, + clientKey: clientCertificate?.key ? await readFile(clientCertificate?.key || '', 'utf8') : undefined, + caCertificate: caCertificatePath ? await readFile(caCertificatePath, 'utf8') : undefined, + } : {}), + }); setGrpcState({ ...grpcState, requestMessages: [], @@ -189,7 +206,7 @@ export const GrpcRequestPane: FunctionComponent = ({ disabled={!activeRequest.url} onClick={async () => { try { - const rendered = + let rendered = await tryToInterpolateRequestOrShowRenderErrorModal({ request: activeRequest, environmentId, @@ -199,11 +216,26 @@ export const GrpcRequestPane: FunctionComponent = ({ reflectionApi: activeRequest.reflectionApi, }, }); + const workspaceClientCertificates = await models.clientCertificate.findByParentId(workspaceId); + const clientCertificate = workspaceClientCertificates.find(c => !c.disabled && urlMatchesCertHost(setDefaultProtocol(c.host, 'grpc:'), rendered.url, false)); + const caCertificate = (await models.caCertificate.findByParentId(workspaceId)); + const caCertificatePath = caCertificate && !caCertificate.disabled ? caCertificate.path : undefined; + const clientCert = clientCertificate?.cert ? await readFile(clientCertificate?.cert, 'utf8') : undefined; + const clientKey = clientCertificate?.key ? await readFile(clientCertificate?.key, 'utf8') : undefined; + rendered = { + ...rendered, + rejectUnauthorized: settings.validateSSL, + ...(activeRequest.url.toLowerCase().startsWith('grpcs:') ? { + clientCert, + clientKey, + caCertificate: caCertificatePath ? await readFile(caCertificatePath, 'utf8') : undefined, + } : {}), + }; const methods = await window.main.grpc.loadMethodsFromReflection(rendered); setGrpcState({ ...grpcState, methods }); patchRequest(requestId, { protoFileId: '', protoMethodName: '' }); } catch (error) { - showModal(ErrorModal, { error }); + showModal(ErrorModal, { error, ...getGrpcConnectionErrorDetails(error) }); } }} > diff --git a/packages/insomnia/src/ui/routes/debug.tsx b/packages/insomnia/src/ui/routes/debug.tsx index 441fce9c964..7074714771c 100644 --- a/packages/insomnia/src/ui/routes/debug.tsx +++ b/packages/insomnia/src/ui/routes/debug.tsx @@ -69,6 +69,7 @@ import { type WebSocketRequest, } from '../../models/websocket-request'; import { isScratchpad } from '../../models/workspace'; +import { getGrpcConnectionErrorDetails, isGrpcConnectionError } from '../../utils/grpc'; import { invariant } from '../../utils/invariant'; import { DropdownHint } from '../components/base/dropdown/dropdown-hint'; import { RequestActionsDropdown } from '../components/dropdowns/request-actions-dropdown'; @@ -83,6 +84,7 @@ import { useDocBodyKeyboardShortcuts } from '../components/keydown-binder'; import { showModal, showPrompt } from '../components/modals'; import { AskModal } from '../components/modals/ask-modal'; import { CookiesModal } from '../components/modals/cookies-modal'; +import { ErrorModal } from '../components/modals/error-modal'; import { GenerateCodeModal } from '../components/modals/generate-code-modal'; import { ImportModal } from '../components/modals/import-modal'; import { PasteCurlModal } from '../components/modals/paste-curl-modal'; @@ -301,6 +303,9 @@ export const Debug: FC = () => { useEffect( () => window.main.on('grpc.error', (_, id, error) => { + if (isGrpcConnectionError(error)) { + showModal(ErrorModal, { error, ...getGrpcConnectionErrorDetails(error) }); + } setGrpcStates(state => state.map(s => (s.requestId === id ? { ...s, error } : s)), ); diff --git a/packages/insomnia/src/utils/grpc.ts b/packages/insomnia/src/utils/grpc.ts new file mode 100644 index 00000000000..da265b5f1ce --- /dev/null +++ b/packages/insomnia/src/utils/grpc.ts @@ -0,0 +1,43 @@ +const GRPC_CONNECTION_ERROR_STRINGS = { + SERVER_SELF_SIGNED: 'self signed certificate in certificate chain', + CLIENT_CERT_REQUIRED: 'CERTIFICATE_REQUIRED', + SERVER_CANCELED: 'CANCELLED', + TLS_NOT_SUPPORTED: 'WRONG_VERSION_NUMBER', + BAD_LOCAL_ROOT_CERT: 'unable to get local issuer certificate', +}; + +export function isGrpcConnectionError(error: Error) { + return (error && error.message && ( + Object.values(GRPC_CONNECTION_ERROR_STRINGS).find(str => error.message.includes(str)) !== null + )); +} + +export function getGrpcConnectionErrorDetails(error: Error) { + let title: string | undefined = undefined; + let message: string | undefined = undefined; + if (!isGrpcConnectionError(error)) { + return { title, message }; + } + + if (error.message.includes(GRPC_CONNECTION_ERROR_STRINGS.SERVER_SELF_SIGNED)) { + title = 'Server Certificate Cannot Be Validated'; + message = 'The server is using a certificate that cannot be validated.\nAdd the server\'s root certificate to this collection or disable SSL validation for requests in Preferences.'; + } else if (error.message.includes(GRPC_CONNECTION_ERROR_STRINGS.CLIENT_CERT_REQUIRED)) { + title = 'Client Certificate Required'; + message = 'The server requires a client certificate to establish a connection.'; + } else if (error.message.includes(GRPC_CONNECTION_ERROR_STRINGS.SERVER_CANCELED)) { + title = 'Server Cancelled Request'; + message = 'The request was cancelled by the server.\nIf the server requires a TLS connection, ensure the request URL is prefixed with "grpcs://".'; + } else if (error.message.includes(GRPC_CONNECTION_ERROR_STRINGS.TLS_NOT_SUPPORTED)) { + title = 'TLS Not Supported'; + message = 'The server does not support TLS connections.\nRemove the "grpcs://" prefix from the request URL to make an insecure request.'; + } else if (error.message.includes(GRPC_CONNECTION_ERROR_STRINGS.BAD_LOCAL_ROOT_CERT)) { + title = 'Local Root Certificate Error'; + message = 'The local root certificate enabled for the host is not valid.\nEither disable the root certificate, or update it with a valid one.'; + } + + return { + title, + message, + }; +}