Skip to content

Commit 0dce68b

Browse files
authored
Merge branch 'main' into task-fix-suspension
2 parents 131c5a2 + 0656757 commit 0dce68b

4 files changed

Lines changed: 121 additions & 1 deletion

File tree

app/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,7 @@ class ApiKeyPermission(StrEnum):
10811081
"""
10821082

10831083
MANAGE_TEMPLATES = "manage_templates"
1084+
MANAGE_REPORTS = "manage_reports"
10841085

10851086

10861087
API_KEY_PERMISSION_TYPES: frozenset[str] = frozenset(p.value for p in ApiKeyPermission)

app/service/callback_rest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import ipaddress
2+
from urllib.parse import urlparse
3+
14
from flask import Blueprint, jsonify, request
25
from sqlalchemy.exc import SQLAlchemyError
36

@@ -36,6 +39,7 @@
3639
def create_service_inbound_api(service_id):
3740
data = request.get_json()
3841
validate(data, create_service_callback_api_schema)
42+
_validate_not_localhost(data.get("url"))
3943
data["service_id"] = service_id
4044
inbound_api = ServiceInboundApi(**data)
4145
try:
@@ -50,6 +54,7 @@ def create_service_inbound_api(service_id):
5054
def update_service_inbound_api(service_id, inbound_api_id):
5155
data = request.get_json()
5256
validate(data, update_service_callback_api_schema)
57+
_validate_not_localhost(data.get("url"))
5358

5459
to_update = get_service_inbound_api(inbound_api_id, service_id)
5560

@@ -85,6 +90,7 @@ def remove_service_inbound_api(service_id, inbound_api_id):
8590
def create_service_callback_api(service_id):
8691
data = request.get_json()
8792
validate(data, create_service_callback_api_schema)
93+
_validate_not_localhost(data.get("url"))
8894
data["service_id"] = service_id
8995
data["callback_type"] = DELIVERY_STATUS_CALLBACK_TYPE
9096
callback_api = ServiceCallbackApi(**data)
@@ -100,6 +106,7 @@ def create_service_callback_api(service_id):
100106
def update_service_callback_api(service_id, callback_api_id):
101107
data = request.get_json()
102108
validate(data, update_service_callback_api_schema)
109+
_validate_not_localhost(data.get("url"))
103110

104111
to_update = get_service_callback_api(callback_api_id, service_id)
105112

@@ -172,3 +179,25 @@ def handle_sql_error(e, table_name):
172179
return jsonify(result="error", message="No result found"), 404
173180
else:
174181
raise e
182+
183+
184+
def _validate_not_localhost(url):
185+
"""Reject callback URLs that reference localhost or loopback addresses."""
186+
if url:
187+
try:
188+
parsed = urlparse(url)
189+
hostname = (parsed.hostname or "").rstrip(".")
190+
is_loopback = hostname == "localhost" or hostname.endswith(".localhost")
191+
if not is_loopback:
192+
try:
193+
is_loopback = ipaddress.ip_address(hostname).is_loopback
194+
except ValueError:
195+
# Hostname is not an IP literal (for example, a normal DNS name);
196+
# keep `is_loopback` as determined by localhost-name checks above.
197+
pass
198+
if is_loopback:
199+
raise InvalidRequest("Callback URL must not point to localhost", status_code=400)
200+
except InvalidRequest:
201+
raise
202+
except Exception as e:
203+
raise InvalidRequest("Invalid callback URL", status_code=400) from e

tests/app/dao/test_api_key_dao.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
update_compromised_api_key_info,
1717
update_last_used_api_key,
1818
)
19-
from app.models import KEY_TYPE_NORMAL, ApiKey
19+
from app.models import API_KEY_PERMISSION_TYPES, KEY_TYPE_NORMAL, ApiKey, ApiKeyPermission
2020
from tests.app.db import create_api_key
2121
from tests.conftest import set_signer_secret_key
2222

@@ -286,6 +286,13 @@ def test_can_persist_and_round_trip_known_permission(self, sample_service):
286286
fetched = ApiKey.query.get(api_key.id)
287287
assert fetched.permissions == ["manage_templates"]
288288

289+
def test_can_persist_and_round_trip_manage_reports_permission(self, sample_service):
290+
assert "manage_reports" in API_KEY_PERMISSION_TYPES
291+
api_key = self._make_key(sample_service, permissions=[ApiKeyPermission.MANAGE_REPORTS])
292+
fetched = ApiKey.query.get(api_key.id)
293+
assert fetched.permissions == ["manage_reports"]
294+
assert fetched.has_permission("manage_reports") is True
295+
289296
def test_unknown_permission_is_rejected(self, sample_service):
290297
with pytest.raises(ValueError, match="Invalid api key permission"):
291298
self._make_key(sample_service, permissions=["not_a_real_permission"])

tests/app/service/test_callback_rest.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
from app.models import ServiceCallbackApi, ServiceInboundApi
6+
from app.service.callback_rest import _validate_not_localhost
67
from tests.app.db import create_service_callback_api, create_service_inbound_api
78

89

@@ -217,3 +218,85 @@ def test_suspend_callback_api(self, admin_request, sample_service, suspend_unsus
217218
callback = ServiceCallbackApi.query.get(service_callback_api.id)
218219
assert callback.is_suspended is suspend_unsuspend
219220
assert callback.updated_by_id == sample_service.users[0].id
221+
222+
223+
@pytest.mark.parametrize(
224+
"url",
225+
[
226+
"https://localhost/callback",
227+
"https://localhost./callback",
228+
"https://localhost:8080/callback",
229+
"https://127.0.0.1/callback",
230+
"https://127.0.0.2/callback",
231+
"https://127.255.255.255/callback",
232+
"https://127.0.0.1:443/callback",
233+
"https://[::1]/callback",
234+
"https://[0:0:0:0:0:0:0:1]/callback",
235+
"https://sub.localhost/callback",
236+
],
237+
)
238+
def test_validate_not_localhost_rejects_localhost_urls(url):
239+
from app.errors import InvalidRequest
240+
241+
with pytest.raises(InvalidRequest):
242+
_validate_not_localhost(url)
243+
244+
245+
@pytest.mark.parametrize(
246+
"url",
247+
[
248+
"https://example.com/callback",
249+
"https://my-service.gc.ca/callback",
250+
None,
251+
],
252+
)
253+
def test_validate_not_localhost_allows_valid_urls(url):
254+
_validate_not_localhost(url) # should not raise
255+
256+
257+
@pytest.mark.parametrize(
258+
"url",
259+
[
260+
"https://localhost/callback",
261+
"https://127.0.0.1/callback",
262+
"https://127.0.0.2/callback",
263+
"https://[::1]/callback",
264+
],
265+
)
266+
def test_create_inbound_api_rejects_localhost(admin_request, sample_service, url):
267+
data = {
268+
"url": url,
269+
"bearer_token": "some-unique-string",
270+
"updated_by_id": str(sample_service.users[0].id),
271+
}
272+
resp_json = admin_request.post(
273+
"service_callback.create_service_inbound_api",
274+
service_id=sample_service.id,
275+
_data=data,
276+
_expected_status=400,
277+
)
278+
assert "localhost" in resp_json["message"].lower()
279+
280+
281+
@pytest.mark.parametrize(
282+
"url",
283+
[
284+
"https://localhost/callback",
285+
"https://127.0.0.1/callback",
286+
"https://127.0.0.2/callback",
287+
"https://[::1]/callback",
288+
],
289+
)
290+
def test_create_callback_api_rejects_localhost(admin_request, sample_service, url):
291+
data = {
292+
"url": url,
293+
"bearer_token": "some-unique-string",
294+
"updated_by_id": str(sample_service.users[0].id),
295+
}
296+
resp_json = admin_request.post(
297+
"service_callback.create_service_callback_api",
298+
service_id=sample_service.id,
299+
_data=data,
300+
_expected_status=400,
301+
)
302+
assert "localhost" in resp_json["message"].lower()

0 commit comments

Comments
 (0)