Skip to content

Commit 5261657

Browse files
authored
feat: hcaptcha service (#15141)
1 parent f37d932 commit 5261657

File tree

7 files changed

+406
-11
lines changed

7 files changed

+406
-11
lines changed

dev/environment

+9-1
Original file line numberDiff line numberDiff line change
@@ -71,5 +71,13 @@ OIDC_AUDIENCE=pypi
7171
RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
7272
RECAPTCHA_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
7373

74+
# Testing hCaptcha keys from https://docs.hcaptcha.com/#integration-testing-test-keys
75+
HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
76+
HCAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000
77+
# Test Key Set: Enterprise Account (Safe End User)
78+
# HCAPTCHA_SITE_KEY=20000000-ffff-ffff-ffff-000000000002
79+
# Test Key Set: Enterprise Account (Bot Detected)
80+
# HCAPTCHA_SITE_KEY=30000000-ffff-ffff-ffff-000000000003
81+
7482
# Example of Captcha backend configuration
75-
# CAPTCHA_BACKEND=warehouse.captcha.recaptcha.Service
83+
# CAPTCHA_BACKEND=warehouse.captcha.hcaptcha.Service

tests/unit/captcha/test_hcaptcha.py

+209
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import pretend
14+
import pytest
15+
import requests
16+
import responses
17+
18+
from warehouse.captcha import hcaptcha, interfaces
19+
20+
_REQUEST = pretend.stub(
21+
# returning a real requests.Session object because responses is responsible
22+
# for mocking that out
23+
http=requests.Session(),
24+
registry=pretend.stub(
25+
settings={
26+
"hcaptcha.site_key": "site_key_value",
27+
"hcaptcha.secret_key": "secret_key_value",
28+
},
29+
),
30+
)
31+
32+
33+
def test_create_captcha_service():
34+
service = hcaptcha.Service.create_service(
35+
context=None,
36+
request=_REQUEST,
37+
)
38+
assert isinstance(service, hcaptcha.Service)
39+
40+
41+
def test_csp_policy():
42+
csp_hostnames = ["https://hcaptcha.com", "https://*.hcaptcha.com"]
43+
service = hcaptcha.Service.create_service(
44+
context=None,
45+
request=_REQUEST,
46+
)
47+
assert service.csp_policy == {
48+
"script-src": csp_hostnames,
49+
"frame-src": csp_hostnames,
50+
"style-src": csp_hostnames,
51+
"connect-src": csp_hostnames,
52+
}
53+
54+
55+
def test_enabled():
56+
service = hcaptcha.Service.create_service(
57+
context=None,
58+
request=_REQUEST,
59+
)
60+
assert service.enabled
61+
62+
63+
class TestVerifyResponse:
64+
@responses.activate
65+
def test_verify_service_disabled(self):
66+
responses.add(
67+
responses.POST,
68+
hcaptcha.VERIFY_URL,
69+
body="",
70+
)
71+
72+
service = hcaptcha.Service.create_service(
73+
context=None,
74+
request=pretend.stub(
75+
registry=pretend.stub(
76+
settings={},
77+
),
78+
),
79+
)
80+
assert service.verify_response("") is None
81+
82+
@responses.activate
83+
def test_verify_response_success(self):
84+
responses.add(
85+
responses.POST,
86+
hcaptcha.VERIFY_URL,
87+
json={
88+
"success": True,
89+
"hostname": "hostname_value",
90+
"challenge_ts": 0,
91+
},
92+
)
93+
94+
service = hcaptcha.Service.create_service(
95+
context=None,
96+
request=_REQUEST,
97+
)
98+
assert service.verify_response("meaningless") == interfaces.ChallengeResponse(
99+
challenge_ts=0,
100+
hostname="hostname_value",
101+
)
102+
103+
@responses.activate
104+
def test_remote_ip_added(self):
105+
responses.add(
106+
responses.POST,
107+
hcaptcha.VERIFY_URL,
108+
json={"success": True},
109+
)
110+
111+
service = hcaptcha.Service.create_service(
112+
context=None,
113+
request=_REQUEST,
114+
)
115+
assert service.verify_response(
116+
"meaningless", remote_ip="someip"
117+
) == interfaces.ChallengeResponse(
118+
challenge_ts=None,
119+
hostname=None,
120+
)
121+
122+
def test_unexpected_error(self, monkeypatch):
123+
service = hcaptcha.Service.create_service(
124+
context=None,
125+
request=_REQUEST,
126+
)
127+
monkeypatch.setattr(
128+
service.request.http, "post", pretend.raiser(Exception("unexpected error"))
129+
)
130+
131+
with pytest.raises(hcaptcha.UnexpectedError) as err:
132+
service.verify_response("meaningless")
133+
134+
assert err.value.args == ("unexpected error",)
135+
136+
@responses.activate
137+
def test_unexpected_data_error(self):
138+
responses.add(
139+
responses.POST,
140+
hcaptcha.VERIFY_URL,
141+
body="something awful",
142+
)
143+
serv = hcaptcha.Service.create_service(context=None, request=_REQUEST)
144+
145+
with pytest.raises(hcaptcha.UnexpectedError) as err:
146+
serv.verify_response("meaningless")
147+
148+
expected = "Unexpected data in response body: something awful"
149+
assert str(err.value) == expected
150+
151+
@responses.activate
152+
def test_missing_success_key(self):
153+
responses.add(
154+
responses.POST,
155+
hcaptcha.VERIFY_URL,
156+
json={},
157+
)
158+
serv = hcaptcha.Service.create_service(context=None, request=_REQUEST)
159+
160+
with pytest.raises(hcaptcha.UnexpectedError) as err:
161+
serv.verify_response("meaningless")
162+
163+
expected = "Missing 'success' key in response: {}"
164+
assert str(err.value) == expected
165+
166+
@responses.activate
167+
def test_missing_error_codes_key(self):
168+
responses.add(
169+
responses.POST,
170+
hcaptcha.VERIFY_URL,
171+
json={"success": False},
172+
)
173+
serv = hcaptcha.Service.create_service(context=None, request=_REQUEST)
174+
175+
with pytest.raises(hcaptcha.UnexpectedError) as err:
176+
serv.verify_response("meaningless")
177+
178+
expected = "Response missing 'error-codes' key: {'success': False}"
179+
assert str(err.value) == expected
180+
181+
@responses.activate
182+
def test_invalid_error_code(self):
183+
responses.add(
184+
responses.POST,
185+
hcaptcha.VERIFY_URL,
186+
json={"success": False, "error_codes": ["foo"]},
187+
)
188+
serv = hcaptcha.Service.create_service(context=None, request=_REQUEST)
189+
190+
with pytest.raises(hcaptcha.UnexpectedError) as err:
191+
serv.verify_response("meaningless")
192+
193+
expected = "Unexpected error code: foo"
194+
assert str(err.value) == expected
195+
196+
@responses.activate
197+
def test_valid_error_code(self):
198+
responses.add(
199+
responses.POST,
200+
hcaptcha.VERIFY_URL,
201+
json={
202+
"success": False,
203+
"error_codes": ["invalid-or-already-seen-response"],
204+
},
205+
)
206+
serv = hcaptcha.Service.create_service(context=None, request=_REQUEST)
207+
208+
with pytest.raises(hcaptcha.InvalidOrAlreadySeenResponseError):
209+
serv.verify_response("meaningless")

warehouse/captcha/hcaptcha.py

+173
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
import http
14+
15+
from urllib.parse import urlencode
16+
17+
from zope.interface import implementer
18+
19+
from .interfaces import ChallengeResponse, ICaptchaService
20+
21+
VERIFY_URL = "https://api.hcaptcha.com/siteverify"
22+
23+
24+
class HCaptchaError(ValueError):
25+
pass
26+
27+
28+
class MissingInputSecretError(HCaptchaError):
29+
pass
30+
31+
32+
class InvalidInputSecretError(HCaptchaError):
33+
pass
34+
35+
36+
class MissingInputResponseError(HCaptchaError):
37+
pass
38+
39+
40+
class InvalidInputResponseError(HCaptchaError):
41+
pass
42+
43+
44+
class BadRequestError(HCaptchaError):
45+
pass
46+
47+
48+
class InvalidOrAlreadySeenResponseError(HCaptchaError):
49+
pass
50+
51+
52+
class NotUsingDummyPasscodeError(HCaptchaError):
53+
pass
54+
55+
56+
class SitekeySecretMismatchError(HCaptchaError):
57+
pass
58+
59+
60+
class UnexpectedError(HCaptchaError):
61+
pass
62+
63+
64+
# https://docs.hcaptcha.com/#siteverify-error-codes-table
65+
ERROR_CODE_MAP = {
66+
"missing-input-secret": MissingInputSecretError,
67+
"invalid-input-secret": InvalidInputSecretError,
68+
"missing-input-response": MissingInputResponseError,
69+
"invalid-input-response": InvalidInputResponseError,
70+
"invalid-or-already-seen-response": InvalidOrAlreadySeenResponseError,
71+
"not-using-dummy-passcode": NotUsingDummyPasscodeError,
72+
"sitekey-secret-mismatch": SitekeySecretMismatchError,
73+
"bad-request": BadRequestError,
74+
}
75+
76+
77+
_CSP_ENTRIES = [
78+
"https://hcaptcha.com",
79+
"https://*.hcaptcha.com",
80+
]
81+
82+
83+
@implementer(ICaptchaService)
84+
class Service:
85+
def __init__(self, *, request, script_src_url, site_key, secret_key):
86+
self.request = request
87+
self.script_src_url = script_src_url
88+
self.site_key = site_key
89+
self.secret_key = secret_key
90+
self.class_name = "h-captcha"
91+
92+
@classmethod
93+
def create_service(cls, context, request) -> "Service":
94+
return cls(
95+
request=request,
96+
script_src_url="https://js.hcaptcha.com/1/api.js",
97+
site_key=request.registry.settings.get("hcaptcha.site_key"),
98+
secret_key=request.registry.settings.get("hcaptcha.secret_key"),
99+
)
100+
101+
@property
102+
def csp_policy(self) -> dict[str, list[str]]:
103+
return {
104+
"script-src": _CSP_ENTRIES,
105+
"frame-src": _CSP_ENTRIES,
106+
"style-src": _CSP_ENTRIES,
107+
"connect-src": _CSP_ENTRIES,
108+
}
109+
110+
@property
111+
def enabled(self) -> bool:
112+
return bool(self.site_key and self.secret_key)
113+
114+
def verify_response(self, response, remote_ip=None) -> ChallengeResponse | None:
115+
if not self.enabled:
116+
# TODO: debug logging
117+
return None
118+
119+
payload = {
120+
"secret": self.secret_key,
121+
"response": response,
122+
}
123+
if remote_ip is not None:
124+
payload["remoteip"] = remote_ip
125+
126+
try:
127+
# TODO: the timeout is hardcoded for now. it would be nice to do
128+
# something a little more generalized in the future.
129+
resp = self.request.http.post(
130+
VERIFY_URL,
131+
urlencode(payload),
132+
headers={
133+
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
134+
},
135+
timeout=10,
136+
)
137+
except Exception as err:
138+
raise UnexpectedError(str(err)) from err
139+
140+
try:
141+
data = resp.json()
142+
except ValueError as e:
143+
raise UnexpectedError(
144+
f'Unexpected data in response body: {str(resp.content, "utf-8")}'
145+
) from e
146+
147+
if "success" not in data:
148+
raise UnexpectedError(f"Missing 'success' key in response: {data}")
149+
150+
if resp.status_code != http.HTTPStatus.OK or not data["success"]:
151+
try:
152+
error_codes = data["error_codes"]
153+
except KeyError as e:
154+
raise UnexpectedError(
155+
f"Response missing 'error-codes' key: {data}"
156+
) from e
157+
try:
158+
exc_tp = ERROR_CODE_MAP[error_codes[0]]
159+
except KeyError as exc:
160+
raise UnexpectedError(
161+
f"Unexpected error code: {error_codes[0]}"
162+
) from exc
163+
raise exc_tp
164+
165+
# challenge_ts = timestamp of the challenge load
166+
# (ISO format yyyy-MM-dd'T'HH:mm:ssZZ)
167+
# TODO: maybe run some validation against the hostname and timestamp?
168+
# TODO: log if either field is empty.. it shouldn't cause a failure,
169+
# but it likely means that google has changed their response structure
170+
return ChallengeResponse(
171+
data.get("challenge_ts"),
172+
data.get("hostname"),
173+
)

0 commit comments

Comments
 (0)