Skip to content

Commit 2300818

Browse files
committed
fix(security): fix cookies leakage between different users
- fix security vulnerabilities of cookies leakage between different users Security-advisories: GHSA-7vwr-g6pm-9hc8
1 parent af1e85b commit 2300818

File tree

5 files changed

+122
-37
lines changed

5 files changed

+122
-37
lines changed

src/fastapi_proxy_lib/core/_tool.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
import httpx
2020
from starlette import status
2121
from starlette.background import BackgroundTask as BackgroundTask_t
22+
from starlette.datastructures import (
23+
Headers as StarletteHeaders,
24+
)
25+
from starlette.datastructures import (
26+
MutableHeaders as StarletteMutableHeaders,
27+
)
2228
from starlette.responses import JSONResponse
2329
from starlette.types import Scope
2430
from typing_extensions import deprecated, overload
@@ -424,3 +430,37 @@ def warn_for_none_filter(
424430
return default_proxy_filter
425431
else:
426432
return proxy_filter
433+
434+
435+
def change_necessary_client_header_for_httpx(
436+
*, headers: StarletteHeaders, target_url: httpx.URL
437+
) -> StarletteMutableHeaders:
438+
"""Change client request headers for sending to proxy server.
439+
440+
- Change "host" header to `target_url.netloc.decode("ascii")`.
441+
- If "Cookie" header is not in headers,
442+
will forcibly add a empty "Cookie" header
443+
to avoid httpx.AsyncClient automatically add another user cookiejar.
444+
445+
Args:
446+
headers: original client request headers.
447+
target_url: httpx.URL of target server url.
448+
449+
Returns:
450+
New requests headers, the copy of original input headers.
451+
"""
452+
# https://www.starlette.io/requests/#headers
453+
new_headers = headers.mutablecopy()
454+
455+
# 将host字段更新为目标url的host
456+
# TODO: 如果查看httpx.URL源码,就会发现netloc是被字符串编码成bytes的,能否想个办法直接获取字符串来提高性能?
457+
new_headers["host"] = target_url.netloc.decode("ascii")
458+
459+
# https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Cookie
460+
461+
# FIX: https://github.com/WSH032/fastapi-proxy-lib/security/advisories/GHSA-7vwr-g6pm-9hc8
462+
# forcibly set `Cookie` header to avoid httpx.AsyncClient automatically add another user cookiejar
463+
if "Cookie" not in new_headers: # case-insensitive
464+
new_headers["Cookie"] = ""
465+
466+
return new_headers

src/fastapi_proxy_lib/core/http.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from ._tool import (
3030
ProxyFilterProto,
3131
_RejectedProxyRequestError, # pyright: ignore [reportPrivateUsage] # 允许使用本项目内部的私有成员
32+
change_necessary_client_header_for_httpx,
3233
check_base_url,
3334
check_http_version,
3435
return_err_msg_response,
@@ -121,9 +122,12 @@ def _change_client_header(
121122
) -> _ConnectionHeaderParseResult:
122123
"""Change client request headers for sending to proxy server.
123124
125+
- Change "host" header to `target_url.netloc.decode("ascii")`.
126+
- If "Cookie" header is not in headers,
127+
will forcibly add a empty "Cookie" header
128+
to avoid httpx.AsyncClient automatically add another user cookiejar.
124129
- Will remove "close" value in "connection" header, and add "keep-alive" value to it.
125130
- And remove "keep-alive" header.
126-
- And change "host" header to `target_url.netloc.decode("ascii")`.
127131
128132
Args:
129133
headers: original client request headers.
@@ -135,9 +139,12 @@ def _change_client_header(
135139
new_headers: New requests headers, the **copy** of original input headers.
136140
"""
137141
# https://www.starlette.io/requests/#headers
138-
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection#syntax
139-
new_headers = headers.mutablecopy()
140142

143+
new_headers = change_necessary_client_header_for_httpx(
144+
headers=headers, target_url=target_url
145+
)
146+
147+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection#syntax
141148
# NOTE: http标准中规定,connecttion头字段的值用于指示逐段头,而标头是大小写不敏感的,故认为可以转为小写处理
142149
client_connection_header = [
143150
v.strip() for v in new_headers.get("connection", "").lower().split(",")
@@ -160,10 +167,6 @@ def _change_client_header(
160167
if "keep-alive" in new_headers:
161168
del new_headers["keep-alive"]
162169

163-
# 将host字段更新为目标url的host
164-
# TODO: 如果查看httpx.URL源码,就会发现netloc是被字符串编码成bytes的,能否想个办法直接获取字符串来提高性能?
165-
new_headers["host"] = target_url.netloc.decode("ascii")
166-
167170
return _ConnectionHeaderParseResult(whether_require_close, new_headers)
168171

169172

@@ -251,6 +254,12 @@ async def send_request_to_target( # pyright: ignore [reportIncompatibleMethodOv
251254
None if request.method in _NON_REQUEST_BODY_METHODS else request.stream()
252255
)
253256

257+
# FIX: https://github.com/WSH032/fastapi-proxy-lib/security/advisories/GHSA-7vwr-g6pm-9hc8
258+
# time cost: 396 ns ± 3.39 ns
259+
# 由于这不是原子性的操作,所以不保证一定阻止cookie泄漏
260+
# 一定能保证修复的方法是通过`_tool.change_necessary_client_header_for_httpx`强制指定优先级最高的cookie头
261+
client.cookies.clear()
262+
254263
# NOTE: 不要在这里catch `client.build_request` 和 `client.send` 的异常,因为通常来说
255264
# - 反向代理的异常需要报 5xx 错误
256265
# - 而正向代理的异常需要报 4xx 错误

src/fastapi_proxy_lib/core/websocket.py

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,6 @@
2424
DEFAULT_QUEUE_SIZE,
2525
)
2626
from starlette import status as starlette_status
27-
from starlette.datastructures import (
28-
Headers as StarletteHeaders,
29-
)
30-
from starlette.datastructures import (
31-
MutableHeaders as StarletteMutableHeaders,
32-
)
3327
from starlette.exceptions import WebSocketException as StarletteWebSocketException
3428
from starlette.responses import Response as StarletteResponse
3529
from starlette.responses import StreamingResponse
@@ -40,6 +34,7 @@
4034

4135
from ._model import BaseProxyModel
4236
from ._tool import (
37+
change_necessary_client_header_for_httpx,
4338
check_base_url,
4439
check_http_version,
4540
)
@@ -82,29 +77,7 @@ class _ClientServerProxyTask(NamedTuple):
8277
#################### Tools function ####################
8378

8479

85-
def _change_client_header(
86-
*, headers: StarletteHeaders, target_url: httpx.URL
87-
) -> StarletteMutableHeaders:
88-
"""Change client request headers for sending to proxy server.
89-
90-
- Change "host" header to `target_url.netloc.decode("ascii")`.
91-
92-
Args:
93-
headers: original client request headers.
94-
target_url: httpx.URL of target server url.
95-
96-
Returns:
97-
New requests headers, the copy of original input headers.
98-
"""
99-
# https://www.starlette.io/requests/#headers
100-
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection#syntax
101-
new_headers = headers.mutablecopy()
102-
103-
# 将host字段更新为目标url的host
104-
# TODO: 如果查看httpx.URL源码,就会发现netloc是被字符串编码成bytes的,能否想个办法直接获取字符串来提高性能?
105-
new_headers["host"] = target_url.netloc.decode("ascii")
106-
107-
return new_headers
80+
_change_client_header = change_necessary_client_header_for_httpx
10881

10982

11083
def _get_client_request_subprotocols(ws_scope: Scope) -> Union[List[str], None]:
@@ -540,6 +513,12 @@ async def send_request_to_target( # pyright: ignore [reportIncompatibleMethodOv
540513
# https://docs.python.org/3.12/library/contextlib.html?highlight=asyncexitstack#catching-exceptions-from-enter-methods
541514
stack = AsyncExitStack()
542515
try:
516+
# FIX: https://github.com/WSH032/fastapi-proxy-lib/security/advisories/GHSA-7vwr-g6pm-9hc8
517+
# time cost: 396 ns ± 3.39 ns
518+
# 由于这不是原子性的操作,所以不保证一定阻止cookie泄漏
519+
# 一定能保证修复的方法是通过`_tool.change_necessary_client_header_for_httpx`强制指定优先级最高的cookie头
520+
client.cookies.clear()
521+
543522
proxy_ws = await stack.enter_async_context(
544523
httpx_ws.aconnect_ws(
545524
# 这个是httpx_ws类型注解的问题,其实是可以使用httpx.URL的

tests/app/echo_http_app.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
DEFAULT_FILE_NAME = "echo.txt"
1313

1414

15-
def get_app() -> AppDataclass4Test:
15+
def get_app() -> AppDataclass4Test: # noqa: C901
1616
"""Get the echo http app.
1717
1818
Returns:
@@ -83,6 +83,28 @@ async def echo_headers_and_params(
8383
}
8484
return msg
8585

86+
@app.get("/get/cookies")
87+
async def cookies(
88+
request: Request,
89+
) -> Mapping[str, str]:
90+
"""Returns cookie of request."""
91+
nonlocal test_app_dataclass
92+
test_app_dataclass.request_dict["request"] = request
93+
return request.cookies
94+
95+
@app.get("/get/cookies/set/{key}/{value}")
96+
async def cookies_set(
97+
request: Request,
98+
key: str,
99+
value: str,
100+
) -> Response:
101+
"""Returns a response which requires client to set cookie: `key=value`."""
102+
nonlocal test_app_dataclass
103+
test_app_dataclass.request_dict["request"] = request
104+
response = Response()
105+
response.set_cookie(key=key, value=value)
106+
return response
107+
86108
@app.post("/post/echo_body")
87109
async def echo_body(request: Request) -> Response:
88110
"""Http post method endpoint for echo body.

tests/test_http.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,41 @@ async def test_bad_url_request(
205205
assert r.status_code == 502
206206
check_if_err_resp_is_from_px_serv(r)
207207

208+
@pytest.mark.anyio()
209+
async def test_cookie_leakage(
210+
self,
211+
tool_4_test_fixture: Tool4TestFixture,
212+
) -> None:
213+
"""Testing for fixing cookie leakage vulnerabilities."""
214+
client_for_conn_to_proxy_server = (
215+
tool_4_test_fixture.client_for_conn_to_proxy_server
216+
)
217+
proxy_server_base_url = tool_4_test_fixture.proxy_server_base_url
218+
219+
# request to set cookie: foo=bar
220+
await client_for_conn_to_proxy_server.get(
221+
proxy_server_base_url + "get/cookies/set/foo/bar"
222+
)
223+
# check if cookie is set
224+
assert client_for_conn_to_proxy_server.cookies["foo"] == "bar"
225+
r = await client_for_conn_to_proxy_server.get(
226+
proxy_server_base_url + "get/cookies"
227+
)
228+
assert r.json()["foo"] == "bar"
229+
230+
# Then simulate the access of another user's client by clearing cookiejar
231+
client_for_conn_to_proxy_server.cookies.clear()
232+
# check if cookiejar is cleared
233+
assert not client_for_conn_to_proxy_server.cookies
234+
235+
# check if cookie is not leaked
236+
r = await client_for_conn_to_proxy_server.get(
237+
proxy_server_base_url + "get/cookies",
238+
cookies={"a": "b"},
239+
)
240+
assert "foo" not in r.json() # not leaked
241+
assert r.json()["a"] == "b" # send cookies normally
242+
208243

209244
class TestForwardHttpProxy(AbstractTestProxy):
210245
"""For testing forward http proxy."""

0 commit comments

Comments
 (0)