Repository: SteveEwell/python-ldap-filter

Issue:
Incorrect handling of ! in string parse
Hello,

first of all, thanks for recently updating the project! Good to see that it is still being maintained!

I would like to report an issue that we saw using the parse function of the library. It appears that the handling of a ! in an attributeValue is incorrect, resulting in ldap string parser to raise an exception. This also happens with ldap search strings that have been generated by the library itself.

To demonstrate I have written the following test script where I create ldap strings using your library, and then immediately parse it again:

```
from ldap_filter import Filter

testcases =[
    "Test",
    "Test!",
    "Test(",
    "Test)",
    "Test(()",
    "Test(Admin!)",
    "Test(with_a_*)",
    "Test(with_a_:)",
    "Test(with_a_&)",
    "Test(with_a_|)",
    "Test(with_a_~)"

]

for value in testcases:
    output = Filter.AND([
        Filter.attribute('name').equal_to(value),
        Filter.attribute('mail').ends_with('@example.com'),
        Filter.OR([
            Filter.attribute('dept').equal_to('accounting'),
            Filter.attribute('dept').equal_to('operations')
        ])
    ])

    # Creation
    print(f"Testcase: {value}")
    print(f"String  : {output.to_string()}")

    # Parsing
    parsed = Filter.parse(output.to_string())
    attributeValue = parsed.filters[0].val
    print(f"Attribute: {attributeValue}")
    assert value == attributeValue
```

This results in:

```
Testcase: Test
String  : (&(name=Test)(mail=*@example.com)(|(dept=accounting)(dept=operations)))
Attribute: Test
Testcase: Test!
String  : (&(name=Test!)(mail=*@example.com)(|(dept=accounting)(dept=operations)))
Traceback (most recent call last):
  File "/home/usr/test.py", line 33, in <module>
    parsed = Filter.parse(output.to_string())
  File "/home/usr/src/python-ldap-filter/ldap_filter/filter.py", line 48, in parse
    return parser.parse(filt, actions=ParserActions())
  File "/home/usr/src/python-ldap-filter/ldap_filter/parser.py", line 1530, in parse
    return parser.parse()
  File "/home/usr/src/python-ldap-filter/ldap_filter/parser.py", line 1513, in parse
    raise ParseError(format_error(self._input, self._failure, self._expected))
ldap_filter.parser.ParseError: Line 1: expected '\\', [^!*\x29], '*', ')'
(&(name=Test!)(mail=*@example.com)(|(dept=accounting)(dept=operations)))

```

I believe the problem is that the `!` is incorrectly excluded as a valid token for the value.

If I apply the following patch:
```
diff --git a/ldap_filter/parser.py b/ldap_filter/parser.py
index 12b38f7..3cc2cc5 100644
--- a/ldap_filter/parser.py
+++ b/ldap_filter/parser.py
@@ -142,7 +142,7 @@ class Grammar(object):
     _input_size = None
     _actions = None
 
-    REGEX_1 = re.compile('^[^!*\\x29]')
+    REGEX_1 = re.compile('^[^*\\x29]')
     REGEX_2 = re.compile('^[a-fA-F0-9]')
     REGEX_3 = re.compile('^[\\x20]')
     REGEX_4 = re.compile('^[\\x09]')
```

All strings are parsed correctly.

I am interested to hear what you think of this issue and if my fix is correct. I have looked at https://www.rfc-editor.org/rfc/rfc4515#section-3 and saw that exclamation mark is not a character that requires escaping.

Hints from issue comments:
@SteveEwell: Thanks for the report. Sorry this is taking a little longer. I can reproduce the issue and I am looking at a fix going off what you have. I just need a little more time to fully understand the impact of this change to make sure it does not introduce other bugs. It's been a little while since I last went through this code in depth. I hopefully should have an update over the next few days with the fix.

Solution patch:
diff --git a/docs/parser.peg b/docs/parser.peg
index f3ac31f..4c15118 100644
--- a/docs/parser.peg
+++ b/docs/parser.peg
@@ -29,7 +29,7 @@ grammar LDAP
     LDAP_OID <- (DIGIT+ ("." DIGIT+)*) %return_oid_type
     options <- (option ";" options) %return_string / option
     option <- (AttrTypeChars+) %return_string
-    AttributeValue <- EscapedCharacter / [^!*\x29]
+    AttributeValue <- EscapedCharacter / [^\x29]
     EscapedCharacter <- '\\' ASCII_VALUE %return_escaped_char
     ASCII_VALUE <- HEX_CHAR HEX_CHAR %return_hex
     HEX_CHAR <- [a-fA-F0-9]
diff --git a/ldap_filter/parser.py b/ldap_filter/parser.py
index 12b38f7..0e855e5 100644
--- a/ldap_filter/parser.py
+++ b/ldap_filter/parser.py
@@ -142,7 +142,7 @@ class Grammar(object):
     _input_size = None
     _actions = None
 
-    REGEX_1 = re.compile('^[^!*\\x29]')
+    REGEX_1 = re.compile('^[^\\x29]')
     REGEX_2 = re.compile('^[a-fA-F0-9]')
     REGEX_3 = re.compile('^[\\x20]')
     REGEX_4 = re.compile('^[\\x09]')


Test patch:
diff --git a/tests/test_filter_parser.py b/tests/test_filter_parser.py
index 6477688..734c69e 100644
--- a/tests/test_filter_parser.py
+++ b/tests/test_filter_parser.py
@@ -30,6 +30,14 @@ def test_allows_whitespace(self):
         string = parsed.to_string()
         assert string == '(&(sn=smith with spaces)(one-two<=morespaces)(objectType=object Type))'
 
+    def test_allows_value_with_exclamation(self):
+        filt = '(&(name=Test!)(mail=*@example.com)(|(dept=accounting)(dept=operations)))'
+        parsed = Filter.parse(filt)
+        test = {'name': 'Test!', 'mail': 'ron@example.com', 'dept': 'operations'}
+        assert parsed.match(test)
+        test_fail = {'name': 'Test', 'mail': 'ron@example.com', 'dept': 'operations'}
+        assert not parsed.match(test_fail)
+
     def test_allowed_characters(self):
         filt = '(orgUnit=%)'
         parsed = Filter.parse(filt)