Skip to content

Commit 048d07b

Browse files
authored
fix(router): fix header propagation for never-join headers like Set-Cookie (#1396)
1 parent 5f4252b commit 048d07b

4 files changed

Lines changed: 101 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
hive-router-plan-executor: patch
3+
hive-router: patch
4+
---
5+
6+
# Propagate all multi-instance headers from a single subgraph response
7+
8+
When a subgraph responded with multiple instances of a never-join header (`Set-Cookie` or `WWW-Authenticate`), the router only forwarded one of them to the client and silently dropped the rest.
9+
10+
The fix is to propagate all values of a never-join header as separate header fields end-to-end, rather than just the first value.
11+
12+
Fixes https://github.com/graphql-hive/router/issues/1388

bin/router/src/shared_state.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use hive_router_plan_executor::executors::common::InboundRequestFingerprint;
1919
use hive_router_plan_executor::extensions::{
2020
compile::compile_extensions_plan, plan::ExtensionsPlan,
2121
};
22+
use hive_router_plan_executor::headers::sanitizer::is_never_join_header;
2223
use hive_router_plan_executor::headers::{
2324
compile::compile_headers_plan, errors::HeaderRuleCompileError, plan::HeaderRulesPlan,
2425
};
@@ -147,9 +148,15 @@ impl From<SharedRouterSingleResponse> for web::HttpResponse {
147148
fn from(shared_response: SharedRouterSingleResponse) -> Self {
148149
let mut response = web::HttpResponse::Ok();
149150
response.status(shared_response.status);
151+
150152
for (header_name, header_value) in shared_response.headers.iter() {
151-
response.set_header(header_name, header_value);
153+
if is_never_join_header(header_name) {
154+
response.header(header_name, header_value);
155+
} else {
156+
response.set_header(header_name, header_value);
157+
}
152158
}
159+
153160
response.body(shared_response.body)
154161
}
155162
}
@@ -264,7 +271,11 @@ impl SharedRouterStreamResponse {
264271
let mut response = web::HttpResponse::Ok();
265272

266273
for (header_name, header_value) in self.headers.iter() {
267-
response.set_header(header_name, header_value);
274+
if is_never_join_header(header_name) {
275+
response.header(header_name, header_value);
276+
} else {
277+
response.set_header(header_name, header_value);
278+
}
268279
}
269280

270281
// we set content type after so that we can override the shared header

e2e/src/header_propagation.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,70 @@ mod header_propagation_e2e_tests {
289289
);
290290
}
291291

292+
// Regression test for https://github.com/graphql-hive/router/issues/1388
293+
#[ntex::test]
294+
async fn should_propagate_all_set_cookie_values_from_a_single_subgraph() {
295+
let subgraphs = TestSubgraphs::builder().build().start().await;
296+
let mut accounts_server = mockito::Server::new_async().await;
297+
let host = accounts_server.host_with_port();
298+
299+
let router = TestRouter::builder()
300+
.inline_config(format!(
301+
r#"
302+
supergraph:
303+
source: file
304+
path: supergraph.graphql
305+
headers:
306+
all:
307+
response:
308+
- propagate:
309+
named: set-cookie
310+
algorithm: append
311+
override_subgraph_urls:
312+
subgraphs:
313+
accounts:
314+
url: "http://{host}/accounts"
315+
"#
316+
))
317+
.with_subgraphs(&subgraphs)
318+
.build()
319+
.start()
320+
.await;
321+
322+
let accounts_response_mock = accounts_server
323+
.mock("POST", "/accounts")
324+
.with_status(200)
325+
.with_header("content-type", "application/json")
326+
.with_header("set-cookie", "access_token=abc; HttpOnly; Secure; Path=/")
327+
.with_header("set-cookie", "refresh_token=xyz; HttpOnly; Secure; Path=/")
328+
.with_body(r#"{"data":{"users":[]}}"#)
329+
.expect(1)
330+
.create();
331+
332+
let res = router
333+
.send_graphql_request("{ users { id } }", None, None)
334+
.await;
335+
336+
assert!(res.status().is_success(), "Expected 200 OK");
337+
338+
accounts_response_mock.assert();
339+
340+
let cookies: Vec<&str> = res
341+
.headers()
342+
.get_all("set-cookie")
343+
.into_iter()
344+
.filter_map(|v| v.to_str().ok())
345+
.collect();
346+
347+
assert_eq!(
348+
cookies.len(),
349+
2,
350+
"expected both Set-Cookie values to be propagated, got {cookies:?}"
351+
);
352+
assert!(cookies.contains(&"access_token=abc; HttpOnly; Secure; Path=/"));
353+
assert!(cookies.contains(&"refresh_token=xyz; HttpOnly; Secure; Path=/"));
354+
}
355+
292356
#[ntex::test]
293357
async fn should_not_override_router_selected_content_type() {
294358
let subgraphs = TestSubgraphs::builder().build().start().await;

lib/executor/src/headers/response.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,18 @@ impl ApplyResponseHeader for ResponsePropagateNamed {
9595
continue;
9696
}
9797

98-
if let Some(header_value) = ctx.subgraph_headers.get(header_name) {
98+
if is_never_join_header(header_name) {
99+
// Never-join-headers (like `set-cookie`) can appear multiple
100+
// times in a single subgraph response, so we need to propagate them all
101+
for header_value in ctx.subgraph_headers.get_all(header_name) {
102+
matched = true;
103+
accumulator.write(
104+
self.rename.as_ref().unwrap_or(header_name),
105+
header_value,
106+
self.strategy,
107+
);
108+
}
109+
} else if let Some(header_value) = ctx.subgraph_headers.get(header_name) {
99110
matched = true;
100111
accumulator.write(
101112
self.rename.as_ref().unwrap_or(header_name),

0 commit comments

Comments
 (0)