New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fixing the cert-request command comparing whole email address case-sensitively. #736
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The approach is good. A couple of things to address (comments inline).
ipaserver/plugins/cert.py
Outdated
| """ | ||
|
|
||
| def lower_domain(email): | ||
| return email.split('@')[0] + '@' + email.split('@')[1].lower() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The case where the input has multiple '@' characters should be accounted for. Otherwise we
could end up accepting an invalid value. We must also handle the case where input does not
have an '@' character. Obviously this is not a valid email but the current code would raise
IndexError.
Also we should avoid doing the split multiple times.
ipaserver/plugins/cert.py
Outdated
| @@ -705,7 +705,8 @@ def execute(self, csr, all=False, raw=False, chain=False, **kw): | |||
| # fail if any email addr from DN does not appear in ldap entry | |||
| email_addrs = csr_obj.subject.get_attributes_for_oid( | |||
| cryptography.x509.oid.NameOID.EMAIL_ADDRESS) | |||
| if len(set(email_addrs) - set(principal_obj.get('mail', []))) > 0: | |||
| if not _emails_are_valid(email_addrs, | |||
| principal_obj.get('mail', [])): | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is checking the email addresses (if any) in the Subject DN. We also need to check rfc822Names in the
Subject Alternative Name extension.
|
@frasertweedale I did the check in SAN extension. However, I'm not sure if these are valid situations: or this: Case 2) If the case 1 is valid, the check in line 799 (below) is not right, because it expects that all emails in SAN extension are in the principal. elif isinstance(gn, cryptography.x509.general_name.RFC822Name):
if principal_type == USER:
if principal_obj and gn.value not in principal_obj.get(
'mail', []):
raise errors.ValidationError(
name='csr',
error=_(
"RFC822Name does not match "
"any of user's email addresses")
)
else:
raise errors.ValidationError(
name='csr',
error=_(
"subject alt name type %s is forbidden "
"for non-user principals") % "RFC822Name" |
ipaserver/plugins/cert.py
Outdated
| @@ -705,7 +705,13 @@ def execute(self, csr, all=False, raw=False, chain=False, **kw): | |||
| # fail if any email addr from DN does not appear in ldap entry | |||
| email_addrs = csr_obj.subject.get_attributes_for_oid( | |||
| cryptography.x509.oid.NameOID.EMAIL_ADDRESS) | |||
| if len(set(email_addrs) - set(principal_obj.get('mail', []))) > 0: | |||
|
|
|||
| san_email_addrs = csr_obj.extensions.get_extension_for_oid( | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, a bit later on we already retrieve, parse and check the SAN extension. See the code following:
elif isinstance(gn, cryptography.x509.general_name.RFC822Name):
...
This is where the check for SAN emails should occur.
Also, this is not correct - there are various types of SAN and they must be checked to see what type they
are. Only the email type (RFC822Name) is checked for email match.
ipaserver/plugins/cert.py
Outdated
| cryptography.x509.oid.ExtensionOID | ||
| .SUBJECT_ALTERNATIVE_NAME) | ||
|
|
||
| if not _emails_are_valid(email_addrs, san_email_addrs, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we want to check a single iterable of emails, and then either a) call this function from two
different places or b) union all the emails from Subject DN and SAN, and call it once.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I prefer the option the option "a", since it seems more straightforward.
ipaserver/plugins/cert.py
Outdated
| @@ -860,6 +866,33 @@ def execute(self, csr, all=False, raw=False, chain=False, **kw): | |||
| ) | |||
|
|
|||
|
|
|||
| def _emails_are_valid(subject_emails, san_emails, principal_emails): | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As discussed above, we probably just want to check a single iterable, and call this
function twice - once for Subject DN emails and once for SAN rfc822name values.
def _emails_are_valid(principal_emails, csr_emails):
...
ipaserver/plugins/cert.py
Outdated
| san_emails_lower = list(map(lower_domain, san_emails)) | ||
|
|
||
| return (set(principal_emails_lower).issubset(subject_emails_lower) or | ||
| set(principal_emails_lower).issubset(san_emails_lower)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not the correct way to check. Emails in the CSR must be a subset of principal principal emails,
not the other way around. Every email in the CSR must match one of the principal emails.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ack
ipaserver/plugins/cert.py
Outdated
| return '' | ||
|
|
||
| email_splited = email.split('@') | ||
| return '{}@{}'.format(email_splited[0], email_splited[1].lower()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic still needs to be fixed. It is simple to construct a CSR with emails that have no @ (or multiple).
(It probably possible to make a bogus LDAP attribute value, too).
So I'd suggest something like:
email_split = email.split('@', 1) # split only at first occurrence of '@'
if len(email_split) > 1:
email_split[1] = email_split[1].lower()
return email_splitThere is no need to "rejoin" the parts back into a string; we can compare on the resulting
lists. If you end up using set then you can turn the lists into tuples so that you have a
hashable value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did just a little bit different because the example above it doesn't work. The split method would return a list like ["fbarreto", "redhat.com"], so, it's necessary to join the two parts with a @.
|
I hope it's fine now |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work, getting into "nit-pick" territory now. I'll try to build and test your changes today.
ipaserver/plugins/cert.py
Outdated
| if principal_obj and gn.value not in principal_obj.get( | ||
| 'mail', []): | ||
| gn_value = (gn.value if isinstance(gn.value, list) | ||
| else [gn.value]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think gn.value will always be a string, so you can just put:
if not _emails_are_valid([gn.value], principal_obj.get('mail', [])):If I'm wrong about that, could you please explain?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In all tests that I did, gn.value was a string. However, since I don't know very much all about the code, I thought it would be a "safer" solution. Changed to your suggestion :)
ipaserver/plugins/cert.py
Outdated
| email_splited = email.split('@', 1) | ||
| email_splited[1] = email_splited[1].lower() | ||
|
|
||
| return '@'.join(email_splited) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The join is OK, but I think not necessary, because you will always be comparing values
returned from lower_domain, and lists will compare properly. So joining is just a bit of
unneeded but harmless work.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I prefer to stay with the '@' join to be more consistent with the method name and all the context. If you think that it's not relevant at all, I can just remove it :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, it's not needed, but it doesn't hurt, and I do not mind if we keep it.
| principal_emails_lower = set(map(lower_domain, principal_emails)) | ||
| csr_emails_lower = set(map(lower_domain, csr_emails)) | ||
|
|
||
| return csr_emails_lower.issubset(principal_emails_lower) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks correct 👍
| NameAttr = namedtuple('NameAttr', 'value') | ||
|
|
||
| subject_addrs = [NameAttr(u'any@EmAiL.CoM')] | ||
| result = _emails_are_valid(subject_addrs, [], [u'any@email.com']) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests need updating now that signature of _emails_are_valid changed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. I was waiting to we agree with the implementation and then change the tests.
85e931f
to
97725d8
Compare
ipaserver/plugins/cert.py
Outdated
| """ | ||
|
|
||
| if not any(principal_emails) or not any(csr_emails): | ||
| return False |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should get rid of this. The subject check is sufficient. If there are no csr_emails, then
the test should be vacuously True, not False. Similarly, if there are no principal_emails, then
the valid if and only if there are also no csr_email. All of this is covered by the subset check,
so just remove these two lines.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok
ipaserver/plugins/cert.py
Outdated
| email_splited = email.split('@', 1) | ||
| email_splited[1] = email_splited[1].lower() | ||
|
|
||
| return '@'.join(email_splited) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, it's not needed, but it doesn't hurt, and I do not mind if we keep it.
| assert True == result, result | ||
|
|
||
| result = _emails_are_valid([], [u'any@email.com']) | ||
| assert False == result, result |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As discussed above, this case should be True.
ipaserver/plugins/cert.py
Outdated
| email_splited = email.split('@', 1) | ||
| email_splited[1] = email_splited[1].lower() | ||
| email_splitted = email.split('@', 1) | ||
| if len(email_splitted) > 1: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This became necessary since we don't have the if statement (in line 964) anymore.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now we are good! Thanks for your work. Just one
tiny docstring fix, and please squash the commits, then we can merge this.
ipaserver/plugins/cert.py
Outdated
| @@ -953,6 +955,25 @@ def execute(self, csr, all=False, raw=False, chain=False, **kw): | |||
| ) | |||
|
|
|||
|
|
|||
| def _emails_are_valid(csr_emails, principal_emails): | |||
| """ | |||
| Checks if any email address from certificate does not | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should say "certificate request"
Now, the cert-request command compares the domain part of the email case-insensitively. https://pagure.io/freeipa/issue/5919
|
Done! Thank you Fraser :)) 👍 |
| @@ -884,8 +886,8 @@ def execute(self, csr, all=False, raw=False, chain=False, **kw): | |||
| "match requested principal") % gn.name) | |||
| elif isinstance(gn, cryptography.x509.general_name.RFC822Name): | |||
| if principal_type == USER: | |||
| if principal_obj and gn.value not in principal_obj.get( | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@felipevolpone sorry mate, one more tiny thing that I missed. We need to retain the if principal_obj and ... because there is a scenario where principal_obj = None - namely, the PKINIT KDC certificate use case. While it's unlikely that the CSR in this use case will include an RFC822Name, we should make the code resilient to this case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi Fraser, ok, no problem.
So, we should have this?
if principal_obj and not _emails_are_valid([gn.value], principal_obj.get('mail', [])):There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@felipevolpone actually, I was confused. This whole bit is guarded by an if principal_type == USER, and if that is true, then principal_obj is set. So we are actually good to go; the check that was there before your change was
superfluous.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good to go.
| @@ -884,8 +886,8 @@ def execute(self, csr, all=False, raw=False, chain=False, **kw): | |||
| "match requested principal") % gn.name) | |||
| elif isinstance(gn, cryptography.x509.general_name.RFC822Name): | |||
| if principal_type == USER: | |||
| if principal_obj and gn.value not in principal_obj.get( | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@felipevolpone actually, I was confused. This whole bit is guarded by an if principal_type == USER, and if that is true, then principal_obj is set. So we are actually good to go; the check that was there before your change was
superfluous.
|
master:
|
Now, the cert-request command compares the domain part of the email case-insensitively.
Fixes: https://pagure.io/freeipa/issue/5919