Skip to content
Draft
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
10 changes: 10 additions & 0 deletions spp_dci_server/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ Dependencies
Changelog
=========

19.0.2.0.4
~~~~~~~~~~

- fix(security): reject non-ASCII Bearer tokens with a 401 instead of
raising an unhandled error. A Bearer token carrying non-ASCII header
bytes reached ``hmac.compare_digest`` as a non-ASCII string, which
raises ``TypeError`` and surfaced as a generic 500 (with a stack
trace) on public DCI endpoints. Such tokens are now rejected before
the constant-time comparison.

19.0.2.0.0
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_dci_server/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{ # pylint: disable=pointless-statement
"name": "OpenSPP DCI Server",
"summary": "DCI API server infrastructure with FastAPI routers",
"version": "19.0.2.0.3",
"version": "19.0.2.0.4",
"category": "OpenSPP/Integration",
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
17 changes: 17 additions & 0 deletions spp_dci_server/middleware/signature.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,23 @@ async def verify_bearer_token(
headers={"WWW-Authenticate": "Bearer"},
)

# Reject non-ASCII credentials before any comparison. HTTP headers are
# decoded as latin-1, so a non-ASCII byte reaches us as a non-ASCII str;
# hmac.compare_digest raises TypeError on non-ASCII str operands, and that
# would escape this dependency as an unhandled 500. No legitimate bearer
# credential is ever non-ASCII (OAuth2 JWTs are base64url; configured
# static tokens are ASCII), so a non-ASCII token is simply invalid. This
# guard sits before the constant-time loop, the OAuth2 path, and the
# opt-out return so every branch is covered by one check.
if not token.isascii():
_logger.warning("DCI request has non-ASCII Bearer token")
raise DCIHTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
error_message="Invalid Bearer token",
error_code="err.auth.invalid_token",
headers={"WWW-Authenticate": "Bearer"},
)

# Get accepted tokens from config (comma-separated list). An empty
# config used to mean "accept any non-empty token" - a fail-open
# default that exposed every bearer-authenticated route the moment
Expand Down
8 changes: 8 additions & 0 deletions spp_dci_server/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
### 19.0.2.0.4

- fix(security): reject non-ASCII Bearer tokens with a 401 instead of raising an
unhandled error. A Bearer token carrying non-ASCII header bytes reached
``hmac.compare_digest`` as a non-ASCII string, which raises ``TypeError`` and
surfaced as a generic 500 (with a stack trace) on public DCI endpoints. Such
tokens are now rejected before the constant-time comparison.

### 19.0.2.0.0

- Initial migration to OpenSPP2
11 changes: 11 additions & 0 deletions spp_dci_server/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,17 @@ <h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
</div>
</div>
<div class="section" id="section-1">
<h1>19.0.2.0.4</h1>
<ul class="simple">
<li>fix(security): reject non-ASCII Bearer tokens with a 401 instead of
raising an unhandled error. A Bearer token carrying non-ASCII header
bytes reached <tt class="docutils literal">hmac.compare_digest</tt> as a non-ASCII string, which
raises <tt class="docutils literal">TypeError</tt> and surfaced as a generic 500 (with a stack
trace) on public DCI endpoints. Such tokens are now rejected before
the constant-time comparison.</li>
</ul>
</div>
<div class="section" id="section-2">
<h1>19.0.2.0.0</h1>
<ul class="simple">
<li>Initial migration to OpenSPP2</li>
Expand Down
38 changes: 38 additions & 0 deletions spp_dci_server/tests/test_bearer_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,44 @@ def test_empty_bearer_token_rejects(self):
self._call("Bearer ")
self.assertEqual(ctx.exception.status_code, 401)

# --- Non-ASCII / malformed credentials (regression) -----------------------

def test_non_ascii_bearer_token_rejected_with_401(self):
"""A Bearer token carrying non-ASCII characters must be rejected as a
401, not crash the dependency. HTTP headers are latin-1-decoded, so a
non-ASCII header byte reaches this code as a non-ASCII str; passing it
to hmac.compare_digest raises TypeError, which - being neither an
HTTPException nor caught here - escaped as a generic 500 (with a stack
trace in the log) on every bearer-authenticated DCI endpoint."""
self.ICP.set_param("dci.api_tokens", "alpha,beta")

with self.assertRaises(HTTPException) as ctx:
self._call("Bearer café-ÿ")
self.assertEqual(ctx.exception.status_code, 401)

def test_non_ascii_bearer_token_rejected_even_with_empty_list(self):
"""The non-ASCII guard is a single choke point: it rejects before the
opt-out 'accept any non-empty token' path too, so a non-ASCII token is
never returned as a valid credential regardless of configuration."""
self.ICP.set_param("dci.api_tokens", "")
self.ICP.set_param("dci.api_tokens_required", "false")

with self.assertRaises(HTTPException) as ctx:
self._call("Bearer café-ÿ")
self.assertEqual(ctx.exception.status_code, 401)

def test_control_char_token_treated_as_normal_invalid(self):
"""Control characters are ASCII, so a control-char token passes the
non-ASCII guard and reaches hmac.compare_digest, which handles it
without raising. It is simply an ordinary non-match -> 401. This pins
that the guard deliberately rejects only non-ASCII input, not every
odd byte, and that control chars do not crash the compare."""
self.ICP.set_param("dci.api_tokens", "alpha")

with self.assertRaises(HTTPException) as ctx:
self._call("Bearer \x00\x01")
self.assertEqual(ctx.exception.status_code, 401)


@tagged("post_install", "-at_install")
class TestSecurityDefaults(DCIServerCommon):
Expand Down
Loading