feat: client certificates and ability to skip ssl cert verification in desktop app (#4111)

Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
This commit is contained in:
Andrew Bastin
2024-06-25 15:35:43 +05:30
parent 5e3bc01922
commit aead9e6c98
15 changed files with 2262 additions and 833 deletions

View File

@@ -1,170 +0,0 @@
import * as E from "fp-ts/Either"
import {
Interceptor,
InterceptorError,
RequestRunResult,
} from "@hoppscotch/common/services/interceptor.service"
import { CookieJarService } from "@hoppscotch/common/services/cookie-jar.service"
import axios, { AxiosRequestConfig, CancelToken } from "axios"
import { cloneDeep } from "lodash-es"
import { Body, HttpVerb, ResponseType, getClient } from "@tauri-apps/api/http"
import { Service } from "dioc"
export const preProcessRequest = (
req: AxiosRequestConfig
): AxiosRequestConfig => {
const reqClone = cloneDeep(req)
// If the parameters are URLSearchParams, inject them to URL instead
// This prevents issues of marshalling the URLSearchParams to the proxy
if (reqClone.params instanceof URLSearchParams) {
try {
const url = new URL(reqClone.url ?? "")
for (const [key, value] of reqClone.params.entries()) {
url.searchParams.append(key, value)
}
reqClone.url = url.toString()
} catch (e) {
// making this a non-empty block, so we can make the linter happy.
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
}
reqClone.params = {}
}
return reqClone
}
async function runRequest(
req: AxiosRequestConfig,
cancelled: () => boolean
): RequestRunResult["response"] {
const timeStart = Date.now()
const processedReq = preProcessRequest(req)
try {
const client = await getClient()
if (cancelled()) {
client.drop()
return E.left("cancellation")
}
let body = Body.text(processedReq.data ?? "")
if (processedReq.data instanceof FormData) {
let body_data = {}
for (const entry of processedReq.data.entries()) {
const [name, value] = entry
if (value instanceof File) {
let file_data = await value.arrayBuffer()
body_data[name] = {
file: new Uint8Array(file_data),
fileName: value.name,
}
}
}
body = Body.form(body_data)
}
const res = await client.request({
method: processedReq.method as HttpVerb,
url: processedReq.url ?? "",
responseType: ResponseType.Binary,
headers: processedReq.headers,
body: body,
})
if (cancelled()) {
client.drop()
return E.left("cancellation")
}
res.data = new Uint8Array(res.data as number[]).buffer
const timeEnd = Date.now()
return E.right({
...res,
config: {
timeData: {
startTime: timeStart,
endTime: timeEnd,
},
},
additional: {
multiHeaders: Object.entries(res.rawHeaders).flatMap(
([header, values]) => values.map((value) => ({ key: header, value }))
),
},
})
} catch (e) {
const timeEnd = Date.now()
if (axios.isAxiosError(e) && e.response) {
return E.right({
...e.response,
config: {
timeData: {
startTime: timeStart,
endTime: timeEnd,
},
},
})
} else if (axios.isCancel(e)) {
return E.left("cancellation")
} else {
return E.left(<InterceptorError>{
humanMessage: {
heading: (t) => t("error.network_fail"),
description: (t) => t("helpers.network_fail"),
},
error: e,
})
}
}
}
export class NativeInterceptorService extends Service implements Interceptor {
public static readonly ID = "NATIVE_INTERCEPTOR_SERVICE"
public interceptorID = "native" // TODO: i18n this
public name = () => "Native"
public selectable = { type: "selectable" as const }
public supportsCookies = true
public cookieJarService = this.bind(CookieJarService)
public runRequest(req: any) {
const processedReq = preProcessRequest(req)
const relevantCookies = this.cookieJarService.getCookiesForURL(
new URL(processedReq.url!)
)
processedReq.headers["Cookie"] = relevantCookies
.map((cookie) => `${cookie.name!}=${cookie.value!}`)
.join(";")
let cancelled = false
const checkCancelled = () => {
return cancelled
}
return {
cancel: () => {
cancelled = true
},
response: runRequest(processedReq, checkCancelled),
}
}
}

View File

@@ -0,0 +1,459 @@
import { CookieJarService } from "@hoppscotch/common/services/cookie-jar.service"
import { Interceptor, InterceptorError, NetworkResponse, RequestRunResult } from "@hoppscotch/common/services/interceptor.service"
import { Service } from "dioc"
import { cloneDeep } from "lodash-es"
import { invoke } from "@tauri-apps/api/tauri"
import * as E from "fp-ts/Either"
import SettingsNativeInterceptor from "../../../components/settings/NativeInterceptor.vue"
import { ref, watch } from "vue"
import { z } from "zod"
import { PersistenceService } from "@hoppscotch/common/services/persistence"
import { CACertStore, ClientCertsStore, ClientCertStore, StoredClientCert } from "./persisted-data"
type KeyValuePair = {
key: string,
value: string
}
type FormDataValue =
| { Text: string }
| {
File: {
filename: string,
data: Uint8Array
}
}
type FormDataEntry = {
key: string,
value: FormDataValue
}
type BodyDef =
| { Text: string }
| { URLEncoded: KeyValuePair[] }
| { FormData: FormDataEntry[] }
type ClientCertDef =
| {
PEMCert: {
certificate_pem: number[],
key_pem: number[]
},
}
| {
PFXCert: {
certificate_pfx: number[],
password: string
}
}
// TODO: Figure out a way to autogen this from the interceptor definition on the Rust side
type RequestDef = {
req_id: number
method: string
endpoint: string
parameters: KeyValuePair[]
headers: KeyValuePair[]
body: BodyDef | null,
validate_certs: boolean,
root_cert_bundle_files: number[],
client_cert: ClientCertDef | null
}
type RunRequestResponse = {
status: number,
status_text: string,
headers: KeyValuePair[],
data: number[],
time_start_ms: number,
time_end_ms: number
}
// HACK: To solve the AxiosRequestConfig being different between @hoppscotch/common
// and the axios present in this package
type AxiosRequestConfig = Parameters<Interceptor["runRequest"]>[0]
export const preProcessRequest = (
req: AxiosRequestConfig
): AxiosRequestConfig => {
const reqClone = cloneDeep(req)
// If the parameters are URLSearchParams, inject them to URL instead
// This prevents issues of marshalling the URLSearchParams to the proxy
if (reqClone.params instanceof URLSearchParams) {
try {
const url = new URL(reqClone.url ?? "")
for (const [key, value] of reqClone.params.entries()) {
url.searchParams.append(key, value)
}
reqClone.url = url.toString()
} catch (e) {
// making this a non-empty block, so we can make the linter happy.
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
}
reqClone.params = {}
}
return reqClone
}
async function processBody(axiosReq: AxiosRequestConfig): Promise<BodyDef | null> {
if (!axiosReq.data) return null
if (typeof axiosReq.data === "string") {
return { Text: axiosReq.data }
}
if (axiosReq.data instanceof FormData) {
const entries: FormDataEntry[] = []
for (const [key, value] of axiosReq.data.entries()) {
if (typeof value === "string") {
entries.push({
key,
value: { Text: value }
})
} else {
entries.push({
key,
value: {
File: {
filename: value.name,
data: new Uint8Array(await value.arrayBuffer())
}
}
})
}
}
return { FormData: entries }
}
throw new Error("Native Process Body: Unhandled Axios Request Configuration")
}
function getURLDomain(url: string): string | null {
try {
return new URL(url).host
} catch (_) {
return null
}
}
function convertClientCertToDefCert(cert: ClientCertificateEntry): ClientCertDef {
if ("PEMCert" in cert.cert) {
return {
PEMCert: {
certificate_pem: Array.from(cert.cert.PEMCert.certificate_pem),
key_pem: Array.from(cert.cert.PEMCert.key_pem)
}
}
} else {
return {
PFXCert: {
certificate_pfx: Array.from(cert.cert.PFXCert.certificate_pfx),
password: cert.cert.PFXCert.password
}
}
}
}
async function convertToRequestDef(
axiosReq: AxiosRequestConfig,
reqID: number,
caCertificates: CACertificateEntry[],
clientCertificates: Map<string, ClientCertificateEntry>,
validateCerts: boolean
): Promise<RequestDef> {
const clientCertDomain = getURLDomain(axiosReq.url!)
const clientCert = clientCertDomain ? clientCertificates.get(clientCertDomain) : null
return {
req_id: reqID,
method: axiosReq.method ?? "GET",
endpoint: axiosReq.url ?? "",
headers: Object.entries(axiosReq.headers ?? {})
.map(([key, value]): KeyValuePair => ({ key, value })),
parameters: Object.entries(axiosReq.params as Record<string, string> ?? {})
.map(([key, value]): KeyValuePair => ({ key, value })),
body: await processBody(axiosReq),
root_cert_bundle_files: caCertificates.map((cert) => Array.from(cert.certificate)),
validate_certs: validateCerts,
client_cert: clientCert ? convertClientCertToDefCert(clientCert) : null
}
}
export const CACertificateEntry = z.object({
filename: z.string().min(1),
enabled: z.boolean(),
certificate: z.instanceof(Uint8Array)
})
export type CACertificateEntry = z.infer<typeof CACertificateEntry>
export const ClientCertificateEntry = z.object({
enabled: z.boolean(),
domain: z.string().trim().min(1),
cert: z.union([
z.object({
PEMCert: z.object({
certificate_filename: z.string().min(1),
certificate_pem: z.instanceof(Uint8Array),
key_filename: z.string().min(1),
key_pem: z.instanceof(Uint8Array),
})
}),
z.object({
PFXCert: z.object({
certificate_filename: z.string().min(1),
certificate_pfx: z.instanceof(Uint8Array),
password: z.string()
})
})
])
})
export type ClientCertificateEntry = z.infer<typeof ClientCertificateEntry>
const CA_STORE_PERSIST_KEY = "native_interceptor_ca_store"
const CLIENT_CERTS_PERSIST_KEY = "native_interceptor_client_certs_store"
const VALIDATE_SSL_KEY = "native_interceptor_validate_ssl"
export class NativeInterceptorService extends Service implements Interceptor {
public static readonly ID = "NATIVE_INTERCEPTOR_SERVICE"
public interceptorID = "native"
public name = () => "Native"
public selectable = { type: "selectable" as const }
public supportsCookies = true
private cookieJarService = this.bind(CookieJarService)
private persistenceService: PersistenceService = this.bind(PersistenceService)
private reqIDTicker = 0
public settingsPageEntry = {
entryTitle: () => "Native", // TODO: i18n this
component: SettingsNativeInterceptor
}
public caCertificates = ref<CACertificateEntry[]>([])
public clientCertificates = ref<Map<string, ClientCertificateEntry>>(new Map())
public validateCerts = ref(true)
override onServiceInit() {
// Load SSL Validation
const persistedValidateSSL: unknown = JSON.parse(
this.persistenceService.getLocalConfig(VALIDATE_SSL_KEY) ?? "null"
)
if (typeof persistedValidateSSL === "boolean") {
this.validateCerts.value = persistedValidateSSL
}
watch(this.validateCerts, () => {
this.persistenceService.setLocalConfig(VALIDATE_SSL_KEY, JSON.stringify(this.validateCerts.value))
})
// Load and setup writes for CA Store
const persistedCAStoreData = JSON.parse(
this.persistenceService.getLocalConfig(CA_STORE_PERSIST_KEY) ?? "null"
)
const caStoreDataParseResult = CACertStore.safeParse(persistedCAStoreData)
if (caStoreDataParseResult.type === "ok") {
this.caCertificates.value = caStoreDataParseResult.value.certs
.map((entry) => ({ ...entry, certificate: new Uint8Array(entry.certificate) }))
}
watch(this.caCertificates, (certs) => {
const storableValue: CACertStore = {
v: 1,
certs: certs
.map((el) => ({ ...el, certificate: Array.from(el.certificate) }))
}
this.persistenceService.setLocalConfig(CA_STORE_PERSIST_KEY, JSON.stringify(storableValue))
})
// Load and setup writes for Client Certs Store
const persistedClientCertStoreData = JSON.parse(
this.persistenceService.getLocalConfig(CLIENT_CERTS_PERSIST_KEY) ?? "null"
)
const clientCertStoreDataParseResult = ClientCertsStore.safeParse(persistedClientCertStoreData)
if (clientCertStoreDataParseResult.type === "ok") {
this.clientCertificates.value = new Map(
Object.entries(
clientCertStoreDataParseResult.value.clientCerts
)
.map(([domain, cert]) => {
if ("PFXCert" in cert.cert) {
const newCert = <ClientCertificateEntry>{
...cert,
cert: {
PFXCert: {
certificate_pfx: new Uint8Array(cert.cert.PFXCert.certificate_pfx),
certificate_filename: cert.cert.PFXCert.certificate_filename,
password: cert.cert.PFXCert.password
}
}
}
return [domain, newCert]
} else {
const newCert = <ClientCertificateEntry>{
...cert,
cert: {
PEMCert: {
certificate_pem: new Uint8Array(cert.cert.PEMCert.certificate_pem),
certificate_filename: cert.cert.PEMCert.certificate_filename,
key_pem: new Uint8Array(cert.cert.PEMCert.key_pem),
key_filename: cert.cert.PEMCert.key_filename
}
}
}
return [domain, newCert]
}
})
)
}
watch(this.clientCertificates, (certs) => {
const storableValue: ClientCertStore = {
v: 1,
clientCerts: Object.fromEntries(
Array.from(certs.entries())
.map(([domain, cert]) => {
if ("PFXCert" in cert.cert) {
const newCert = <StoredClientCert>{
...cert,
cert: {
PFXCert: {
certificate_pfx: Array.from(cert.cert.PFXCert.certificate_pfx),
certificate_filename: cert.cert.PFXCert.certificate_filename,
password: cert.cert.PFXCert.password
}
}
}
return [domain, newCert]
} else {
const newCert = <StoredClientCert>{
...cert,
cert: {
PEMCert: {
certificate_pem: Array.from(cert.cert.PEMCert.certificate_pem),
certificate_filename: cert.cert.PEMCert.certificate_filename,
key_pem: Array.from(cert.cert.PEMCert.key_pem),
key_filename: cert.cert.PEMCert.key_filename
}
}
}
return [domain, newCert]
}
})
)
}
this.persistenceService.setLocalConfig(CLIENT_CERTS_PERSIST_KEY, JSON.stringify(storableValue))
})
}
public runRequest(req: AxiosRequestConfig): RequestRunResult<InterceptorError> {
const processedReq = preProcessRequest(req)
const relevantCookies = this.cookieJarService.getCookiesForURL(
new URL(processedReq.url!)
)
processedReq.headers["Cookie"] = relevantCookies
.map((cookie) => `${cookie.name!}=${cookie.value!}`)
.join(";")
const reqID = this.reqIDTicker++;
return {
cancel: () => {
invoke("plugin:hopp_native_interceptor|cancel_request", { reqId: reqID });
},
response: (async () => {
const requestDef = await convertToRequestDef(
processedReq,
reqID,
this.caCertificates.value,
this.clientCertificates.value,
this.validateCerts.value
)
try {
console.log(requestDef)
const response: RunRequestResponse = await invoke(
"plugin:hopp_native_interceptor|run_request",
{ req: requestDef }
)
return E.right({
headers: Object.fromEntries(
response.headers.map(({ key, value }) => [key, value])
),
status: response.status,
statusText: response.status_text,
data: new Uint8Array(response.data).buffer,
config: {
timeData: {
startTime: response.time_start_ms,
endTime: response.time_end_ms
}
},
additional: {
multiHeaders: response.headers
}
})
} catch (e) {
console.log(e)
if (typeof e === "object" && (e as any)["RequestCancelled"]) {
return E.left("cancellation" as const)
}
// TODO: More in-depth error messages
return E.left(<InterceptorError>{
humanMessage: {
heading: (t) => t("error.network_fail"),
description: (t) => t("helpers.network_fail"),
}
})
}
})()
}
}
}

View File

@@ -0,0 +1,85 @@
import { z } from "zod"
import { defineVersion, createVersionedEntity, InferredEntity } from "verzod"
const Uint8 = z.number()
.int()
.gte(0)
.lte(255);
export const StoredCACert = z.object({
filename: z.string().min(1),
enabled: z.boolean(),
certificate: z.array(Uint8)
})
const caCertStore_v1 = defineVersion({
initial: true,
schema: z.object({
v: z.literal(1),
certs: z.array(StoredCACert)
})
})
export const CACertStore = createVersionedEntity({
latestVersion: 1,
versionMap: {
1: caCertStore_v1
},
getVersion(data) {
const result = caCertStore_v1.schema.safeParse(data)
return result.success ? result.data.v : null
}
})
export type CACertStore = InferredEntity<typeof CACertStore>
export const StoredClientCert = z.object({
enabled: z.boolean(),
domain: z.string().trim().min(1),
cert: z.union([
z.object({
PEMCert: z.object({
certificate_filename: z.string().min(1),
certificate_pem: z.array(Uint8),
key_filename: z.string().min(1),
key_pem: z.array(Uint8)
})
}),
z.object({
PFXCert: z.object({
certificate_filename: z.string().min(1),
certificate_pfx: z.array(Uint8),
password: z.string()
})
})
])
})
export type StoredClientCert = z.infer<typeof StoredClientCert>
const clientCertsStore_v1 = defineVersion({
initial: true,
schema: z.object({
v: z.literal(1),
clientCerts: z.record(StoredClientCert)
})
})
export const ClientCertsStore = createVersionedEntity({
latestVersion: 1,
versionMap: {
1: clientCertsStore_v1
},
getVersion(data) {
const result = clientCertsStore_v1.schema.safeParse(data)
return result.success ? result.data.v : null
}
})
export type ClientCertStore = InferredEntity<typeof ClientCertsStore>