Skip to content

Commit eba57c1

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

27 files changed

+1679
-297
lines changed

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, Generic, Mapping, TypeVar
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
@@ -79,8 +80,15 @@ def get_connection(
7980
proxy_manager = self.proxy_manager_for(proxy)
8081

8182
if isinstance(proxy_manager, ProxyManager):
82-
# Add Host to proxy header SNOW-232777
83-
proxy_manager.proxy_headers["Host"] = parsed_url.hostname
83+
# Add Host to proxy header SNOW-232777 and SNOW-694457
84+
85+
# RFC 7230 / 5.4 – a proxy’s Host header must repeat the request authority
86+
# verbatim: <hostname>[:<port>] with IPv6 still in [brackets]. We take that
87+
# straight from urlparse(url).netloc, which preserves port and brackets (and case-sensitive hostname).
88+
# Note: netloc also keeps user-info (user:pass@host) if present in URL. The driver never sends
89+
# URLs with embedded credentials, so we leave them unhandled — for full support
90+
# we’d need to manually concatenate hostname with optional port and IPv6 brackets.
91+
proxy_manager.proxy_headers["Host"] = parsed_url.netloc
8492
else:
8593
logger.debug(
8694
f"Unable to set 'Host' to proxy manager of type {type(proxy_manager)} as"
@@ -112,6 +120,10 @@ class BaseHttpConfig:
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) -> BaseHttpConfig:
117129
"""Return a new config with overrides applied."""
@@ -325,13 +337,13 @@ class SessionManager(_RequestVerbsUsingSessionMixin):
325337
**Two Operating Modes**:
326338
- use_pooling=False: One-shot sessions (create, use, close) - suitable for infrequent requests
327339
- use_pooling=True: Per-hostname session pools - reuses TCP connections, avoiding handshake
328-
and SSL/TLS negotiation overhead for repeated requests to the same host
340+
and SSL/TLS negotiation overhead for repeated requests to the same host.
329341
330342
**Key Benefits**:
331343
- Centralized HTTP configuration management and easy propagation across the codebase
332344
- Consistent proxy setup (SNOW-694457) and headers customization (SNOW-2043816)
333345
- HTTPAdapter customization for connection-level request manipulation
334-
- Performance optimization through connection reuse for high-traffic scenarios
346+
- Performance optimization through connection reuse for high-traffic scenarios.
335347
336348
**Usage**: Create the base session manager, then use clone() for derived managers to ensure
337349
proper config propagation. Pre-commit checks enforce usage to prevent code drift back to
@@ -347,7 +359,6 @@ def __init__(self, config: HttpConfig | None = None, **http_config_kwargs) -> No
347359
logger.debug("Creating a config for the SessionManager")
348360
config = HttpConfig(**http_config_kwargs)
349361
self._cfg: HttpConfig = config
350-
351362
self._sessions_map: dict[str | None, SessionPool] = collections.defaultdict(
352363
lambda: SessionPool(self)
353364
)
@@ -370,6 +381,19 @@ def from_config(cls, cfg: HttpConfig, **overrides: Any) -> SessionManager:
370381
def config(self) -> HttpConfig:
371382
return self._cfg
372383

384+
@config.setter
385+
def config(self, cfg: HttpConfig) -> None:
386+
self._cfg = cfg
387+
388+
@property
389+
def proxy_url(self) -> str:
390+
return get_proxy_url(
391+
self._cfg.proxy_host,
392+
self._cfg.proxy_port,
393+
self._cfg.proxy_user,
394+
self._cfg.proxy_password,
395+
)
396+
373397
@property
374398
def use_pooling(self) -> bool:
375399
return self._cfg.use_pooling
@@ -427,6 +451,7 @@ def _mount_adapters(self, session: requests.Session) -> None:
427451
def make_session(self) -> Session:
428452
session = requests.Session()
429453
self._mount_adapters(session)
454+
session.proxies = {"http": self.proxy_url, "https": self.proxy_url}
430455
return session
431456

432457
@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)