Repository: privacyidea/privacyidea

Issue:
Failcounter increments on every token without a PIN when the user= arg only has a <space>
The failcounter increments on every token without a PIN when the user= parameter just has a <space>.
In our case this resulted basically in a denial-of-service of our two factor authentication system, as by default any fail counter over 10 will lock the token out. 

# What did you try to do?

Have tokens without PIN, for example in our setup we have HOTP, TOTP and VASCO tokens without PIN.
Then a request with a user=<space> will increment the failcounter for all these tokens.

**For example with httpie:**
`http http://localhost:5000/validate/check "pass=" "user= "`

**Or with curl:**
`curl http://localhost:5000/validate/check --data "pass=&user= "`

This will return: "**wrong otp value**" and increment all failcounters by 1.

# What outcome did you expect?

Since the **user=** parameter does not match any user in privacyidea with a token, i was expecting it to just deny/drop the request. 

# What outcome did you experience?

All tokens verifications were blocked because all failcounters where incremented to their current max of 10. And any valid check would output the following **"matching 1 tokens, Failcounter exceeded"**.

# Configuration

Tested on privacyIDEA 2.22 and 2.23.1.
Debian stable, virtualenv. 

# Extra information
I'm suspecting something wrong in the logic of [privacyidea/lib/token.py#L2205](https://github.com/privacyidea/privacyidea/blob/aded3ad61a281412f761023430219f59debb4c96/privacyidea/lib/token.py#L2205), so that this is matching all tokens without a PIN.
```
    elif pin_matching_token_list:
        # We did not find a valid token and no challenge.
        # But there are tokens, with a matching pin.
        # So we increase the failcounter. Return failure.
        for tokenobject in pin_matching_token_list:
            tokenobject.inc_failcount()
            reply_dict["message"] = "wrong otp value"
```


Failcounter increments on every token without a PIN when the user= arg only has a <space>
The failcounter increments on every token without a PIN when the user= parameter just has a <space>.
In our case this resulted basically in a denial-of-service of our two factor authentication system, as by default any fail counter over 10 will lock the token out. 

# What did you try to do?

Have tokens without PIN, for example in our setup we have HOTP, TOTP and VASCO tokens without PIN.
Then a request with a user=<space> will increment the failcounter for all these tokens.

**For example with httpie:**
`http http://localhost:5000/validate/check "pass=" "user= "`

**Or with curl:**
`curl http://localhost:5000/validate/check --data "pass=&user= "`

This will return: "**wrong otp value**" and increment all failcounters by 1.

# What outcome did you expect?

Since the **user=** parameter does not match any user in privacyidea with a token, i was expecting it to just deny/drop the request. 

# What outcome did you experience?

All tokens verifications were blocked because all failcounters where incremented to their current max of 10. And any valid check would output the following **"matching 1 tokens, Failcounter exceeded"**.

# Configuration

Tested on privacyIDEA 2.22 and 2.23.1.
Debian stable, virtualenv. 

# Extra information
I'm suspecting something wrong in the logic of [privacyidea/lib/token.py#L2205](https://github.com/privacyidea/privacyidea/blob/aded3ad61a281412f761023430219f59debb4c96/privacyidea/lib/token.py#L2205), so that this is matching all tokens without a PIN.
```
    elif pin_matching_token_list:
        # We did not find a valid token and no challenge.
        # But there are tokens, with a matching pin.
        # So we increase the failcounter. Return failure.
        for tokenobject in pin_matching_token_list:
            tokenobject.inc_failcount()
            reply_dict["message"] = "wrong otp value"
```




Discussion:
Thanks for pointing out. We send you a patch.
Thanks for pointing out. We send you a patch.

Resolving patch:
diff --git a/privacyidea/api/validate.py b/privacyidea/api/validate.py
--- a/privacyidea/api/validate.py
+++ b/privacyidea/api/validate.py
@@ -164,6 +164,7 @@ def after_request(response):
 
 
 @validate_blueprint.route('/offlinerefill', methods=['POST'])
+@check_user_or_serial_in_request(request)
 @event("validate_offlinerefill", request, g)
 def offlinerefill():
     """
diff --git a/privacyidea/lib/decorators.py b/privacyidea/lib/decorators.py
--- a/privacyidea/lib/decorators.py
+++ b/privacyidea/lib/decorators.py
@@ -84,10 +84,15 @@ def __init__(self, request):
     def __call__(self, func):
         @functools.wraps(func)
         def check_user_or_serial_in_request_wrapper(*args, **kwds):
-            user = self.request.all_data.get("user")
-            serial = self.request.all_data.get("serial")
+            user = self.request.all_data.get("user", "").strip()
+            serial = self.request.all_data.get("serial", "").strip()
             if not serial and not user:
                 raise ParameterError(_("You need to specify a serial or a user."))
+            if "*" in serial:
+                raise ParameterError(_("Invalid serial number."))
+            if "%" in user:
+                raise ParameterError(_("Invalid user."))
+
             f_result = func(*args, **kwds)
             return f_result
 


Test patch:
diff --git a/tests/test_api_validate.py b/tests/test_api_validate.py
--- a/tests/test_api_validate.py
+++ b/tests/test_api_validate.py
@@ -2193,6 +2193,40 @@ def test_30_challenge_text(self):
         remove_token("CHAL3")
         remove_token("CHAL4")
 
+    def test_01_check_invalid_input(self):
+        # Empty username
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": " ",
+                                                 "pass": ""}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 400, res)
+            result = json.loads(res.data).get("result")
+            error_msg = result.get("error").get("message")
+            self.assertEqual("ERR905: You need to specify a serial or a user.", error_msg)
+
+        # wrong username
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"user": "h%h",
+                                                 "pass": ""}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 400, res)
+            result = json.loads(res.data).get("result")
+            error_msg = result.get("error").get("message")
+            self.assertEqual("ERR905: Invalid user.", error_msg)
+
+        # wrong serial
+        with self.app.test_request_context('/validate/check',
+                                           method='POST',
+                                           data={"serial": "*",
+                                                 "pass": ""}):
+            res = self.app.full_dispatch_request()
+            self.assertTrue(res.status_code == 400, res)
+            result = json.loads(res.data).get("result")
+            error_msg = result.get("error").get("message")
+            self.assertEqual("ERR905: Invalid serial number.", error_msg)
+
 
 class AChallengeResponse(MyTestCase):