Skip to content

Commit 0752047

Browse files
ewdurbinclaude
andauthored
Proxy protection (#18427)
* Add proxy protection to detect and warn about untrusted domains - Add WAREHOUSE_ALLOWED_DOMAINS configuration variable to specify trusted domains - Create JavaScript proxy detection that checks if current domain is allowed - Display full-page red warning overlay for untrusted domains - Show persistent banner after user dismisses the overlay - Add tests for allowed domains configuration parsing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Add domain verification with request nonces to proxy protection Enhance proxy protection by hashing allowed domains with per-request nonces to prevent simple proxy rewriting of the allowlist. This makes it more difficult for malicious proxies to bypass domain verification by tampering with the allowed domains list. Security improvement includes: - Generate cryptographically secure nonce for each request - Hash allowed domains using HMAC with nonce as key - Verify domains client-side using Web Crypto API - Prevent plaintext domain list exposure in HTML Note: This is not a bulletproof security solution but raises the bar for attackers by requiring active request interception and computation rather than simple string replacement. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Make hostname in proxy warning modal more visible - Switch from innerHTML to createElement for better style control - Increase domain font size to 72px with bold weight (900) - Use light red color (#ff6b6b) for better contrast - Reduce vertical margin to 10px for tighter layout - Apply monospace font for clear domain display 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * lint/translations * Fix tests for proxy protection configuration - Add warehouse.allowed_domains to expected settings in config tests - Add .request module to expected includes list - Ensures all tests pass with new proxy protection feature 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Simplify proxy protection to always warn on unmatched domains - Remove subdomain matching logic - only exact domain matches are allowed - Always show warning unless domain explicitly matches configured list - Default to showing warning if data elements are missing or invalid - Remove special case for empty domain list This ensures maximum security by always warning users unless they are on an explicitly allowed domain. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * include localhost/127.0.0.1 in dev/environment * Move proxy protection initialization to index.js - Remove docReady import and call from proxy-protection.js - Export checkProxyProtection function for use in index.js - Import and initialize with docReady in index.js - Follows same pattern as other utility modules like WebAuthn This centralizes all docReady calls in index.js and makes the module structure more consistent. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c82e977 commit 0752047

File tree

9 files changed

+438
-54
lines changed

9 files changed

+438
-54
lines changed

dev/environment

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,5 @@ HELPDESK_NOTIFICATION_BACKEND="warehouse.helpdesk.services.ConsoleAdminNotificat
9494

9595
# Example of Domain Status configuration
9696
# DOMAIN_STATUS_BACKEND="warehouse.accounts.services.DomainrDomainStatusService client_id=some_client_id"
97+
98+
WAREHOUSE_ALLOWED_DOMAINS=127.0.0.1,localhost

tests/unit/test_config.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ def __init__(self):
333333
"gcloud.service_account_info": {},
334334
"warehouse.forklift.legacy.MAX_FILESIZE_MIB": 100,
335335
"warehouse.forklift.legacy.MAX_PROJECT_SIZE_GIB": 10,
336+
"warehouse.allowed_domains": [],
336337
}
337338
if environment == config.Environment.development:
338339
expected_settings.update(
@@ -394,6 +395,7 @@ def __init__(self):
394395
]
395396
+ [
396397
pretend.call(".logging"),
398+
pretend.call(".request"),
397399
pretend.call("pyramid_jinja2"),
398400
pretend.call(".filters"),
399401
pretend.call("pyramid_mailer"),
@@ -670,3 +672,34 @@ def test_root_factory_access_control_list():
670672
),
671673
),
672674
]
675+
676+
677+
class TestWarehouseAllowedDomains:
678+
def test_allowed_domains_parsing(self):
679+
"""Test that allowed domains are parsed correctly."""
680+
681+
# Test the lambda function used in maybe_set
682+
def parser(s):
683+
return [d.strip() for d in s.split(",") if d.strip()]
684+
685+
# Test normal case
686+
assert parser("pypi.org, test.pypi.org, example.com") == [
687+
"pypi.org",
688+
"test.pypi.org",
689+
"example.com",
690+
]
691+
692+
# Test with empty strings
693+
assert parser("pypi.org,,, test.pypi.org, ") == ["pypi.org", "test.pypi.org"]
694+
695+
# Test with only commas
696+
assert parser(",,,") == []
697+
698+
# Test single domain
699+
assert parser("pypi.org") == ["pypi.org"]
700+
701+
# Test with extra spaces
702+
assert parser(" pypi.org , test.pypi.org ") == [
703+
"pypi.org",
704+
"test.pypi.org",
705+
]

tests/unit/test_request.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
3+
import pretend
4+
5+
from warehouse import request
6+
7+
8+
class TestCreateNonce:
9+
def test_generates_unique_nonces(self):
10+
"""Test that each request gets a unique nonce."""
11+
req1 = pretend.stub()
12+
req2 = pretend.stub()
13+
14+
nonce1 = request._create_nonce(req1)
15+
nonce2 = request._create_nonce(req2)
16+
17+
# Nonces should be strings
18+
assert isinstance(nonce1, str)
19+
assert isinstance(nonce2, str)
20+
21+
# Nonces should have reasonable length (base64url encoded 32 bytes)
22+
assert len(nonce1) >= 32
23+
assert len(nonce2) >= 32
24+
25+
# Nonces should be unique
26+
assert nonce1 != nonce2
27+
28+
def test_nonce_is_url_safe(self):
29+
"""Test that nonces are URL-safe."""
30+
req = pretend.stub()
31+
nonce = request._create_nonce(req)
32+
33+
# Should only contain URL-safe characters
34+
# base64url uses A-Z, a-z, 0-9, -, _
35+
import re
36+
37+
assert re.match(r"^[A-Za-z0-9_-]+$", nonce)
38+
39+
40+
class TestCreateHashedDomains:
41+
def test_hashes_domains_with_nonce(self):
42+
"""Test that domains are hashed using the nonce."""
43+
req = pretend.stub(
44+
nonce="test-nonce-123",
45+
registry=pretend.stub(
46+
settings={"warehouse.allowed_domains": ["pypi.org", "test.pypi.org"]}
47+
),
48+
)
49+
50+
hashed = request._create_hashed_domains(req)
51+
52+
# Should return comma-separated list
53+
assert "," in hashed
54+
hashes = hashed.split(",")
55+
assert len(hashes) == 2
56+
57+
# Each hash should be 64 chars (sha256 hex)
58+
for h in hashes:
59+
assert len(h) == 64
60+
assert all(c in "0123456789abcdef" for c in h)
61+
62+
# Hashes should be different for different domains
63+
assert hashes[0] != hashes[1]
64+
65+
def test_different_nonce_produces_different_hashes(self):
66+
"""Test that different nonces produce different hashes for same domain."""
67+
req1 = pretend.stub(
68+
nonce="nonce-1",
69+
registry=pretend.stub(settings={"warehouse.allowed_domains": ["pypi.org"]}),
70+
)
71+
req2 = pretend.stub(
72+
nonce="nonce-2",
73+
registry=pretend.stub(settings={"warehouse.allowed_domains": ["pypi.org"]}),
74+
)
75+
76+
hashed1 = request._create_hashed_domains(req1)
77+
hashed2 = request._create_hashed_domains(req2)
78+
79+
assert hashed1 != hashed2
80+
81+
def test_empty_domains_returns_empty_string(self):
82+
"""Test that empty domain list returns empty string."""
83+
req = pretend.stub(
84+
nonce="test-nonce",
85+
registry=pretend.stub(settings={"warehouse.allowed_domains": []}),
86+
)
87+
88+
hashed = request._create_hashed_domains(req)
89+
assert hashed == ""
90+
91+
def test_no_domains_setting_returns_empty_string(self):
92+
"""Test that missing domains setting returns empty string."""
93+
req = pretend.stub(nonce="test-nonce", registry=pretend.stub(settings={}))
94+
95+
hashed = request._create_hashed_domains(req)
96+
assert hashed == ""

warehouse/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,13 @@ def configure(settings=None):
341341
maybe_set(settings, "warehouse.ip_salt", "WAREHOUSE_IP_SALT")
342342
maybe_set(settings, "warehouse.num_proxies", "WAREHOUSE_NUM_PROXIES", int)
343343
maybe_set(settings, "warehouse.domain", "WAREHOUSE_DOMAIN")
344+
maybe_set(
345+
settings,
346+
"warehouse.allowed_domains",
347+
"WAREHOUSE_ALLOWED_DOMAINS",
348+
lambda s: [d.strip() for d in s.split(",") if d.strip()],
349+
default=[],
350+
)
344351
maybe_set(settings, "forklift.domain", "FORKLIFT_DOMAIN")
345352
maybe_set(settings, "auth.domain", "AUTH_DOMAIN")
346353
maybe_set(
@@ -636,6 +643,9 @@ def configure(settings=None):
636643
# Register our logging support
637644
config.include(".logging")
638645

646+
# Register request utilities (nonce, etc.)
647+
config.include(".request")
648+
639649
# We'll want to use Jinja2 as our template system.
640650
config.include("pyramid_jinja2")
641651

0 commit comments

Comments
 (0)