Skip to content

Commit 0815c50

Browse files
authored
Merge pull request #10 from currentslab/agent/0.1.2-hardening
fix(client): hardening round from external audit (0.1.2)
2 parents aa8a7ae + 7fb48fc commit 0815c50

6 files changed

Lines changed: 183 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
## 0.1.2
4+
5+
### Fixed
6+
- Restored compatibility with the declared `python-dateutil>=2.8.0` floor:
7+
the date-parse error handler no longer references `parser.ParserError`
8+
(introduced in dateutil 2.8.1).
9+
- Non-JSON error responses (e.g. HTML gateway 502s) and non-object JSON
10+
error payloads now raise `CurrentsAPIError` instead of leaking
11+
`JSONDecodeError`/`AttributeError`. The exception now carries the real
12+
HTTP status code via its `status` property; the payload's `status` value
13+
is only used when no HTTP status is available.
14+
- `search()` now rejects falsey invalid arguments (`keywords=0`, empty/
15+
whitespace keywords) instead of silently omitting them.
16+
- Naive `datetime` inputs are rejected with a clear `ValueError` (they were
17+
silently labeled UTC before); `date` objects are now explicitly formatted
18+
as UTC midnight.
19+
- `examples/source_linked_briefing` now escapes publisher-controlled
20+
Markdown text and validates URL schemes, matching the protection the
21+
company-monitor example already had (blocks `javascript:` link injection).
22+
323
## 0.1.1
424

525
### Fixed

currentsapi/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import sys
22

33
__project__ = "currentsapi"
4-
__version__ = "0.1.1"
4+
__version__ = "0.1.2"
55
__repo__ = "https://github.com/currentslab/currentsapi-python"
66

77
from .client import CurrentsAPI

currentsapi/client.py

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,25 @@
99
class CurrentsAPIError(Exception):
1010
"""Raised when the Currents API returns an error response."""
1111

12-
def __init__(self, response):
12+
def __init__(self, response, http_status=None):
13+
if not isinstance(response, dict):
14+
response = {}
1315
self.response = response
14-
self._status = response.get("status")
16+
self._http_status = http_status
1517
self._code = response.get("code")
1618
self._message = response.get("message") or response.get("msg")
17-
super().__init__(self._message or str(response))
19+
super().__init__(self._message or (str(response) if response else "Unknown API error"))
1820

1921
@property
2022
def status(self):
23+
"""HTTP status code as int; payload status used only as last resort."""
24+
if self._http_status is not None:
25+
return self._http_status
26+
payload_status = self.response.get("status")
2127
try:
22-
return int(self._status)
28+
return int(payload_status)
2329
except (TypeError, ValueError):
24-
return self._status
30+
return payload_status
2531

2632
@property
2733
def code(self):
@@ -58,17 +64,36 @@ def _get(self, endpoint, params=None):
5864
params=params or {},
5965
)
6066
if r.status_code != requests.codes.ok:
61-
raise CurrentsAPIError(r.json())
62-
return r.json()
67+
raise self._error_from_response(r)
68+
try:
69+
payload = r.json()
70+
except ValueError:
71+
raise CurrentsAPIError(
72+
{"message": "Response body is not valid JSON"},
73+
http_status=r.status_code,
74+
)
75+
return payload
76+
77+
@staticmethod
78+
def _error_from_response(r):
79+
try:
80+
payload = r.json()
81+
except ValueError:
82+
payload = {}
83+
if not isinstance(payload, dict):
84+
payload = {
85+
"message": "API returned a non-object error payload",
86+
"details": payload,
87+
}
88+
return CurrentsAPIError(payload, http_status=r.status_code)
6389

6490
def latest_news(self, language=None):
6591
params = {}
66-
if language:
92+
if language is not None:
6793
if not isinstance(language, str):
6894
raise ValueError("language must be a string")
6995
params["language"] = language
7096
return self._get(self.latest_endpoint, params)
71-
7297
def search(
7398
self,
7499
language=None,
@@ -80,32 +105,38 @@ def search(
80105
):
81106
params = {}
82107

83-
if keywords:
108+
if keywords is not None:
84109
if not isinstance(keywords, str):
85110
raise ValueError("keywords must be a string")
111+
if not keywords.strip():
112+
raise ValueError("keywords must not be empty")
86113
params["keywords"] = keywords
87114

88-
if country:
115+
if country is not None:
89116
if not isinstance(country, str):
90117
raise ValueError("country must be a string")
91118
params["country"] = country
92119

93-
if language:
120+
if language is not None:
94121
if not isinstance(language, str):
95122
raise ValueError("language must be a string")
96123
params["language"] = language
97124

98-
if category:
125+
if category is not None:
99126
if not isinstance(category, str):
100127
raise ValueError("category must be a string")
101128
params["category"] = category
102129

103-
if start_date:
104-
date = self._normalize_date(self._parse_date(start_date, "start_date"))
130+
if start_date is not None:
131+
date = self._normalize_date(
132+
self._parse_date(start_date, "start_date"), "start_date"
133+
)
105134
params["start_date"] = date.strftime("%Y-%m-%dT%H:%M:%SZ")
106135

107-
if end_date:
108-
date = self._normalize_date(self._parse_date(end_date, "end_date"))
136+
if end_date is not None:
137+
date = self._normalize_date(
138+
self._parse_date(end_date, "end_date"), "end_date"
139+
)
109140
params["end_date"] = date.strftime("%Y-%m-%dT%H:%M:%SZ")
110141

111142
return self._get(self.search_endpoint, params)
@@ -124,7 +155,7 @@ def _parse_date(date_value, param_name):
124155
if isinstance(date_value, str):
125156
try:
126157
return parser.parse(date_value)
127-
except (parser.ParserError, OverflowError, ValueError) as exc:
158+
except (OverflowError, ValueError) as exc:
128159
raise ValueError(
129160
"{} is not a parsable date: {}".format(param_name, exc)
130161
) from exc
@@ -138,7 +169,18 @@ def _parse_date(date_value, param_name):
138169
)
139170

140171
@staticmethod
141-
def _normalize_date(date_value):
142-
if isinstance(date_value, datetime.datetime) and date_value.tzinfo is not None:
172+
def _normalize_date(date_value, param_name):
173+
if isinstance(date_value, datetime.datetime):
174+
if date_value.tzinfo is None:
175+
raise ValueError(
176+
"{} datetime must be timezone-aware; attach a tzinfo "
177+
"(naive datetimes are ambiguous and are NOT assumed to be "
178+
"UTC)".format(param_name)
179+
)
143180
return date_value.astimezone(datetime.timezone.utc)
181+
if isinstance(date_value, datetime.date):
182+
return datetime.datetime(
183+
date_value.year, date_value.month, date_value.day,
184+
tzinfo=datetime.timezone.utc,
185+
)
144186
return date_value

examples/_example_utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Shared helpers for the shipped Currents API examples."""
2+
3+
import re
4+
5+
6+
_MARKDOWN_ESCAPES = re.compile(r"([\\`*_\[\]()<>#!|{}])")
7+
8+
9+
def escape_markdown_text(value):
10+
"""Escape Markdown-significant characters in publisher-controlled text."""
11+
return _MARKDOWN_ESCAPES.sub(r"\\\1", str(value))
12+
13+
14+
def safe_markdown_url(url, allowed_schemes=("http", "https")):
15+
"""Return a URL safe to embed in Markdown, or ``None`` if unsafe.
16+
17+
Rejects empty values, non-string values, unknown/unsafe schemes
18+
(e.g. ``javascript:``), and whitespace/control characters that could
19+
break out of a Markdown link target.
20+
"""
21+
if not isinstance(url, str):
22+
return None
23+
candidate = url.strip()
24+
if not candidate:
25+
return None
26+
if any(ch.isspace() or ord(ch) < 0x20 for ch in candidate):
27+
return None
28+
match = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*):", candidate)
29+
if not match or match.group(1).lower() not in allowed_schemes:
30+
return None
31+
# Escape parentheses so the URL cannot break out of [..](..) syntax.
32+
return candidate.replace("(", "%28").replace(")", "%29")

examples/source_linked_briefing/briefing.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@
44
import argparse
55
import json
66
import os
7+
import sys
78
from datetime import datetime, timezone
89
from pathlib import Path
910

11+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
12+
from _example_utils import escape_markdown_text, safe_markdown_url
13+
14+
sys.path.pop(0)
15+
1016

1117
def parse_args():
1218
parser = argparse.ArgumentParser(description=__doc__)
@@ -100,17 +106,17 @@ def resolve_generated_at(args, response):
100106
def build_output(response, generated_at):
101107
validate_response(response)
102108
articles = [normalize_article(article) for article in response["news"]]
103-
articles.sort(key=lambda article: article["title"].casefold())
109+
articles.sort(key=lambda article: escape_markdown_text(article["title"]).casefold())
104110
articles.sort(key=lambda article: article["published"], reverse=True)
105-
lines = ["# Source-Linked News Briefing", "", "Generated at: {}".format(generated_at), ""]
111+
lines = ["# Source-Linked News Briefing", "", "Generated at: {}".format(escape_markdown_text(generated_at)), ""]
106112
for article in articles:
107-
title = article["title"]
108-
url = article["url"]
113+
title = escape_markdown_text(article["title"])
114+
url = safe_markdown_url(article["url"])
109115
lines.append("- {} - <{}>".format(title, url) if url else "- {}".format(title))
110116
if article["published"]:
111-
lines.append(" - Published: {}".format(article["published"]))
117+
lines.append(" - Published: {}".format(escape_markdown_text(article["published"])))
112118
if article["description"]:
113-
lines.append(" - {}".format(article["description"]))
119+
lines.append(" - {}".format(escape_markdown_text(article["description"])))
114120
return "\n".join(lines) + "\n", {
115121
"generated_at": generated_at,
116122
"articles": articles,

tests/test_client.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ def test_search_all_params(self, mock_get):
7171
language="en",
7272
country="US",
7373
category="technology",
74-
start_date="2024-01-15",
75-
end_date="2024-06-30",
74+
start_date="2024-01-15T00:00:00Z",
75+
end_date="2024-06-30T00:00:00Z",
7676
)
7777
args, kwargs = mock_get.call_args
7878
self.assertEqual(
@@ -146,9 +146,10 @@ def test_api_error_raises_exception(self, mock_get):
146146
api = CurrentsAPI("key")
147147
with self.assertRaises(CurrentsAPIError) as ctx:
148148
api.latest_news()
149-
self.assertEqual(ctx.exception.status, "error")
149+
self.assertEqual(ctx.exception.status, 401)
150150
self.assertEqual(ctx.exception.code, "Unauthorized")
151151
self.assertEqual(ctx.exception.message, "Invalid key")
152+
self.assertEqual(ctx.exception.response["status"], "error")
152153

153154
def test_invalid_keywords_type(self):
154155
api = CurrentsAPI("key")
@@ -209,5 +210,57 @@ def test_impossible_date_string_raises_valueerror(self):
209210
with self.assertRaises(ValueError):
210211
api.search(start_date="2026-13-45")
211212

213+
@patch("currentsapi.client.requests.get")
214+
def test_non_json_502_raises_currents_api_error(self, mock_get):
215+
mock_get.return_value = Mock(status_code=502)
216+
mock_get.return_value.json.side_effect = ValueError("Expecting value")
217+
api = CurrentsAPI("key")
218+
with self.assertRaises(CurrentsAPIError) as ctx:
219+
api.latest_news()
220+
self.assertEqual(ctx.exception.status, 502)
221+
222+
@patch("currentsapi.client.requests.get")
223+
def test_non_object_error_payload_raises_currents_api_error(self, mock_get):
224+
mock_get.return_value = Mock(status_code=401)
225+
mock_get.return_value.json.return_value = []
226+
api = CurrentsAPI("key")
227+
with self.assertRaises(CurrentsAPIError) as ctx:
228+
api.latest_news()
229+
self.assertEqual(ctx.exception.status, 401)
230+
self.assertIn("non-object", ctx.exception.message)
231+
232+
def test_falsey_keywords_rejected(self):
233+
api = CurrentsAPI("key")
234+
with self.assertRaises(ValueError):
235+
api.search(keywords=0)
236+
with self.assertRaises(ValueError):
237+
api.search(keywords=" ")
238+
239+
def test_naive_datetime_rejected(self):
240+
api = CurrentsAPI("key")
241+
with self.assertRaises(ValueError):
242+
api.search(start_date=datetime.datetime(2024, 6, 1, 12, 0))
243+
244+
def test_date_object_becomes_utc_midnight(self):
245+
mock_response = Mock(status_code=200, json=Mock(return_value={"status": "ok"}))
246+
with patch("currentsapi.client.requests.get", return_value=mock_response) as mock_get:
247+
api = CurrentsAPI("key")
248+
api.search(start_date=datetime.date(2024, 6, 1))
249+
kwargs = mock_get.call_args.kwargs
250+
self.assertEqual(kwargs["params"]["start_date"], "2024-06-01T00:00:00Z")
251+
252+
def test_status_prefers_http_code_over_payload(self):
253+
exc = CurrentsAPIError({"status": "error"}, http_status=401)
254+
self.assertEqual(exc.status, 401)
255+
exc2 = CurrentsAPIError({"status": "404"})
256+
self.assertEqual(exc2.status, 404)
257+
exc3 = CurrentsAPIError({"status": "error"})
258+
self.assertEqual(exc3.status, "error")
259+
260+
def test_exception_accepts_non_dict_payload(self):
261+
exc = CurrentsAPIError(["unexpected"])
262+
self.assertIsNone(exc.code)
263+
self.assertEqual(str(exc), "Unknown API error")
264+
212265
if __name__ == "__main__":
213266
unittest.main()

0 commit comments

Comments
 (0)