Add per-domain toggle to disable automatic HTTP redirect following in the Native and Agent interceptors. When disabled, requests return the redirect response (status code, headers, body) without following the Location header. Previously HTTP redirects were always followed (on browser, can't do much about that, see https://fetch.spec.whatwg.org/#atomic-http-redirect-handling) without option to inspect the redirect response itself. This prevented developers from accessing redirect metadata needed when testing OAuth flows (PKCE where intermediate responses contain authorization tokens), authentication endpoints that return codes in Location headers with 302 status, and debugging API redirect chains. But on the desktop app, redirects were just never followed, creating the opposite effect. The browser's fetch API applies atomic HTTP redirect handling per spec, making it impossible to intercept redirects and inspect their responses. The Native and Agent interceptors use curl and native HTTP clients respectively, both supporting redirect control, making this feature viable for these specific interceptors. (Proxyscotch tbd).
256 lines
6.6 KiB
TypeScript
256 lines
6.6 KiB
TypeScript
import { pipe } from "fp-ts/function"
|
|
import * as O from "fp-ts/Option"
|
|
import * as E from "fp-ts/Either"
|
|
import { StoreFile, RelayRequest } from "@hoppscotch/kernel"
|
|
|
|
export type InputDomainSetting = {
|
|
version: "v1"
|
|
security?: {
|
|
certificates?: {
|
|
client?:
|
|
| {
|
|
kind: "pem"
|
|
cert?: StoreFile
|
|
key?: StoreFile
|
|
}
|
|
| {
|
|
kind: "pfx"
|
|
data?: StoreFile
|
|
password?: string
|
|
}
|
|
ca?: StoreFile[]
|
|
}
|
|
verifyHost?: boolean
|
|
verifyPeer?: boolean
|
|
}
|
|
proxy?: {
|
|
url: string
|
|
auth?: {
|
|
username?: string
|
|
password?: string
|
|
}
|
|
certificates?: {
|
|
ca?: StoreFile[]
|
|
client?:
|
|
| {
|
|
kind: "pem"
|
|
cert?: StoreFile
|
|
key?: StoreFile
|
|
}
|
|
| {
|
|
kind: "pfx"
|
|
data?: StoreFile
|
|
password?: string
|
|
}
|
|
}
|
|
}
|
|
options?: {
|
|
followRedirects?: boolean
|
|
maxRedirects?: number
|
|
timeout?: number
|
|
decompress?: boolean
|
|
cookies?: boolean
|
|
keepAlive?: boolean
|
|
}
|
|
}
|
|
|
|
const convertStoreFile = (file: StoreFile): O.Option<Uint8Array> =>
|
|
file.include === false ? O.none : O.some(file.content)
|
|
|
|
const convertClientCert = (
|
|
cert?:
|
|
| {
|
|
kind: "pem"
|
|
cert?: StoreFile
|
|
key?: StoreFile
|
|
}
|
|
| {
|
|
kind: "pfx"
|
|
data?: StoreFile
|
|
password?: string
|
|
}
|
|
): O.Option<
|
|
NonNullable<
|
|
NonNullable<
|
|
Pick<RelayRequest, "proxy" | "security">["security"]
|
|
>["certificates"]
|
|
>["client"]
|
|
> => {
|
|
if (!cert) return O.none
|
|
|
|
switch (cert.kind) {
|
|
case "pem": {
|
|
const certContent = pipe(
|
|
O.fromNullable(cert.cert),
|
|
O.chain(convertStoreFile)
|
|
)
|
|
const keyContent = pipe(
|
|
O.fromNullable(cert.key),
|
|
O.chain(convertStoreFile)
|
|
)
|
|
|
|
return pipe(
|
|
O.Do,
|
|
O.bind("cert", () => certContent),
|
|
O.bind("key", () => keyContent),
|
|
O.map(({ cert, key }) => ({
|
|
kind: "pem" as const,
|
|
cert,
|
|
key,
|
|
}))
|
|
)
|
|
}
|
|
case "pfx": {
|
|
const dataContent = pipe(
|
|
O.fromNullable(cert.data),
|
|
O.chain(convertStoreFile)
|
|
)
|
|
|
|
return pipe(
|
|
O.Do,
|
|
O.bind("data", () => dataContent),
|
|
O.bind("password", () => O.fromNullable(cert.password)),
|
|
O.map(({ data, password }) => ({
|
|
kind: "pfx" as const,
|
|
data,
|
|
password,
|
|
}))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
const convertCaCerts = (certs?: StoreFile[]): O.Option<Uint8Array[]> =>
|
|
pipe(
|
|
O.fromNullable(certs),
|
|
O.chain((certs: StoreFile[]) => {
|
|
const converted = certs
|
|
.map(convertStoreFile)
|
|
.filter(O.isSome)
|
|
.map((opt) => opt.value)
|
|
return converted.length > 0 ? O.some(converted) : O.none
|
|
})
|
|
)
|
|
|
|
const convertSecurity = (
|
|
security?: InputDomainSetting["security"]
|
|
): O.Option<Pick<RelayRequest, "proxy" | "security">["security"]> =>
|
|
pipe(
|
|
O.fromNullable(security),
|
|
O.chain((security) => {
|
|
const certificatesOption = pipe(
|
|
O.fromNullable(security.certificates),
|
|
O.chain((certificates) => {
|
|
const clientCert = convertClientCert(certificates.client)
|
|
const caCerts = convertCaCerts(certificates.ca)
|
|
|
|
// Include if at least one certificate exists
|
|
return O.isSome(clientCert) || O.isSome(caCerts)
|
|
? O.some({
|
|
...(O.isSome(clientCert) ? { client: clientCert.value } : {}),
|
|
...(O.isSome(caCerts) ? { ca: caCerts.value } : {}),
|
|
})
|
|
: O.none
|
|
})
|
|
)
|
|
return O.some({
|
|
...(O.isSome(certificatesOption)
|
|
? { certificates: certificatesOption.value }
|
|
: {}),
|
|
// Default to `false` if not explicitly set,
|
|
// if no certificates but security object exists, still return verify settings
|
|
verifyHost: security.verifyHost ?? false,
|
|
verifyPeer: security.verifyPeer ?? false,
|
|
})
|
|
}),
|
|
// If no security object at all, return default settings
|
|
O.alt(() =>
|
|
O.some({
|
|
verifyHost: false,
|
|
verifyPeer: false,
|
|
})
|
|
)
|
|
)
|
|
|
|
const convertProxy = (
|
|
proxy?: InputDomainSetting["proxy"]
|
|
): O.Option<Pick<RelayRequest, "proxy" | "security">["proxy"]> =>
|
|
pipe(
|
|
O.fromNullable(proxy),
|
|
O.chain((proxy) => {
|
|
if (!proxy.url) return O.none
|
|
|
|
const auth = proxy.auth && {
|
|
username: proxy.auth.username || "",
|
|
password: proxy.auth.password || "",
|
|
}
|
|
|
|
return pipe(
|
|
O.fromNullable(proxy.certificates),
|
|
O.chain((certificates) =>
|
|
pipe(
|
|
O.Do,
|
|
O.bind("client", () => convertClientCert(certificates.client)),
|
|
O.bind("ca", () => convertCaCerts(certificates.ca)),
|
|
O.map((certs) => ({
|
|
client: certs.client,
|
|
ca: certs.ca,
|
|
}))
|
|
)
|
|
),
|
|
O.fold(
|
|
() =>
|
|
O.some({
|
|
url: proxy.url,
|
|
...(auth && { auth }),
|
|
}),
|
|
(certificates) =>
|
|
O.some({
|
|
url: proxy.url,
|
|
...(auth && { auth }),
|
|
certificates,
|
|
})
|
|
)
|
|
)
|
|
})
|
|
)
|
|
|
|
const convertOptions = (
|
|
options?: InputDomainSetting["options"]
|
|
): O.Option<NonNullable<RelayRequest["meta"]>["options"]> =>
|
|
pipe(
|
|
O.fromNullable(options),
|
|
O.map((opts) => ({
|
|
...(opts.followRedirects !== undefined && {
|
|
followRedirects: opts.followRedirects,
|
|
}),
|
|
...(opts.maxRedirects !== undefined && {
|
|
maxRedirects: Math.min(opts.maxRedirects, 10),
|
|
}),
|
|
...(opts.timeout !== undefined && { timeout: opts.timeout }),
|
|
...(opts.decompress !== undefined && { decompress: opts.decompress }),
|
|
...(opts.cookies !== undefined && { cookies: opts.cookies }),
|
|
...(opts.keepAlive !== undefined && { keepAlive: opts.keepAlive }),
|
|
})),
|
|
O.filter((opts) => Object.keys(opts).length > 0)
|
|
)
|
|
|
|
export const convertDomainSetting = (
|
|
input: InputDomainSetting
|
|
): E.Either<Error, Pick<RelayRequest, "proxy" | "security" | "meta">> => {
|
|
if (input.version !== "v1") {
|
|
return E.left(new Error("Invalid version"))
|
|
}
|
|
|
|
const security = convertSecurity(input.security)
|
|
const proxy = convertProxy(input.proxy)
|
|
const options = convertOptions(input.options)
|
|
|
|
const result: Pick<RelayRequest, "proxy" | "security" | "meta"> = {
|
|
proxy: O.isSome(proxy) ? proxy.value : undefined,
|
|
security: O.isSome(security) ? security.value : undefined,
|
|
meta: O.isSome(options) ? { options: options.value } : undefined,
|
|
}
|
|
|
|
return E.right(result)
|
|
}
|