Skip to content

Commit 8d35a40

Browse files
committed
fix(spp_api_v2): add payload masking and URL sanitization to outgoing API log
- Add groups restriction to url field and XML view to limit access to auditors - Add _sanitize_url method to strip sensitive query parameters before logging - Call _sanitize_url in log_call so stored URLs never contain tokens or keys - Add tests covering _sanitize_url and end-to-end URL sanitization in log_call
1 parent 01b4d79 commit 8d35a40

File tree

5 files changed

+297
-14
lines changed

5 files changed

+297
-14
lines changed

spp_api_v2/models/api_outgoing_log.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class ApiOutgoingLog(models.Model):
2727
url = fields.Char(
2828
required=True,
2929
index=True,
30-
help="Full URL called",
30+
groups="spp_api_v2.group_api_v2_auditor",
31+
help="Full URL called (may contain sensitive query parameters)",
3132
)
3233

3334
endpoint = fields.Char(

spp_api_v2/services/outgoing_api_log_service.py

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import json
55
import logging
6+
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
67

78
import psycopg2
89

@@ -55,6 +56,26 @@ def __init__(
5556
self.service_code = service_code
5657
self.user_id = user_id or env.uid
5758

59+
# Sensitive key patterns matched case-insensitively
60+
SENSITIVE_KEYS = frozenset(
61+
{
62+
"authorization",
63+
"password",
64+
"token",
65+
"access_token",
66+
"refresh_token",
67+
"api_key",
68+
"apikey",
69+
"secret",
70+
"client_secret",
71+
"credential",
72+
"credentials",
73+
"private_key",
74+
}
75+
)
76+
77+
MASK_VALUE = "***MASKED***"
78+
5879
def log_call(
5980
self,
6081
url: str,
@@ -76,15 +97,21 @@ def log_call(
7697
Logging failures never raise exceptions.
7798
"""
7899
try:
79-
# Truncate large payloads
80-
truncated_request = self._truncate_payload(request_summary)
81-
truncated_response = self._truncate_payload(response_summary)
82-
100+
# Sanitize URL, mask sensitive keys, then truncate large payloads
101+
safe_url = self._sanitize_url(url)
102+
masked_request = self._mask_sensitive_keys(request_summary)
103+
masked_response = self._mask_sensitive_keys(response_summary)
104+
truncated_request = self._truncate_payload(masked_request)
105+
truncated_response = self._truncate_payload(masked_response)
106+
107+
# sudo() is intentional: log records must be created regardless of
108+
# the calling user's permissions on spp.api.outgoing.log. The
109+
# service is an internal component, not a user-facing API.
83110
return (
84111
self.env["spp.api.outgoing.log"]
85112
.sudo()
86113
.log_call(
87-
url=url,
114+
url=safe_url,
88115
endpoint=endpoint,
89116
http_method=http_method,
90117
request_summary=truncated_request,
@@ -107,6 +134,66 @@ def log_call(
107134
_logger.exception("Failed to log outgoing API call")
108135
return None
109136

137+
def _mask_sensitive_keys(self, payload):
138+
"""Recursively mask values of known sensitive keys in a payload.
139+
140+
Args:
141+
payload: Dict payload to mask, or None.
142+
143+
Returns:
144+
A new dict with sensitive values replaced by MASK_VALUE,
145+
or None if input is None.
146+
"""
147+
if payload is None:
148+
return None
149+
150+
return self._mask_recursive(payload)
151+
152+
def _mask_recursive(self, obj):
153+
"""Walk a structure and mask sensitive dict keys."""
154+
if isinstance(obj, dict):
155+
return {
156+
key: (self.MASK_VALUE if key.lower() in self.SENSITIVE_KEYS else self._mask_recursive(value))
157+
for key, value in obj.items()
158+
}
159+
if isinstance(obj, list):
160+
return [self._mask_recursive(item) for item in obj]
161+
return obj
162+
163+
def _sanitize_url(self, url):
164+
"""Remove sensitive query parameters from a URL before logging.
165+
166+
Query parameters whose names match SENSITIVE_KEYS (case-insensitively)
167+
are replaced with MASK_VALUE so that API keys or tokens embedded in
168+
query strings are never persisted.
169+
170+
Args:
171+
url: URL string to sanitize.
172+
173+
Returns:
174+
Sanitized URL string with sensitive query parameters masked,
175+
or the original value if it cannot be parsed as a URL.
176+
"""
177+
if not url:
178+
return url
179+
180+
try:
181+
parsed = urlparse(url)
182+
except Exception:
183+
return url
184+
185+
if not parsed.query:
186+
return url
187+
188+
params = parse_qs(parsed.query, keep_blank_values=True)
189+
sanitized_params = {
190+
key: ([self.MASK_VALUE] if key.lower() in self.SENSITIVE_KEYS else values) for key, values in params.items()
191+
}
192+
193+
sanitized_query = urlencode(sanitized_params, doseq=True)
194+
sanitized = parsed._replace(query=sanitized_query)
195+
return urlunparse(sanitized)
196+
110197
def _truncate_payload(self, payload, max_length=10000):
111198
"""Truncate large payloads for DB storage.
112199

spp_api_v2/tests/test_api_outgoing_log.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,9 @@ def setUpClass(cls):
266266
)
267267

268268
def test_auditor_can_read_sensitive_fields(self):
269-
"""User with auditor group can read request_summary, response_summary, error_detail"""
269+
"""User with auditor group can read url, request_summary, response_summary, error_detail"""
270270
log = self.log_record.with_user(self.auditor_user)
271+
self.assertEqual(log.url, "https://crvs.example.org/api/registry/sync/search")
271272
self.assertTrue(log.request_summary)
272273
self.assertEqual(log.request_summary["message"]["national_id"], "123456")
273274
self.assertTrue(log.response_summary)
@@ -278,6 +279,8 @@ def test_auditor_can_read_sensitive_fields(self):
278279
def test_non_auditor_cannot_read_sensitive_fields(self):
279280
"""User without auditor group gets AccessError for sensitive fields"""
280281
log = self.log_record.with_user(self.viewer_user)
282+
with self.assertRaises(AccessError):
283+
_ = log.url
281284
with self.assertRaises(AccessError):
282285
_ = log.request_summary
283286
with self.assertRaises(AccessError):
@@ -288,7 +291,6 @@ def test_non_auditor_cannot_read_sensitive_fields(self):
288291
def test_non_auditor_can_read_metadata_fields(self):
289292
"""User without auditor group can still read non-sensitive metadata"""
290293
log = self.log_record.with_user(self.viewer_user)
291-
self.assertEqual(log.url, "https://crvs.example.org/api/registry/sync/search")
292294
self.assertEqual(log.endpoint, "/registry/sync/search")
293295
self.assertEqual(log.http_method, "POST")
294296
self.assertEqual(log.status, "http_error")
@@ -299,8 +301,9 @@ def test_sensitive_fields_hidden_in_fields_get(self):
299301
fields_info = (
300302
self.env["spp.api.outgoing.log"]
301303
.with_user(self.viewer_user)
302-
.fields_get(["request_summary", "response_summary", "error_detail"])
304+
.fields_get(["url", "request_summary", "response_summary", "error_detail"])
303305
)
306+
self.assertNotIn("url", fields_info)
304307
self.assertNotIn("request_summary", fields_info)
305308
self.assertNotIn("response_summary", fields_info)
306309
self.assertNotIn("error_detail", fields_info)

spp_api_v2/tests/test_outgoing_api_log_service.py

Lines changed: 193 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22
"""Tests for OutgoingApiLogService"""
33

4+
import json
5+
46
from odoo.tests.common import TransactionCase
57

68
from ..services.outgoing_api_log_service import OutgoingApiLogService
@@ -150,8 +152,6 @@ def test_truncate_payload_exact_boundary(self):
150152
)
151153

152154
# Build a payload whose JSON serialization is exactly max_length
153-
import json
154-
155155
max_length = 50
156156
# {"k": "..."} — adjust value to hit exact length
157157
base = json.dumps({"k": ""}) # '{"k": ""}' = 10 chars
@@ -173,8 +173,6 @@ def test_truncate_payload_one_over_boundary(self):
173173
service_code="test",
174174
)
175175

176-
import json
177-
178176
max_length = 50
179177
base = json.dumps({"k": ""})
180178
filler = "x" * (max_length - len(base) + 1)
@@ -186,3 +184,194 @@ def test_truncate_payload_one_over_boundary(self):
186184
self.assertTrue(result["_truncated"])
187185
self.assertEqual(result["_original_length"], max_length + 1)
188186
self.assertEqual(len(result["_preview"]), max_length)
187+
188+
def test_mask_sensitive_keys_top_level(self):
189+
"""_mask_sensitive_keys masks known sensitive keys at top level"""
190+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
191+
192+
payload = {
193+
"Authorization": "Bearer secret-token-123",
194+
"Content-Type": "application/json",
195+
"data": "visible",
196+
}
197+
198+
result = service._mask_sensitive_keys(payload)
199+
self.assertEqual(result["Authorization"], "***MASKED***")
200+
self.assertEqual(result["Content-Type"], "application/json")
201+
self.assertEqual(result["data"], "visible")
202+
203+
def test_mask_sensitive_keys_nested(self):
204+
"""_mask_sensitive_keys masks sensitive keys in nested dicts"""
205+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
206+
207+
payload = {
208+
"header": {
209+
"authorization": "Bearer xyz",
210+
"action": "search",
211+
},
212+
"body": {"password": "s3cret", "username": "admin"},
213+
}
214+
215+
result = service._mask_sensitive_keys(payload)
216+
self.assertEqual(result["header"]["authorization"], "***MASKED***")
217+
self.assertEqual(result["header"]["action"], "search")
218+
self.assertEqual(result["body"]["password"], "***MASKED***")
219+
self.assertEqual(result["body"]["username"], "admin")
220+
221+
def test_mask_sensitive_keys_case_insensitive(self):
222+
"""_mask_sensitive_keys matches key names case-insensitively"""
223+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
224+
225+
payload = {
226+
"API_KEY": "key123",
227+
"api_key": "key456",
228+
"Api_Key": "key789",
229+
}
230+
231+
result = service._mask_sensitive_keys(payload)
232+
for key in payload:
233+
self.assertEqual(result[key], "***MASKED***")
234+
235+
def test_mask_sensitive_keys_various_sensitive_names(self):
236+
"""_mask_sensitive_keys masks all common sensitive key names"""
237+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
238+
239+
sensitive_keys = [
240+
"authorization",
241+
"password",
242+
"token",
243+
"access_token",
244+
"refresh_token",
245+
"api_key",
246+
"apikey",
247+
"secret",
248+
"client_secret",
249+
"credential",
250+
"private_key",
251+
]
252+
253+
payload = {key: f"value_{key}" for key in sensitive_keys}
254+
result = service._mask_sensitive_keys(payload)
255+
256+
for key in sensitive_keys:
257+
self.assertEqual(
258+
result[key],
259+
"***MASKED***",
260+
f"Key '{key}' should be masked",
261+
)
262+
263+
def test_mask_sensitive_keys_preserves_non_dict_values(self):
264+
"""_mask_sensitive_keys handles lists and non-dict nested structures"""
265+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
266+
267+
payload = {
268+
"items": [
269+
{"password": "secret", "name": "item1"},
270+
{"token": "abc", "name": "item2"},
271+
],
272+
"count": 2,
273+
}
274+
275+
result = service._mask_sensitive_keys(payload)
276+
self.assertEqual(result["items"][0]["password"], "***MASKED***")
277+
self.assertEqual(result["items"][0]["name"], "item1")
278+
self.assertEqual(result["items"][1]["token"], "***MASKED***")
279+
self.assertEqual(result["items"][1]["name"], "item2")
280+
self.assertEqual(result["count"], 2)
281+
282+
def test_mask_sensitive_keys_none_input(self):
283+
"""_mask_sensitive_keys returns None for None input"""
284+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
285+
286+
self.assertIsNone(service._mask_sensitive_keys(None))
287+
288+
def test_mask_sensitive_keys_does_not_mutate_original(self):
289+
"""_mask_sensitive_keys returns a new dict without mutating the input"""
290+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
291+
292+
payload = {"password": "secret", "data": "visible"}
293+
result = service._mask_sensitive_keys(payload)
294+
295+
self.assertEqual(payload["password"], "secret")
296+
self.assertEqual(result["password"], "***MASKED***")
297+
298+
def test_log_call_masks_sensitive_keys_in_payloads(self):
299+
"""log_call applies masking to request and response payloads"""
300+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
301+
302+
result = service.log_call(
303+
url="https://example.org/api/test",
304+
request_summary={"authorization": "Bearer xyz", "action": "search"},
305+
response_summary={"token": "resp-token", "status": "ok"},
306+
)
307+
308+
self.assertTrue(result)
309+
self.assertEqual(result.request_summary["authorization"], "***MASKED***")
310+
self.assertEqual(result.request_summary["action"], "search")
311+
self.assertEqual(result.response_summary["token"], "***MASKED***")
312+
self.assertEqual(result.response_summary["status"], "ok")
313+
314+
def test_sanitize_url_no_sensitive_params(self):
315+
"""_sanitize_url leaves URLs without sensitive params unchanged"""
316+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
317+
318+
url = "https://example.org/api/search?q=hello&limit=10"
319+
result = service._sanitize_url(url)
320+
self.assertIn("q=hello", result)
321+
self.assertIn("limit=10", result)
322+
323+
def test_sanitize_url_masks_sensitive_params(self):
324+
"""_sanitize_url replaces sensitive query parameter values with MASKED"""
325+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
326+
327+
url = "https://example.org/api/search?api_key=secret123&q=hello"
328+
result = service._sanitize_url(url)
329+
self.assertNotIn("secret123", result)
330+
self.assertIn("***MASKED***", result)
331+
self.assertIn("q=hello", result)
332+
333+
def test_sanitize_url_masks_multiple_sensitive_params(self):
334+
"""_sanitize_url masks all sensitive params in a single URL"""
335+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
336+
337+
url = "https://example.org/api?token=abc&api_key=xyz&page=1"
338+
result = service._sanitize_url(url)
339+
self.assertNotIn("abc", result)
340+
self.assertNotIn("xyz", result)
341+
self.assertIn("page=1", result)
342+
343+
def test_sanitize_url_case_insensitive_params(self):
344+
"""_sanitize_url matches sensitive param names case-insensitively"""
345+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
346+
347+
url = "https://example.org/api?API_KEY=secret&Access_Token=tok"
348+
result = service._sanitize_url(url)
349+
self.assertNotIn("secret", result)
350+
self.assertNotIn("tok", result)
351+
352+
def test_sanitize_url_no_query_string(self):
353+
"""_sanitize_url returns URL unchanged when there is no query string"""
354+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
355+
356+
url = "https://example.org/api/search"
357+
result = service._sanitize_url(url)
358+
self.assertEqual(result, url)
359+
360+
def test_sanitize_url_none_input(self):
361+
"""_sanitize_url returns None for None input"""
362+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
363+
364+
self.assertIsNone(service._sanitize_url(None))
365+
366+
def test_log_call_sanitizes_url(self):
367+
"""log_call strips sensitive query parameters from URL before storing"""
368+
service = OutgoingApiLogService(self.env, service_name="Test", service_code="test")
369+
370+
result = service.log_call(
371+
url="https://example.org/api/search?api_key=supersecret&q=hello",
372+
)
373+
374+
self.assertTrue(result)
375+
self.assertNotIn("supersecret", result.url)
376+
self.assertIn("***MASKED***", result.url)
377+
self.assertIn("q=hello", result.url)

0 commit comments

Comments
 (0)