Skip to content

Commit 9d68eb1

Browse files
authored
Merge pull request #11 from currentslab/agent/0.1.3-followup
fix: 0.1.3 follow-ups from post-0.1.2 review
2 parents 0815c50 + 6157de4 commit 9d68eb1

7 files changed

Lines changed: 172 additions & 11 deletions

File tree

‎CHANGELOG.md‎

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

3+
## 0.1.3
4+
5+
### Fixed
6+
- Restored documented `YYYY-MM-DD` string support in `search()` (broken in
7+
0.1.2 when naive datetimes started being rejected); date-only strings are
8+
formatted as UTC midnight. Naive timestamp strings (e.g.
9+
`2024-01-15T10:00:00`) remain rejected as ambiguous.
10+
- `examples/_example_utils.safe_markdown_url` now validates with `urlsplit`
11+
(scheme + hostname required) and percent-encodes angle brackets,
12+
parentheses, and spaces, closing a Markdown/HTML injection path where a
13+
`https://...><script>...` URL passed validation.
14+
- `CurrentsAPI(domain=...)` now requires explicit `allow_custom_domain=True`
15+
for any domain other than the default, since a custom domain receives your
16+
API key on every request.
17+
- `examples/source_linked_briefing` catches `CurrentsAPIError` and prints a
18+
clean error message (with HTTP status) instead of a traceback.
19+
320
## 0.1.2
421

522
### 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.2"
4+
__version__ = "0.1.3"
55
__repo__ = "https://github.com/currentslab/currentsapi-python"
66

77
from .client import CurrentsAPI

‎currentsapi/client.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,16 @@ def __init__(
4545
domain=constants.DOMAIN,
4646
version=constants.VERSION,
4747
timeout=30,
48+
allow_custom_domain=False,
4849
):
4950
if not isinstance(api_key, str):
5051
raise ValueError("api_key must be a string")
52+
if domain != constants.DOMAIN and not allow_custom_domain:
53+
raise ValueError(
54+
"Passing a custom domain forwards your API key to that host. "
55+
"If this is intentional (e.g. testing), pass "
56+
"allow_custom_domain=True."
57+
)
5158
self.api_key = ApiAuth(api_key)
5259
self.latest_endpoint = constants.LATEST_NEWS_URL % (domain, version)
5360
self.search_endpoint = constants.SEARCH_URL % (domain, version)
@@ -153,6 +160,10 @@ def available_category(self):
153160
@staticmethod
154161
def _parse_date(date_value, param_name):
155162
if isinstance(date_value, str):
163+
try:
164+
return datetime.date.fromisoformat(date_value)
165+
except ValueError:
166+
pass
156167
try:
157168
return parser.parse(date_value)
158169
except (OverflowError, ValueError) as exc:

‎examples/_example_utils.py‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Shared helpers for the shipped Currents API examples."""
22

33
import re
4+
from urllib.parse import urlsplit
45

56

67
_MARKDOWN_ESCAPES = re.compile(r"([\\`*_\[\]()<>#!|{}])")
@@ -14,19 +15,25 @@ def escape_markdown_text(value):
1415
def safe_markdown_url(url, allowed_schemes=("http", "https")):
1516
"""Return a URL safe to embed in Markdown, or ``None`` if unsafe.
1617
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.
18+
Requires an explicit allowed scheme (default http/https) and a hostname;
19+
percent-encodes characters that could break out of ``[..](..)`` or
20+
``<..>`` Markdown targets (parentheses, angle brackets, spaces,
21+
control characters).
2022
"""
2123
if not isinstance(url, str):
2224
return None
2325
candidate = url.strip()
24-
if not candidate:
26+
if not candidate or any(ord(ch) < 0x20 for ch in candidate):
2527
return None
26-
if any(ch.isspace() or ord(ch) < 0x20 for ch in candidate):
28+
parts = urlsplit(candidate)
29+
if parts.scheme.lower() not in allowed_schemes or not parts.netloc:
2730
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:
31+
if any(ch.isspace() for ch in candidate):
3032
return None
31-
# Escape parentheses so the URL cannot break out of [..](..) syntax.
32-
return candidate.replace("(", "%28").replace(")", "%29")
33+
return (
34+
candidate.replace("<", "%3C")
35+
.replace(">", "%3E")
36+
.replace("(", "%28")
37+
.replace(")", "%29")
38+
.replace(" ", "%20")
39+
)

‎examples/source_linked_briefing/briefing.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@
1313

1414
sys.path.pop(0)
1515

16+
try:
17+
from currentsapi.client import CurrentsAPIError
18+
except ImportError: # pragma: no cover - currentsapi not installed
19+
class CurrentsAPIError(Exception):
20+
"""Placeholder when currentsapi is unavailable; never raised."""
21+
22+
pass
23+
1624

1725
def parse_args():
1826
parser = argparse.ArgumentParser(description=__doc__)
@@ -135,6 +143,11 @@ def main():
135143
validate_response(response)
136144
generated_at = resolve_generated_at(args, response)
137145
markdown, structured = build_output(response, generated_at)
146+
except CurrentsAPIError as exc:
147+
status = exc.status if exc.status is not None else "unknown"
148+
raise SystemExit(
149+
"error: Currents API request failed (HTTP {}): {}".format(status, exc)
150+
)
138151
except (OSError, ValueError, json.JSONDecodeError) as exc:
139152
raise SystemExit("error: {}".format(exc))
140153

‎tests/test_briefing_security.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Security regression tests for the source-linked briefing example."""
2+
3+
import importlib.util
4+
import sys
5+
from pathlib import Path
6+
7+
EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "examples"
8+
sys.path.insert(0, str(EXAMPLES_DIR))
9+
10+
from _example_utils import escape_markdown_text, safe_markdown_url # noqa: E402
11+
12+
_spec = importlib.util.spec_from_file_location(
13+
"briefing", EXAMPLES_DIR / "source_linked_briefing" / "briefing.py"
14+
)
15+
briefing = importlib.util.module_from_spec(_spec)
16+
_spec.loader.exec_module(briefing)
17+
18+
19+
class TestSafeMarkdownUrl:
20+
def test_javascript_scheme_rejected(self):
21+
assert safe_markdown_url("javascript:alert(1)") is None
22+
23+
def test_data_scheme_rejected(self):
24+
assert safe_markdown_url("data:text/html,<script>alert(1)</script>") is None
25+
26+
def test_scheme_relative_rejected(self):
27+
assert safe_markdown_url("//attacker.example/x") is None
28+
29+
def test_missing_netloc_rejected(self):
30+
assert safe_markdown_url("https://") is None
31+
32+
def test_angle_brackets_percent_encoded(self):
33+
out = safe_markdown_url("https://example.com><script>x</script>")
34+
assert "<" not in out and ">" not in out
35+
assert out == "https://example.com%3E%3Cscript%3Ex%3C/script%3E"
36+
37+
def test_parens_percent_encoded(self):
38+
assert safe_markdown_url("https://example.com/x(1)") == "https://example.com/x%281%29"
39+
40+
def test_normal_url_unchanged(self):
41+
assert safe_markdown_url("https://example.com/a?b=1&c=2") == "https://example.com/a?b=1&c=2"
42+
43+
def test_non_string_rejected(self):
44+
assert safe_markdown_url(None) is None
45+
assert safe_markdown_url(123) is None
46+
47+
48+
class TestBriefingOutputSanitization:
49+
def test_hostile_title_cannot_form_link(self):
50+
art = {
51+
"title": "[click here](javascript:alert(1))",
52+
"description": "",
53+
"url": "",
54+
"published": "2026-01-01",
55+
}
56+
md, _ = briefing.build_output(
57+
{"status": "ok", "news": [art]}, generated_at="2026-01-01T00:00:00Z"
58+
)
59+
assert "](javascript:" not in md
60+
61+
def test_hostile_url_cannot_break_out(self):
62+
art = {
63+
"title": "t",
64+
"description": "",
65+
"url": "https://example.com><script>location.href='//attacker.example'</script>",
66+
"published": "2026-01-01",
67+
}
68+
md, _ = briefing.build_output(
69+
{"status": "ok", "news": [art]}, generated_at="2026-01-01T00:00:00Z"
70+
)
71+
assert "<script>" not in md
72+
73+
def test_clean_url_rendered(self):
74+
art = {
75+
"title": "t",
76+
"description": "",
77+
"url": "https://example.com/story",
78+
"published": "2026-01-01",
79+
}
80+
md, _ = briefing.build_output(
81+
{"status": "ok", "news": [art]}, generated_at="2026-01-01T00:00:00Z"
82+
)
83+
assert "<https://example.com/story>" in md
84+
85+
def test_escape_markdown_text_neutralizes_link_syntax(self):
86+
escaped = escape_markdown_text("[x](javascript:alert(1))")
87+
assert "\\[x\\]" in escaped
88+
assert "\\(javascript:" in escaped

‎tests/test_client.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import unittest
33
from unittest.mock import Mock, patch
44

5+
from currentsapi import constants
56
from currentsapi import CurrentsAPI
67
from currentsapi.client import CurrentsAPIError
78

@@ -34,7 +35,7 @@ def test_urls_setup(self):
3435
"https://api.currentsapi.services/v1/available/categories",
3536
)
3637

37-
api = CurrentsAPI("dummy_key", "localhost", "v0")
38+
api = CurrentsAPI("dummy_key", "localhost", "v0", allow_custom_domain=True)
3839
self.assertEqual(api.latest_endpoint, "https://localhost/v0/latest-news")
3940
self.assertEqual(api.search_endpoint, "https://localhost/v0/search")
4041

@@ -262,5 +263,29 @@ def test_exception_accepts_non_dict_payload(self):
262263
self.assertIsNone(exc.code)
263264
self.assertEqual(str(exc), "Unknown API error")
264265

266+
@patch("currentsapi.client.requests.get")
267+
def test_date_only_string_accepted(self, mock_get):
268+
mock_get.return_value = Mock(status_code=200, json=Mock(return_value={"status": "ok"}))
269+
api = CurrentsAPI("key")
270+
api.search(start_date="2024-01-15", end_date="2024-06-30")
271+
kwargs = mock_get.call_args.kwargs
272+
self.assertEqual(kwargs["params"]["start_date"], "2024-01-15T00:00:00Z")
273+
self.assertEqual(kwargs["params"]["end_date"], "2024-06-30T00:00:00Z")
274+
275+
@patch("currentsapi.client.requests.get")
276+
def test_naive_timestamp_string_rejected(self, mock_get):
277+
api = CurrentsAPI("key")
278+
with self.assertRaises(ValueError):
279+
api.search(start_date="2024-01-15T10:00:00")
280+
281+
def test_custom_domain_requires_opt_in(self):
282+
with self.assertRaises(ValueError) as ctx:
283+
CurrentsAPI("key", domain="attacker.example")
284+
self.assertIn("allow_custom_domain", str(ctx.exception))
285+
api = CurrentsAPI("key", domain="attacker.example", allow_custom_domain=True)
286+
self.assertEqual(api.latest_endpoint, "https://attacker.example/v1/latest-news")
287+
api = CurrentsAPI("key", domain=constants.DOMAIN)
288+
self.assertEqual(api.latest_endpoint, "https://api.currentsapi.services/v1/latest-news")
289+
265290
if __name__ == "__main__":
266291
unittest.main()

0 commit comments

Comments
 (0)