Skip to content

Commit ecf7d25

Browse files
feat(relay): control redirect follow (hoppscotch#5508)
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).
1 parent 567344a commit ecf7d25

File tree

18 files changed

+254
-18
lines changed

18 files changed

+254
-18
lines changed

packages/hoppscotch-agent/src-tauri/Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/hoppscotch-common/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,7 @@
10451045
"validate_certificates": "Validate SSL/TLS Certificates",
10461046
"verify_host": "Verify Host",
10471047
"verify_peer": "Verify Peer",
1048+
"follow_redirects": "Follow Redirects",
10481049
"client_certificates": "Client Certificates",
10491050
"certificate_settings": "Certificate Settings",
10501051
"certificate": "Certificate",

packages/hoppscotch-common/src/components/settings/Agent.vue

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@
3131
{{ t("settings.verify_peer") }}
3232
</div>
3333

34+
<div class="flex items-center">
35+
<HoppSmartToggle
36+
:on="domainSettings[selectedDomain]?.options?.followRedirects ?? true"
37+
@change="toggleFollowRedirects"
38+
/>
39+
{{ t("settings.follow_redirects") }}
40+
</div>
41+
3442
<div class="flex space-x-4">
3543
<HoppButtonSecondary
3644
:icon="IconFileBadge"
@@ -517,6 +525,10 @@ function updateDomainSettings(newSettings: any) {
517525
...newSettings.security?.certificates,
518526
},
519527
},
528+
options: {
529+
...currentSettings?.options,
530+
...newSettings.options,
531+
},
520532
}
521533
522534
store.saveDomainSettings(domain, domainSettings[domain])
@@ -538,6 +550,17 @@ function toggleVerifyPeer() {
538550
})
539551
}
540552
553+
function toggleFollowRedirects() {
554+
const currentValue =
555+
domainSettings[selectedDomain.value]?.options?.followRedirects ?? true
556+
557+
updateDomainSettings({
558+
options: {
559+
followRedirects: !currentValue,
560+
},
561+
})
562+
}
563+
541564
function toggleProxy() {
542565
updateDomainSettings({
543566
proxy: domainSettings[selectedDomain.value]?.proxy

packages/hoppscotch-common/src/components/settings/Native.vue

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@
3131
{{ t("settings.verify_peer") }}
3232
</div>
3333

34+
<div class="flex items-center">
35+
<HoppSmartToggle
36+
:on="domainSettings[selectedDomain]?.options?.followRedirects ?? true"
37+
@change="toggleFollowRedirects"
38+
/>
39+
{{ t("settings.follow_redirects") }}
40+
</div>
41+
3442
<div class="flex space-x-4">
3543
<HoppButtonSecondary
3644
:icon="IconFileBadge"
@@ -513,6 +521,10 @@ function updateDomainSettings(newSettings: any) {
513521
...newSettings.security?.certificates,
514522
},
515523
},
524+
options: {
525+
...currentSettings?.options,
526+
...newSettings.options,
527+
},
516528
}
517529
518530
store.saveDomainSettings(domain, domainSettings[domain])
@@ -534,6 +546,17 @@ function toggleVerifyPeer() {
534546
})
535547
}
536548
549+
function toggleFollowRedirects() {
550+
const currentValue =
551+
domainSettings[selectedDomain.value]?.options?.followRedirects ?? true
552+
553+
updateDomainSettings({
554+
options: {
555+
followRedirects: !currentValue,
556+
},
557+
})
558+
}
559+
537560
function toggleProxy() {
538561
updateDomainSettings({
539562
proxy: domainSettings[selectedDomain.value]?.proxy

packages/hoppscotch-common/src/helpers/functional/domain-settings.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ export type InputDomainSetting = {
4444
}
4545
}
4646
}
47+
options?: {
48+
followRedirects?: boolean
49+
maxRedirects?: number
50+
timeout?: number
51+
decompress?: boolean
52+
cookies?: boolean
53+
keepAlive?: boolean
54+
}
4755
}
4856

4957
const convertStoreFile = (file: StoreFile): O.Option<Uint8Array> =>
@@ -207,19 +215,41 @@ const convertProxy = (
207215
})
208216
)
209217

218+
const convertOptions = (
219+
options?: InputDomainSetting["options"]
220+
): O.Option<NonNullable<RelayRequest["meta"]>["options"]> =>
221+
pipe(
222+
O.fromNullable(options),
223+
O.map((opts) => ({
224+
...(opts.followRedirects !== undefined && {
225+
followRedirects: opts.followRedirects,
226+
}),
227+
...(opts.maxRedirects !== undefined && {
228+
maxRedirects: Math.min(opts.maxRedirects, 10),
229+
}),
230+
...(opts.timeout !== undefined && { timeout: opts.timeout }),
231+
...(opts.decompress !== undefined && { decompress: opts.decompress }),
232+
...(opts.cookies !== undefined && { cookies: opts.cookies }),
233+
...(opts.keepAlive !== undefined && { keepAlive: opts.keepAlive }),
234+
})),
235+
O.filter((opts) => Object.keys(opts).length > 0)
236+
)
237+
210238
export const convertDomainSetting = (
211239
input: InputDomainSetting
212-
): E.Either<Error, Pick<RelayRequest, "proxy" | "security">> => {
240+
): E.Either<Error, Pick<RelayRequest, "proxy" | "security" | "meta">> => {
213241
if (input.version !== "v1") {
214242
return E.left(new Error("Invalid version"))
215243
}
216244

217245
const security = convertSecurity(input.security)
218246
const proxy = convertProxy(input.proxy)
247+
const options = convertOptions(input.options)
219248

220-
const result: Pick<RelayRequest, "proxy" | "security"> = {
249+
const result: Pick<RelayRequest, "proxy" | "security" | "meta"> = {
221250
proxy: O.isSome(proxy) ? proxy.value : undefined,
222251
security: O.isSome(security) ? security.value : undefined,
252+
meta: O.isSome(options) ? { options: options.value } : undefined,
223253
}
224254

225255
return E.right(result)

packages/hoppscotch-common/src/platform/std/kernel-interceptors/agent/store.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ const defaultDomainConfig: InputDomainSetting = {
3434
verifyPeer: true,
3535
},
3636
proxy: undefined,
37+
options: {
38+
followRedirects: true,
39+
},
3740
}
3841

3942
export class KernelInterceptorAgentStore extends Service {
@@ -147,6 +150,15 @@ export class KernelInterceptorAgentStore extends Service {
147150
)
148151
}
149152

153+
private mergeOptions(
154+
...settings: (Required<InputDomainSetting>["options"] | undefined)[]
155+
): Required<InputDomainSetting>["options"] | undefined {
156+
return settings.reduce(
157+
(acc, setting) => (setting ? { ...acc, ...setting } : acc),
158+
undefined as Required<InputDomainSetting>["options"] | undefined
159+
)
160+
}
161+
150162
private getMergedSettings(domain: string): InputDomainSetting {
151163
const domainSettings = this.domainSettings.get(domain)
152164
const globalSettings =
@@ -160,13 +172,17 @@ export class KernelInterceptorAgentStore extends Service {
160172
domainSettings?.security
161173
),
162174
proxy: this.mergeProxy(globalSettings?.proxy, domainSettings?.proxy),
175+
options: this.mergeOptions(
176+
globalSettings?.options,
177+
domainSettings?.options
178+
),
163179
}
164180

165181
return { version: "v1", ...result }
166182
}
167183

168184
public completeRequest(
169-
request: Omit<PluginRequest, "proxy" | "security">
185+
request: Omit<RelayRequest, "proxy" | "security" | "meta">
170186
): PluginRequest {
171187
const host = new URL(request.url).host
172188
const settings = this.getMergedSettings(host)

packages/hoppscotch-common/src/platform/std/kernel-interceptors/native/store.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const defaultDomainConfig: InputDomainSetting = {
2626
verifyPeer: true,
2727
},
2828
proxy: undefined,
29+
options: {
30+
followRedirects: true,
31+
},
2932
}
3033

3134
export class KernelInterceptorNativeStore extends Service {
@@ -117,6 +120,15 @@ export class KernelInterceptorNativeStore extends Service {
117120
)
118121
}
119122

123+
private mergeOptions(
124+
...settings: (Required<InputDomainSetting>["options"] | undefined)[]
125+
): Required<InputDomainSetting>["options"] | undefined {
126+
return settings.reduce(
127+
(acc, setting) => (setting ? { ...acc, ...setting } : acc),
128+
undefined as Required<InputDomainSetting>["options"] | undefined
129+
)
130+
}
131+
120132
private getMergedSettings(domain: string): InputDomainSetting {
121133
const domainSettings = this.domainSettings.get(domain)
122134
const globalSettings =
@@ -130,13 +142,17 @@ export class KernelInterceptorNativeStore extends Service {
130142
domainSettings?.security
131143
),
132144
proxy: this.mergeProxy(globalSettings?.proxy, domainSettings?.proxy),
145+
options: this.mergeOptions(
146+
globalSettings?.options,
147+
domainSettings?.options
148+
),
133149
}
134150

135151
return { version: "v1", ...result }
136152
}
137153

138154
public completeRequest(
139-
request: Omit<RelayRequest, "proxy" | "security">
155+
request: Omit<RelayRequest, "proxy" | "security" | "meta">
140156
): RelayRequest {
141157
const host = new URL(request.url).host
142158
const settings = this.getMergedSettings(host)

packages/hoppscotch-desktop/plugin-workspace/relay/src/interop.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,22 @@ pub struct CertificateConfig {
313313
pub ca: Option<Vec<Bytes>>,
314314
}
315315

316+
#[derive(Debug, Serialize, Deserialize, Clone)]
317+
pub struct RequestMeta {
318+
pub options: Option<RequestOptions>,
319+
}
320+
321+
#[derive(Debug, Serialize, Deserialize, Clone)]
322+
#[serde(rename_all = "camelCase")]
323+
pub struct RequestOptions {
324+
pub timeout: Option<u64>,
325+
pub follow_redirects: Option<bool>,
326+
pub max_redirects: Option<u32>,
327+
pub decompress: Option<bool>,
328+
pub cookies: Option<bool>,
329+
pub keep_alive: Option<bool>,
330+
}
331+
316332
#[derive(Debug, Serialize, Deserialize, Clone)]
317333
pub struct Request {
318334
pub id: i64,
@@ -327,6 +343,7 @@ pub struct Request {
327343
pub auth: Option<AuthType>,
328344
pub security: Option<SecurityConfig>,
329345
pub proxy: Option<ProxyConfig>,
346+
pub meta: Option<RequestMeta>,
330347
}
331348

332349
#[derive(Debug, Serialize, Deserialize, Clone)]

packages/hoppscotch-desktop/plugin-workspace/relay/src/request.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,88 @@ impl<'a> CurlRequest<'a> {
113113
}
114114
})?;
115115

116+
let Some(ref meta) = self.request.meta else {
117+
tracing::debug!("No meta configuration provided");
118+
return Ok(());
119+
};
120+
121+
let Some(ref options) = meta.options else {
122+
tracing::debug!("No options in meta configuration");
123+
return Ok(());
124+
};
125+
126+
if let Some(follow) = options.follow_redirects {
127+
tracing::debug!(follow_redirects = follow, "Setting redirect behavior");
128+
self.handle.follow_location(follow).map_err(|e| {
129+
tracing::error!(error = %e, "Failed to set follow_location");
130+
RelayError::Network {
131+
message: "Failed to set redirect behavior".into(),
132+
cause: Some(e.to_string()),
133+
}
134+
})?;
135+
}
136+
137+
if let Some(max) = options.max_redirects {
138+
tracing::debug!(max_redirects = max, "Setting maximum redirects");
139+
self.handle.max_redirections(max).map_err(|e| {
140+
tracing::error!(error = %e, "Failed to set max_redirections");
141+
RelayError::Network {
142+
message: "Failed to set maximum redirects".into(),
143+
cause: Some(e.to_string()),
144+
}
145+
})?;
146+
}
147+
148+
if let Some(timeout_ms) = options.timeout {
149+
tracing::debug!(timeout_ms = timeout_ms, "Setting request timeout");
150+
self.handle
151+
.timeout(std::time::Duration::from_millis(timeout_ms))
152+
.map_err(|e| {
153+
tracing::error!(error = %e, "Failed to set timeout");
154+
RelayError::Network {
155+
message: "Failed to set timeout".into(),
156+
cause: Some(e.to_string()),
157+
}
158+
})?;
159+
}
160+
161+
if let Some(decompress) = options.decompress {
162+
if !decompress {
163+
tracing::debug!("Disabling automatic decompression");
164+
self.handle.accept_encoding("identity").map_err(|e| {
165+
tracing::error!(error = %e, "Failed to disable decompression");
166+
RelayError::Network {
167+
message: "Failed to disable decompression".into(),
168+
cause: Some(e.to_string()),
169+
}
170+
})?;
171+
}
172+
}
173+
174+
if let Some(enable_cookies) = options.cookies {
175+
tracing::debug!(enable_cookies = enable_cookies, "Setting cookie handling");
176+
if enable_cookies {
177+
self.handle.cookie_file("").map_err(|e| {
178+
tracing::error!(error = %e, "Failed to enable cookies");
179+
RelayError::Network {
180+
message: "Failed to enable cookie handling".into(),
181+
cause: Some(e.to_string()),
182+
}
183+
})?;
184+
}
185+
}
186+
187+
if let Some(keep_alive) = options.keep_alive {
188+
tracing::debug!(keep_alive = keep_alive, "Setting keep-alive");
189+
self.handle.tcp_keepalive(keep_alive).map_err(|e| {
190+
tracing::error!(error = %e, "Failed to set keep-alive");
191+
RelayError::Network {
192+
message: "Failed to set keep-alive".into(),
193+
cause: Some(e.to_string()),
194+
}
195+
})?;
196+
}
197+
116198
tracing::debug!("Basic request parameters set successfully");
117199
Ok(())
118200
}

0 commit comments

Comments
 (0)