Skip to content

Commit 3ebaec4

Browse files
committed
Fixed client_id sanitization to prevent DB errors
1 parent c30d341 commit 3ebaec4

File tree

9 files changed

+226
-4
lines changed

9 files changed

+226
-4
lines changed

docs/sections/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Unreleased
1010

1111
* Added: Translation to Russian.
1212
* Changed: Ruff as a fast Python linter and code formatter.
13+
* Fixed: client_id sanitization to prevent database errors.
1314

1415
0.8.4
1516
=====

oidc_provider/admin.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.forms import ModelForm
77
from django.utils.translation import gettext_lazy as _
88

9+
from oidc_provider.lib.utils.sanitization import sanitize_client_id
910
from oidc_provider.models import Client
1011
from oidc_provider.models import Code
1112
from oidc_provider.models import RSAKey
@@ -23,12 +24,15 @@ def __init__(self, *args, **kwargs):
2324
self.fields["client_id"].widget.attrs["disabled"] = "true"
2425
self.fields["client_secret"].required = False
2526
self.fields["client_secret"].widget.attrs["disabled"] = "true"
27+
self.fields["jwt_alg"].required = False
2628

2729
def clean_client_id(self):
2830
instance = getattr(self, "instance", None)
2931
if instance and instance.pk:
30-
return instance.client_id
32+
# Sanitize existing client_id to remove any problematic characters
33+
return sanitize_client_id(instance.client_id)
3134
else:
35+
# Generate new client_id (digits only)
3236
return str(randint(1, 999999)).zfill(6)
3337

3438
def clean_client_secret(self):

oidc_provider/lib/endpoints/authorize.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from oidc_provider.lib.errors import ClientIdError
2929
from oidc_provider.lib.errors import RedirectUriError
3030
from oidc_provider.lib.utils.common import get_browser_state_or_default
31+
from oidc_provider.lib.utils.sanitization import sanitize_client_id
3132
from oidc_provider.lib.utils.token import create_code
3233
from oidc_provider.lib.utils.token import create_id_token
3334
from oidc_provider.lib.utils.token import create_token
@@ -72,7 +73,10 @@ def _extract_params(self):
7273
# and POST request.
7374
query_dict = self.request.POST if self.request.method == "POST" else self.request.GET
7475

75-
self.params["client_id"] = query_dict.get("client_id", "")
76+
# Sanitize client_id to remove control characters that cause PostgreSQL errors
77+
client_id = query_dict.get("client_id", "")
78+
self.params["client_id"] = sanitize_client_id(client_id)
79+
7680
self.params["redirect_uri"] = query_dict.get("redirect_uri", "")
7781
self.params["response_type"] = query_dict.get("response_type", "")
7882
self.params["scope"] = query_dict.get("scope", "").split()

oidc_provider/lib/endpoints/introspection.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from oidc_provider.lib.errors import TokenIntrospectionError
77
from oidc_provider.lib.utils.common import run_processing_hook
88
from oidc_provider.lib.utils.oauth2 import extract_client_auth
9+
from oidc_provider.lib.utils.sanitization import sanitize_client_id
910
from oidc_provider.models import Client
1011
from oidc_provider.models import Token
1112

@@ -27,7 +28,8 @@ def _extract_params(self):
2728
# Introspection only supports POST requests
2829
self.params["token"] = self.request.POST.get("token")
2930
client_id, client_secret = extract_client_auth(self.request)
30-
self.params["client_id"] = client_id
31+
# Sanitize client_id to remove control characters that cause PostgreSQL errors
32+
self.params["client_id"] = sanitize_client_id(client_id)
3133
self.params["client_secret"] = client_secret
3234

3335
def validate_params(self):

oidc_provider/lib/endpoints/token.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from oidc_provider.lib.errors import TokenError
1212
from oidc_provider.lib.errors import UserAuthError
1313
from oidc_provider.lib.utils.oauth2 import extract_client_auth
14+
from oidc_provider.lib.utils.sanitization import sanitize_client_id
1415
from oidc_provider.lib.utils.token import create_id_token
1516
from oidc_provider.lib.utils.token import create_token
1617
from oidc_provider.lib.utils.token import encode_id_token
@@ -31,7 +32,8 @@ def __init__(self, request):
3132
def _extract_params(self):
3233
client_id, client_secret = extract_client_auth(self.request)
3334

34-
self.params["client_id"] = client_id
35+
# Sanitize client_id to remove control characters that cause PostgreSQL errors
36+
self.params["client_id"] = sanitize_client_id(client_id)
3537
self.params["client_secret"] = client_secret
3638
self.params["redirect_uri"] = self.request.POST.get("redirect_uri", "")
3739
self.params["grant_type"] = self.request.POST.get("grant_type", "")
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import re
2+
3+
4+
def sanitize_client_id(client_id):
5+
"""
6+
Sanitize client_id according to OAuth 2.0 RFC 6749 specification.
7+
8+
Removes control characters that can cause database errors while preserving
9+
all valid visible ASCII characters (VCHAR: 0x21-0x7E) as defined by the
10+
OAuth 2.0 specification.
11+
12+
Args:
13+
client_id (str): The client_id parameter from the request
14+
15+
Returns:
16+
str: Sanitized client_id with control characters removed
17+
18+
Examples:
19+
>>> sanitize_client_id("Hello\\x00World")
20+
'HelloWorld'
21+
>>> sanitize_client_id("valid-client-123")
22+
'valid-client-123'
23+
>>> sanitize_client_id("")
24+
''
25+
>>> sanitize_client_id(None)
26+
''
27+
"""
28+
if not client_id:
29+
return ""
30+
31+
return re.sub(r"[^\x21-\x7E]", "", client_id)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from django.contrib.auth import get_user_model
2+
from django.test import TestCase
3+
4+
from oidc_provider.admin import ClientForm
5+
from oidc_provider.models import Client
6+
from oidc_provider.models import ResponseType
7+
from oidc_provider.tests.app.utils import create_fake_user
8+
9+
User = get_user_model()
10+
11+
12+
class ClientFormTest(TestCase):
13+
"""
14+
Test cases for ClientForm in admin.
15+
"""
16+
17+
def setUp(self):
18+
self.user = create_fake_user()
19+
self.code_response_type, _ = ResponseType.objects.get_or_create(
20+
value="code", defaults={"description": "code (Authorization Code Flow)"}
21+
)
22+
23+
def test_creates_client_without_client_id_generates_random_one(self):
24+
"""Test that creating a client without client_id generates a random 6-digit one."""
25+
form_data = {
26+
"name": "Test Client",
27+
"owner": self.user.pk,
28+
"client_type": "public",
29+
"response_types": [self.code_response_type.pk],
30+
"_redirect_uris": "http://example.com/callback",
31+
}
32+
33+
form = ClientForm(data=form_data)
34+
self.assertTrue(form.is_valid(), f"Form errors: {form.errors}")
35+
36+
# The form should generate a client_id
37+
client_id = form.clean_client_id()
38+
self.assertIsNotNone(client_id)
39+
self.assertEqual(len(client_id), 6)
40+
self.assertTrue(client_id.isdigit())
41+
self.assertTrue(1 <= int(client_id) <= 999999)
42+
43+
def test_creates_client_with_custom_client_id_preserves_it(self):
44+
"""Test that providing a custom client_id preserves it for new clients."""
45+
# Create and save a client first
46+
client = Client.objects.create(
47+
name="Existing Client",
48+
owner=self.user,
49+
client_type="public",
50+
client_id="custom-client-123",
51+
)
52+
client.response_types.add(self.code_response_type)
53+
54+
form_data = {
55+
"name": "Existing Client Updated",
56+
"owner": self.user.pk,
57+
"client_type": "public",
58+
"response_types": [self.code_response_type.pk],
59+
"_redirect_uris": "http://example.com/callback",
60+
"client_id": "custom-client-123",
61+
}
62+
63+
# Test updating existing client
64+
form = ClientForm(data=form_data, instance=client)
65+
self.assertTrue(form.is_valid(), f"Form errors: {form.errors}")
66+
67+
# Should return the sanitized version of existing client_id
68+
client_id = form.clean_client_id()
69+
self.assertEqual(client_id, "custom-client-123")
70+
71+
def test_sanitizes_existing_client_id_with_control_characters(self):
72+
"""Test that existing client_id with control characters gets sanitized."""
73+
# Create a client with problematic client_id
74+
client = Client.objects.create(
75+
name="Problematic Client",
76+
owner=self.user,
77+
client_type="public",
78+
client_id="normalclient", # Start with normal client_id
79+
)
80+
client.response_types.add(self.code_response_type)
81+
82+
# Manually set problematic client_id to test sanitization
83+
client.client_id = "client\x00\x01test" # Contains null byte and control char
84+
85+
form_data = {
86+
"name": "Problematic Client",
87+
"owner": self.user.pk,
88+
"client_type": "public",
89+
"response_types": [self.code_response_type.pk],
90+
"_redirect_uris": "http://example.com/callback",
91+
}
92+
93+
form = ClientForm(data=form_data, instance=client)
94+
self.assertTrue(form.is_valid(), f"Form errors: {form.errors}")
95+
96+
# Should return sanitized client_id
97+
client_id = form.clean_client_id()
98+
self.assertEqual(client_id, "clienttest") # Control characters removed

oidc_provider/tests/cases/test_authorize_endpoint.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,26 @@ def test_strip_prompt_login(self):
583583
self.assertIn("none", strip_prompt_login(path3))
584584
self.assertNotIn("login", strip_prompt_login(path3))
585585

586+
def test_client_id_with_null_char_are_rejected(self):
587+
"""
588+
Test that client_id parameters containing unexpected characters and
589+
are properly sanitized and to not cause database errors.
590+
"""
591+
data = {
592+
"client_id": "Hello\0World",
593+
"response_type": next(self.client_code.response_type_values()),
594+
"redirect_uri": self.client_code.default_redirect_uri,
595+
"scope": "openid email",
596+
"state": self.state,
597+
"prompt": "none",
598+
}
599+
600+
response = self._auth_request("get", data)
601+
602+
self.assertEqual(response.status_code, 200)
603+
604+
self.assertIn("Client ID Error", response.content.decode("utf-8"))
605+
586606

587607
class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin):
588608
"""

oidc_provider/tests/cases/test_utils.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from oidc_provider.lib.utils.common import get_browser_state_or_default
1313
from oidc_provider.lib.utils.common import get_issuer
14+
from oidc_provider.lib.utils.sanitization import sanitize_client_id
1415
from oidc_provider.lib.utils.token import create_id_token
1516
from oidc_provider.lib.utils.token import create_token
1617
from oidc_provider.tests.app.utils import create_fake_client
@@ -155,3 +156,62 @@ def test_get_browser_state_uses_session_key_to_calculate_browser_state_if_availa
155156
request.session = Mock(session_key="my_session_key")
156157
state = get_browser_state_or_default(request)
157158
self.assertEqual(state, sha224("my_session_key".encode("utf-8")).hexdigest())
159+
160+
161+
class SanitizationTest(TestCase):
162+
"""
163+
Test cases for sanitization utils.
164+
"""
165+
166+
def test_sanitize_client_id_removes_null_bytes(self):
167+
"""Test that null bytes are removed from client_id."""
168+
client_id = "Hello\x00World"
169+
result = sanitize_client_id(client_id)
170+
self.assertEqual(result, "HelloWorld")
171+
172+
def test_sanitize_client_id_removes_control_characters(self):
173+
"""Test that various control characters are removed."""
174+
client_id = "client\x01\x02\x03\x1f\x7fid"
175+
result = sanitize_client_id(client_id)
176+
self.assertEqual(result, "clientid")
177+
178+
def test_sanitize_client_id_preserves_valid_characters(self):
179+
"""Test that valid visible ASCII characters are preserved."""
180+
client_id = "valid-client_123.abc!@#$%^&*()+={}[]|\\:;\"'<>?,./~`"
181+
result = sanitize_client_id(client_id)
182+
self.assertEqual(result, client_id) # Should remain unchanged
183+
184+
def test_sanitize_client_id_handles_empty_string(self):
185+
"""Test that empty string returns empty string."""
186+
result = sanitize_client_id("")
187+
self.assertEqual(result, "")
188+
189+
def test_sanitize_client_id_handles_none(self):
190+
"""Test that None returns empty string."""
191+
result = sanitize_client_id(None)
192+
self.assertEqual(result, "")
193+
194+
def test_sanitize_client_id_removes_whitespace_characters(self):
195+
"""Test that whitespace characters are removed (not part of VCHAR)."""
196+
client_id = "client\t\n\r id"
197+
result = sanitize_client_id(client_id)
198+
self.assertEqual(result, "clientid")
199+
200+
def test_sanitize_client_id_preserves_printable_ascii(self):
201+
"""Test preservation of all printable ASCII characters (0x21-0x7E)."""
202+
# All VCHAR characters as per RFC 6749
203+
vchar_string = "".join(chr(i) for i in range(0x21, 0x7F))
204+
result = sanitize_client_id(vchar_string)
205+
self.assertEqual(result, vchar_string)
206+
207+
def test_sanitize_client_id_removes_unicode_characters(self):
208+
"""Test that Unicode characters outside ASCII range are removed."""
209+
client_id = "client-ñáéíóú-测试-🔥"
210+
result = sanitize_client_id(client_id)
211+
self.assertEqual(result, "client---")
212+
213+
def test_sanitize_client_id_mixed_valid_invalid(self):
214+
"""Test mixed valid and invalid characters."""
215+
client_id = "valid\x00client\x01-\x7f123\tabc"
216+
result = sanitize_client_id(client_id)
217+
self.assertEqual(result, "validclient-123abc")

0 commit comments

Comments
 (0)