Skip to content

Commit 87c623e

Browse files
SNOW-694457: env-vars-proxy-leaking (#2451)
Co-authored-by: Berkay Öztürk <[email protected]>
1 parent d87dc0b commit 87c623e

28 files changed

+1680
-297
lines changed

DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
2828
- Fix case-sensitivity of `Oauth` and `programmatic_access_token` authenticator values.
2929
- Relaxed `pyarrow` version constraint, versions >= 19 can now be used.
3030
- Populate type_code in ResultMetadata for interval types.
31+
- Proxy setup with connection parameters added.
3132

3233
- v3.16.0(July 04,2025)
3334
- Bumped numpy dependency from <2.1.0 to <=2.2.4.

src/snowflake/connector/auth/_oauth_base.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@
2020
)
2121
from ..errors import Error, ProgrammingError
2222
from ..network import OAUTH_AUTHENTICATOR
23+
from ..proxy import get_proxy_url
2324
from ..secret_detector import SecretDetector
2425
from ..token_cache import TokenCache, TokenKey, TokenType
2526
from ..vendored import urllib3
27+
from ..vendored.requests.utils import get_environ_proxies, select_proxy
28+
from ..vendored.urllib3.poolmanager import ProxyManager
2629
from .by_plugin import AuthByPlugin, AuthType
2730

2831
if TYPE_CHECKING:
@@ -319,7 +322,13 @@ def _get_refresh_token_response(
319322
fields["scope"] = self._scope
320323
try:
321324
# TODO(SNOW-2229411) Session manager should be used here. It may require additional security validation (since we would transition from PoolManager to requests.Session) and some parameters would be passed implicitly. OAuth token exchange must NOT reuse pooled HTTP sessions. We should create a fresh SessionManager with use_pooling=False for each call.
322-
return urllib3.PoolManager().request_encode_body(
325+
proxy_url = self._resolve_proxy_url(conn, self._token_request_url)
326+
http_client = (
327+
ProxyManager(proxy_url=proxy_url)
328+
if proxy_url
329+
else urllib3.PoolManager()
330+
)
331+
return http_client.request_encode_body(
323332
"POST",
324333
self._token_request_url,
325334
encode_multipart=False,
@@ -359,7 +368,11 @@ def _get_request_token_response(
359368
fields: dict[str, str],
360369
) -> (str | None, str | None):
361370
# TODO(SNOW-2229411) Session manager should be used here. It may require additional security validation (since we would transition from PoolManager to requests.Session) and some parameters would be passed implicitly. Token request must bypass HTTP connection pools.
362-
resp = urllib3.PoolManager().request_encode_body(
371+
proxy_url = self._resolve_proxy_url(connection, self._token_request_url)
372+
http_client = (
373+
ProxyManager(proxy_url=proxy_url) if proxy_url else urllib3.PoolManager()
374+
)
375+
resp = http_client.request_encode_body(
363376
"POST",
364377
self._token_request_url,
365378
headers=self._create_token_request_headers(),
@@ -400,3 +413,25 @@ def _create_token_request_headers(self) -> dict[str, str]:
400413
"Accept": "application/json",
401414
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
402415
}
416+
417+
@staticmethod
418+
def _resolve_proxy_url(
419+
connection: SnowflakeConnection, request_url: str
420+
) -> str | None:
421+
# TODO(SNOW-2229411) Session manager should be used instead. It may require additional security validation.
422+
"""Resolve proxy URL from explicit config first, then environment variables."""
423+
# First try explicit proxy configuration from connection parameters
424+
proxy_url = get_proxy_url(
425+
connection.proxy_host,
426+
connection.proxy_port,
427+
connection.proxy_user,
428+
connection.proxy_password,
429+
)
430+
431+
if proxy_url:
432+
return proxy_url
433+
434+
# Fall back to environment variables (HTTP_PROXY, HTTPS_PROXY)
435+
# Use proper proxy selection that considers the URL scheme
436+
proxies = get_environ_proxies(request_url)
437+
return select_proxy(request_url, proxies)

src/snowflake/connector/auth/oauth_code.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ def _do_authorization_request(
269269
"login. If you can't see it, check existing browser windows, "
270270
"or your OS settings. Press CTRL+C to abort and try again..."
271271
)
272+
# TODO(SNOW-2229411) Investigate if Session manager / Http Config should be used here.
272273
code, state = (
273274
self._receive_authorization_callback(callback_server, connection)
274275
if webbrowser.open(authorization_request)

src/snowflake/connector/connection.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from cryptography.hazmat.primitives import serialization
2828
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
2929

30-
from . import errors, proxy
30+
from . import errors
3131
from ._query_context_cache import QueryContextCache
3232
from ._utils import (
3333
_DEFAULT_VALUE_SERVER_DOP_CAP_FOR_FILE_TRANSFER,
@@ -924,6 +924,10 @@ def connect(self, **kwargs) -> None:
924924
self._http_config = HttpConfig(
925925
adapter_factory=ProxySupportAdapterFactory(),
926926
use_pooling=(not self.disable_request_pooling),
927+
proxy_host=self.proxy_host,
928+
proxy_port=self.proxy_port,
929+
proxy_user=self.proxy_user,
930+
proxy_password=self.proxy_password,
927931
)
928932
self._session_manager = SessionManager(self._http_config)
929933

@@ -1125,10 +1129,6 @@ def __open_connection(self):
11251129
use_numpy=self._numpy, support_negative_year=self._support_negative_year
11261130
)
11271131

1128-
proxy.set_proxies(
1129-
self.proxy_host, self.proxy_port, self.proxy_user, self.proxy_password
1130-
)
1131-
11321132
self._rest = SnowflakeRestful(
11331133
host=self.host,
11341134
port=self.port,

src/snowflake/connector/proxy.py

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,28 @@
11
#!/usr/bin/env python
22
from __future__ import annotations
33

4-
import os
54

6-
7-
def set_proxies(
5+
def get_proxy_url(
86
proxy_host: str | None,
97
proxy_port: str | None,
108
proxy_user: str | None = None,
119
proxy_password: str | None = None,
12-
) -> dict[str, str] | None:
13-
"""Sets proxy dict for requests."""
14-
PREFIX_HTTP = "http://"
15-
PREFIX_HTTPS = "https://"
16-
proxies = None
10+
) -> str | None:
11+
http_prefix = "http://"
12+
https_prefix = "https://"
13+
1714
if proxy_host and proxy_port:
18-
if proxy_host.startswith(PREFIX_HTTP):
19-
proxy_host = proxy_host[len(PREFIX_HTTP) :]
20-
elif proxy_host.startswith(PREFIX_HTTPS):
21-
proxy_host = proxy_host[len(PREFIX_HTTPS) :]
22-
if proxy_user or proxy_password:
23-
proxy_auth = "{proxy_user}:{proxy_password}@".format(
24-
proxy_user=proxy_user if proxy_user is not None else "",
25-
proxy_password=proxy_password if proxy_password is not None else "",
26-
)
15+
if proxy_host.startswith(http_prefix):
16+
host = proxy_host[len(http_prefix) :]
17+
elif proxy_host.startswith(https_prefix):
18+
host = proxy_host[len(https_prefix) :]
2719
else:
28-
proxy_auth = ""
29-
proxies = {
30-
"http": "http://{proxy_auth}{proxy_host}:{proxy_port}".format(
31-
proxy_host=proxy_host,
32-
proxy_port=str(proxy_port),
33-
proxy_auth=proxy_auth,
34-
),
35-
"https": "http://{proxy_auth}{proxy_host}:{proxy_port}".format(
36-
proxy_host=proxy_host,
37-
proxy_port=str(proxy_port),
38-
proxy_auth=proxy_auth,
39-
),
40-
}
41-
os.environ["HTTP_PROXY"] = proxies["http"]
42-
os.environ["HTTPS_PROXY"] = proxies["https"]
43-
return proxies
20+
host = proxy_host
21+
auth = (
22+
f"{proxy_user or ''}:{proxy_password or ''}@"
23+
if proxy_user or proxy_password
24+
else ""
25+
)
26+
return f"{http_prefix}{auth}{host}:{proxy_port}"
27+
28+
return None

src/snowflake/connector/result_batch.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from .options import installed_pandas
2727
from .options import pyarrow as pa
2828
from .secret_detector import SecretDetector
29-
from .session_manager import SessionManager
29+
from .session_manager import HttpConfig, SessionManager
3030
from .time_util import TimerContextManager
3131

3232
logger = getLogger(__name__)
@@ -261,6 +261,8 @@ def __init__(
261261
[s._to_result_metadata_v1() for s in schema] if schema is not None else None
262262
)
263263
self._use_dict_result = use_dict_result
264+
# Passed to contain the configured Http behavior in case the connectio is no longer active for the download
265+
# Can be overridden with setters if needed.
264266
self._session_manager = session_manager
265267
self._metrics: dict[str, int] = {}
266268
self._data: str | list[tuple[Any, ...]] | None = None
@@ -300,6 +302,25 @@ def uncompressed_size(self) -> int | None:
300302
def column_names(self) -> list[str]:
301303
return [col.name for col in self._schema]
302304

305+
@property
306+
def session_manager(self) -> SessionManager | None:
307+
return self._session_manager
308+
309+
@session_manager.setter
310+
def session_manager(self, session_manager: SessionManager | None) -> None:
311+
self._session_manager = session_manager
312+
313+
@property
314+
def http_config(self):
315+
return self._session_manager.config
316+
317+
@http_config.setter
318+
def http_config(self, config: HttpConfig) -> None:
319+
if self._session_manager:
320+
self._session_manager.config = config
321+
else:
322+
self._session_manager = SessionManager(config=config)
323+
303324
def __iter__(
304325
self,
305326
) -> Iterator[dict | Exception] | Iterator[tuple | Exception]:

src/snowflake/connector/session_manager.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import TYPE_CHECKING, Any, Callable, Generator, Mapping
1111

1212
from .compat import urlparse
13+
from .proxy import get_proxy_url
1314
from .vendored import requests
1415
from .vendored.requests import Response, Session
1516
from .vendored.requests.adapters import BaseAdapter, HTTPAdapter
@@ -76,8 +77,15 @@ def get_connection(
7677
proxy_manager = self.proxy_manager_for(proxy)
7778

7879
if isinstance(proxy_manager, ProxyManager):
79-
# Add Host to proxy header SNOW-232777
80-
proxy_manager.proxy_headers["Host"] = parsed_url.hostname
80+
# Add Host to proxy header SNOW-232777 and SNOW-694457
81+
82+
# RFC 7230 / 5.4 – a proxy’s Host header must repeat the request authority
83+
# verbatim: <hostname>[:<port>] with IPv6 still in [brackets]. We take that
84+
# straight from urlparse(url).netloc, which preserves port and brackets (and case-sensitive hostname).
85+
# Note: netloc also keeps user-info (user:pass@host) if present in URL. The driver never sends
86+
# URLs with embedded credentials, so we leave them unhandled — for full support
87+
# we’d need to manually concatenate hostname with optional port and IPv6 brackets.
88+
proxy_manager.proxy_headers["Host"] = parsed_url.netloc
8189
else:
8290
logger.debug(
8391
f"Unable to set 'Host' to proxy manager of type {type(proxy_manager)} as"
@@ -112,6 +120,10 @@ class HttpConfig:
112120
)
113121
use_pooling: bool = True
114122
max_retries: int | None = REQUESTS_RETRY
123+
proxy_host: str | None = None
124+
proxy_port: str | None = None
125+
proxy_user: str | None = None
126+
proxy_password: str | None = None
115127

116128
def copy_with(self, **overrides: Any) -> HttpConfig:
117129
"""Return a new HttpConfig with overrides applied."""
@@ -293,13 +305,13 @@ class SessionManager(_RequestVerbsUsingSessionMixin):
293305
**Two Operating Modes**:
294306
- use_pooling=False: One-shot sessions (create, use, close) - suitable for infrequent requests
295307
- use_pooling=True: Per-hostname session pools - reuses TCP connections, avoiding handshake
296-
and SSL/TLS negotiation overhead for repeated requests to the same host
308+
and SSL/TLS negotiation overhead for repeated requests to the same host.
297309
298310
**Key Benefits**:
299311
- Centralized HTTP configuration management and easy propagation across the codebase
300312
- Consistent proxy setup (SNOW-694457) and headers customization (SNOW-2043816)
301313
- HTTPAdapter customization for connection-level request manipulation
302-
- Performance optimization through connection reuse for high-traffic scenarios
314+
- Performance optimization through connection reuse for high-traffic scenarios.
303315
304316
**Usage**: Create the base session manager, then use clone() for derived managers to ensure
305317
proper config propagation. Pre-commit checks enforce usage to prevent code drift back to
@@ -315,7 +327,6 @@ def __init__(self, config: HttpConfig | None = None, **http_config_kwargs) -> No
315327
logger.debug("Creating a config for the SessionManager")
316328
config = HttpConfig(**http_config_kwargs)
317329
self._cfg: HttpConfig = config
318-
319330
self._sessions_map: dict[str | None, SessionPool] = collections.defaultdict(
320331
lambda: SessionPool(self)
321332
)
@@ -338,6 +349,19 @@ def from_config(cls, cfg: HttpConfig, **overrides: Any) -> SessionManager:
338349
def config(self) -> HttpConfig:
339350
return self._cfg
340351

352+
@config.setter
353+
def config(self, cfg: HttpConfig) -> None:
354+
self._cfg = cfg
355+
356+
@property
357+
def proxy_url(self) -> str:
358+
return get_proxy_url(
359+
self._cfg.proxy_host,
360+
self._cfg.proxy_port,
361+
self._cfg.proxy_user,
362+
self._cfg.proxy_password,
363+
)
364+
341365
@property
342366
def use_pooling(self) -> bool:
343367
return self._cfg.use_pooling
@@ -395,6 +419,7 @@ def _mount_adapters(self, session: requests.Session) -> None:
395419
def make_session(self) -> Session:
396420
session = requests.Session()
397421
self._mount_adapters(session)
422+
session.proxies = {"http": self.proxy_url, "https": self.proxy_url}
398423
return session
399424

400425
@contextlib.contextmanager

test/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from contextlib import contextmanager
66
from logging import getLogger
77
from pathlib import Path
8+
from test.test_utils.cross_module_fixtures.http_fixtures import * # NOQA
9+
from test.test_utils.cross_module_fixtures.wiremock_fixtures import * # NOQA
810
from typing import Generator
911

1012
import pytest
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"mappings": [
3+
{
4+
"request": {
5+
"urlPathPattern": "/session/v1/login-request.*",
6+
"method": "POST",
7+
"bodyPatterns": [
8+
{
9+
"equalToJson" : {
10+
"data": {
11+
"LOGIN_NAME": "testUser",
12+
"PASSWORD": "testPassword"
13+
}
14+
},
15+
"ignoreExtraElements" : true
16+
}
17+
]
18+
},
19+
"response": {
20+
"status": 200,
21+
"jsonBody": {
22+
"data": {
23+
"masterToken": "master token",
24+
"token": "session token",
25+
"validityInSeconds": 3600,
26+
"masterValidityInSeconds": 14400,
27+
"displayUserName": "TEST_USER",
28+
"serverVersion": "8.48.0 b2024121104444034239f05",
29+
"firstLogin": false,
30+
"remMeToken": null,
31+
"remMeValidityInSeconds": 0,
32+
"healthCheckInterval": 45,
33+
"newClientForUpgrade": "3.12.3",
34+
"sessionId": 1172562260498,
35+
"parameters": [
36+
{
37+
"name": "CLIENT_PREFETCH_THREADS",
38+
"value": 4
39+
}
40+
],
41+
"sessionInfo": {
42+
"databaseName": "TEST_DB",
43+
"schemaName": "TEST_GO",
44+
"warehouseName": "TEST_XSMALL",
45+
"roleName": "ANALYST"
46+
},
47+
"idToken": null,
48+
"idTokenValidityInSeconds": 0,
49+
"responseData": null,
50+
"mfaToken": null,
51+
"mfaTokenValidityInSeconds": 0
52+
},
53+
"code": null,
54+
"message": null,
55+
"success": true
56+
}
57+
}
58+
}
59+
]
60+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"request": {
3+
"urlPattern": "/.*",
4+
"method": "ANY"
5+
},
6+
"response": {
7+
"proxyBaseUrl": "{{TARGET_HTTP_HOST_WITH_PORT}}",
8+
"additionalProxyRequestHeaders": {
9+
"Via": "1.1 wiremock-proxy"
10+
}
11+
}
12+
}

0 commit comments

Comments
 (0)