Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## 0.1.3

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

## 0.1.2

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion currentsapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import sys

__project__ = "currentsapi"
__version__ = "0.1.2"
__version__ = "0.1.3"
__repo__ = "https://github.com/currentslab/currentsapi-python"

from .client import CurrentsAPI
Expand Down
11 changes: 11 additions & 0 deletions currentsapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,16 @@ def __init__(
domain=constants.DOMAIN,
version=constants.VERSION,
timeout=30,
allow_custom_domain=False,
):
if not isinstance(api_key, str):
raise ValueError("api_key must be a string")
if domain != constants.DOMAIN and not allow_custom_domain:
raise ValueError(
"Passing a custom domain forwards your API key to that host. "
"If this is intentional (e.g. testing), pass "
"allow_custom_domain=True."
)
self.api_key = ApiAuth(api_key)
self.latest_endpoint = constants.LATEST_NEWS_URL % (domain, version)
self.search_endpoint = constants.SEARCH_URL % (domain, version)
Expand Down Expand Up @@ -153,6 +160,10 @@ def available_category(self):
@staticmethod
def _parse_date(date_value, param_name):
if isinstance(date_value, str):
try:
return datetime.date.fromisoformat(date_value)
except ValueError:
pass
try:
return parser.parse(date_value)
except (OverflowError, ValueError) as exc:
Expand Down
25 changes: 16 additions & 9 deletions examples/_example_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Shared helpers for the shipped Currents API examples."""

import re
from urllib.parse import urlsplit


_MARKDOWN_ESCAPES = re.compile(r"([\\`*_\[\]()<>#!|{}])")
Expand All @@ -14,19 +15,25 @@ def escape_markdown_text(value):
def safe_markdown_url(url, allowed_schemes=("http", "https")):
"""Return a URL safe to embed in Markdown, or ``None`` if unsafe.

Rejects empty values, non-string values, unknown/unsafe schemes
(e.g. ``javascript:``), and whitespace/control characters that could
break out of a Markdown link target.
Requires an explicit allowed scheme (default http/https) and a hostname;
percent-encodes characters that could break out of ``[..](..)`` or
``<..>`` Markdown targets (parentheses, angle brackets, spaces,
control characters).
"""
if not isinstance(url, str):
return None
candidate = url.strip()
if not candidate:
if not candidate or any(ord(ch) < 0x20 for ch in candidate):
return None
if any(ch.isspace() or ord(ch) < 0x20 for ch in candidate):
parts = urlsplit(candidate)
if parts.scheme.lower() not in allowed_schemes or not parts.netloc:
return None
match = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*):", candidate)
if not match or match.group(1).lower() not in allowed_schemes:
if any(ch.isspace() for ch in candidate):
return None
# Escape parentheses so the URL cannot break out of [..](..) syntax.
return candidate.replace("(", "%28").replace(")", "%29")
return (
candidate.replace("<", "%3C")
.replace(">", "%3E")
.replace("(", "%28")
.replace(")", "%29")
.replace(" ", "%20")
)
13 changes: 13 additions & 0 deletions examples/source_linked_briefing/briefing.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@

sys.path.pop(0)

try:
from currentsapi.client import CurrentsAPIError
except ImportError: # pragma: no cover - currentsapi not installed
class CurrentsAPIError(Exception):
"""Placeholder when currentsapi is unavailable; never raised."""

pass


def parse_args():
parser = argparse.ArgumentParser(description=__doc__)
Expand Down Expand Up @@ -135,6 +143,11 @@ def main():
validate_response(response)
generated_at = resolve_generated_at(args, response)
markdown, structured = build_output(response, generated_at)
except CurrentsAPIError as exc:
status = exc.status if exc.status is not None else "unknown"
raise SystemExit(
"error: Currents API request failed (HTTP {}): {}".format(status, exc)
)
except (OSError, ValueError, json.JSONDecodeError) as exc:
raise SystemExit("error: {}".format(exc))

Expand Down
88 changes: 88 additions & 0 deletions tests/test_briefing_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Security regression tests for the source-linked briefing example."""

import importlib.util
import sys
from pathlib import Path

EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "examples"
sys.path.insert(0, str(EXAMPLES_DIR))

from _example_utils import escape_markdown_text, safe_markdown_url # noqa: E402

_spec = importlib.util.spec_from_file_location(
"briefing", EXAMPLES_DIR / "source_linked_briefing" / "briefing.py"
)
briefing = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(briefing)


class TestSafeMarkdownUrl:
def test_javascript_scheme_rejected(self):
assert safe_markdown_url("javascript:alert(1)") is None

def test_data_scheme_rejected(self):
assert safe_markdown_url("data:text/html,<script>alert(1)</script>") is None

def test_scheme_relative_rejected(self):
assert safe_markdown_url("//attacker.example/x") is None

def test_missing_netloc_rejected(self):
assert safe_markdown_url("https://") is None

def test_angle_brackets_percent_encoded(self):
out = safe_markdown_url("https://example.com><script>x</script>")
assert "<" not in out and ">" not in out
assert out == "https://example.com%3E%3Cscript%3Ex%3C/script%3E"

def test_parens_percent_encoded(self):
assert safe_markdown_url("https://example.com/x(1)") == "https://example.com/x%281%29"

def test_normal_url_unchanged(self):
assert safe_markdown_url("https://example.com/a?b=1&c=2") == "https://example.com/a?b=1&c=2"

def test_non_string_rejected(self):
assert safe_markdown_url(None) is None
assert safe_markdown_url(123) is None


class TestBriefingOutputSanitization:
def test_hostile_title_cannot_form_link(self):
art = {
"title": "[click here](javascript:alert(1))",
"description": "",
"url": "",
"published": "2026-01-01",
}
md, _ = briefing.build_output(
{"status": "ok", "news": [art]}, generated_at="2026-01-01T00:00:00Z"
)
assert "](javascript:" not in md

def test_hostile_url_cannot_break_out(self):
art = {
"title": "t",
"description": "",
"url": "https://example.com><script>location.href='//attacker.example'</script>",
"published": "2026-01-01",
}
md, _ = briefing.build_output(
{"status": "ok", "news": [art]}, generated_at="2026-01-01T00:00:00Z"
)
assert "<script>" not in md

def test_clean_url_rendered(self):
art = {
"title": "t",
"description": "",
"url": "https://example.com/story",
"published": "2026-01-01",
}
md, _ = briefing.build_output(
{"status": "ok", "news": [art]}, generated_at="2026-01-01T00:00:00Z"
)
assert "<https://example.com/story>" in md

def test_escape_markdown_text_neutralizes_link_syntax(self):
escaped = escape_markdown_text("[x](javascript:alert(1))")
assert "\\[x\\]" in escaped
assert "\\(javascript:" in escaped
27 changes: 26 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import unittest
from unittest.mock import Mock, patch

from currentsapi import constants
from currentsapi import CurrentsAPI
from currentsapi.client import CurrentsAPIError

Expand Down Expand Up @@ -34,7 +35,7 @@ def test_urls_setup(self):
"https://api.currentsapi.services/v1/available/categories",
)

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

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

@patch("currentsapi.client.requests.get")
def test_date_only_string_accepted(self, mock_get):
mock_get.return_value = Mock(status_code=200, json=Mock(return_value={"status": "ok"}))
api = CurrentsAPI("key")
api.search(start_date="2024-01-15", end_date="2024-06-30")
kwargs = mock_get.call_args.kwargs
self.assertEqual(kwargs["params"]["start_date"], "2024-01-15T00:00:00Z")
self.assertEqual(kwargs["params"]["end_date"], "2024-06-30T00:00:00Z")

@patch("currentsapi.client.requests.get")
def test_naive_timestamp_string_rejected(self, mock_get):
api = CurrentsAPI("key")
with self.assertRaises(ValueError):
api.search(start_date="2024-01-15T10:00:00")

def test_custom_domain_requires_opt_in(self):
with self.assertRaises(ValueError) as ctx:
CurrentsAPI("key", domain="attacker.example")
self.assertIn("allow_custom_domain", str(ctx.exception))
api = CurrentsAPI("key", domain="attacker.example", allow_custom_domain=True)
self.assertEqual(api.latest_endpoint, "https://attacker.example/v1/latest-news")
api = CurrentsAPI("key", domain=constants.DOMAIN)
self.assertEqual(api.latest_endpoint, "https://api.currentsapi.services/v1/latest-news")

if __name__ == "__main__":
unittest.main()
Loading