From 8ecf2aadfdd9a6925a0ad56ebc64fcb0c9c5fea6 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 23 Jul 2026 11:33:17 +0800 Subject: [PATCH 1/5] test(spp_dci_server): reproduce 500 on non-ASCII bearer token A Bearer token carrying non-ASCII header bytes reaches hmac.compare_digest as a non-ASCII str, raising TypeError -> unhandled 500 on public DCI endpoints. Add regression tests (currently failing) pinning a 401 for both the configured-token and empty-list opt-out paths. --- .../tests/test_bearer_middleware.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/spp_dci_server/tests/test_bearer_middleware.py b/spp_dci_server/tests/test_bearer_middleware.py index ecccfc9c3..2736f0e54 100644 --- a/spp_dci_server/tests/test_bearer_middleware.py +++ b/spp_dci_server/tests/test_bearer_middleware.py @@ -146,6 +146,32 @@ 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) + @tagged("post_install", "-at_install") class TestSecurityDefaults(DCIServerCommon): From dd785a2884bd93bc7f7916981456d61c47653801 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 23 Jul 2026 11:33:17 +0800 Subject: [PATCH 2/5] security(dci): reject non-ASCII bearer tokens with 401 before constant-time compare Header bytes are latin-1-decoded, so a non-ASCII Authorization value reaches verify_bearer_token as a non-ASCII str. hmac.compare_digest raises TypeError on non-ASCII str operands; the bearer dependency has no try/except, so the error escaped as a generic 500 with a stack trace instead of a 401. Reject non-ASCII credentials up front (before the constant-time loop, the OAuth2 JWT path, and the opt-out return). No legitimate bearer credential is non-ASCII, so this changes no valid-caller behaviour and preserves the timing-safe comparison for ASCII tokens. Regression from the switch to hmac.compare_digest for constant-time token comparison. --- spp_dci_server/middleware/signature.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spp_dci_server/middleware/signature.py b/spp_dci_server/middleware/signature.py index 82032fefe..dac3e476f 100644 --- a/spp_dci_server/middleware/signature.py +++ b/spp_dci_server/middleware/signature.py @@ -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 From 115a338aa20d5a6c5625959c7494b8e1205f98a6 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 23 Jul 2026 11:33:17 +0800 Subject: [PATCH 3/5] docs(spp_dci_server): bump version + HISTORY for non-ASCII bearer fix Patch bump 19.0.2.0.3 -> 19.0.2.0.4 with a HISTORY fragment. README.rst and static/description/index.html are regenerated from CI's pinned generator. --- spp_dci_server/__manifest__.py | 2 +- spp_dci_server/readme/HISTORY.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/spp_dci_server/__manifest__.py b/spp_dci_server/__manifest__.py index ebfd938c2..e07533adc 100644 --- a/spp_dci_server/__manifest__.py +++ b/spp_dci_server/__manifest__.py @@ -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", diff --git a/spp_dci_server/readme/HISTORY.md b/spp_dci_server/readme/HISTORY.md index 4aaf9afef..9b7737c8b 100644 --- a/spp_dci_server/readme/HISTORY.md +++ b/spp_dci_server/readme/HISTORY.md @@ -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 From 5bd3bc6b4adedc6862828dbc9a8091dae752f2cc Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 23 Jul 2026 11:49:28 +0800 Subject: [PATCH 4/5] docs(spp_dci_server): regenerate README + index.html for 19.0.2.0.4 Generated files from CI's pinned oca-gen-addon-readme output (applied verbatim from the CI diff), matching the HISTORY.md fragment. --- spp_dci_server/README.rst | 10 ++++++++++ spp_dci_server/static/description/index.html | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/spp_dci_server/README.rst b/spp_dci_server/README.rst index bc1b589d5..3423e95f4 100644 --- a/spp_dci_server/README.rst +++ b/spp_dci_server/README.rst @@ -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 ~~~~~~~~~~ diff --git a/spp_dci_server/static/description/index.html b/spp_dci_server/static/description/index.html index 980e54294..2050f39f8 100644 --- a/spp_dci_server/static/description/index.html +++ b/spp_dci_server/static/description/index.html @@ -534,6 +534,17 @@

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

  • Initial migration to OpenSPP2
  • From 7ccff30534e661ba8018dc803a7c7db241f96af4 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 23 Jul 2026 12:17:09 +0800 Subject: [PATCH 5/5] test(spp_dci_server): pin control-char bearer token as ordinary invalid Control characters are ASCII and pass the non-ASCII guard, reaching hmac.compare_digest without crashing. Document that the guard rejects only non-ASCII input and that a control-char token is a normal 401 non-match. Raised in staff review of the non-ASCII bearer fix. --- spp_dci_server/tests/test_bearer_middleware.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spp_dci_server/tests/test_bearer_middleware.py b/spp_dci_server/tests/test_bearer_middleware.py index 2736f0e54..81cf7746e 100644 --- a/spp_dci_server/tests/test_bearer_middleware.py +++ b/spp_dci_server/tests/test_bearer_middleware.py @@ -172,6 +172,18 @@ def test_non_ascii_bearer_token_rejected_even_with_empty_list(self): 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):