Merge lp:~mbp/launchpad/925597-dkim-from into lp:launchpad

Proposed by Martin Pool on 2012-03-23
Status: Merged
Approved by: Martin Pool on 2012-04-13
Approved revision: no longer in the source branch.
Merged at revision: 15093
Proposed branch: lp:~mbp/launchpad/925597-dkim-from
Merge into: lp:launchpad
Diff against target: 430 lines (+195/-67)
2 files modified
lib/lp/services/mail/incoming.py (+55/-18)
lib/lp/services/mail/tests/test_dkim.py (+140/-49)
To merge this branch: bzr merge lp:~mbp/launchpad/925597-dkim-from
Reviewer Review Type Date Requested Status
Ian Booth (community) Approve on 2012-04-13
Brad Crittenden (community) code 2012-03-23 Approve on 2012-03-23
Review via email: mp+98976@code.launchpad.net

Commit Message

cope with mail dkim-signed by addresses that aren't know to launchpad

Description of the Change

This fixes bug <https://bugs.launchpad.net/launchpad/+bug/925597> which is hit if you are sending mail from gmail, but your gmail address is not registered with lp.

I wonder if I also need to test the case that your gmail address is known but not verified? I probably should.

To post a comment you must log in.
Brad Crittenden (bac) wrote :

Thanks for the fix Martin. Your changes are clear and the new test case is quite readable.

'make lint' shows a couple of valid items along with the usual suspects of non-issues. If you have a second to address those it would be nice.

Again, thanks for this fix and for championing the DKIM support.

review: Approve (code)
Martin Pool (mbp) wrote :

Thanks for the prompt friendly review!

I think it will be worth adding the other test case, so I'll try to do that
today or tomorrow, then get an incremental review and merge it myself.

Martin Pool (mbp) wrote :

I cleaned it up a bit more, and added a test for mail signed by unverified addresses, which was a case that needed to be handled.

Ian Booth (wallyworld) wrote :

Looks great. Thanks for making the changes suggested on irc.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/services/mail/incoming.py'
2--- lib/lp/services/mail/incoming.py 2012-01-04 03:23:19 +0000
3+++ lib/lp/services/mail/incoming.py 2012-04-13 03:28:25 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Functions dealing with mails coming into Launchpad."""
10@@ -33,6 +33,10 @@
11 IGPGHandler,
12 )
13 from lp.services.identity.interfaces.account import AccountStatus
14+from lp.services.identity.interfaces.emailaddress import (
15+ EmailAddressStatus,
16+ IEmailAddressSet,
17+ )
18 from lp.services.librarian.interfaces.client import UploadFailed
19 from lp.services.mail.handlers import mail_handlers
20 from lp.services.mail.helpers import (
21@@ -103,8 +107,8 @@
22 return domain in _trusted_dkim_domains
23
24
25-def _authenticateDkim(signed_message):
26- """Attempt DKIM authentication of email.
27+def _verifyDkimOrigin(signed_message):
28+ """Find a From or Sender address for which there's a DKIM signature.
29
30 :returns: A string email address for the trusted sender, if there is one,
31 otherwise None.
32@@ -190,6 +194,46 @@
33 return None
34
35
36+def _getPrincipalByDkim(mail):
37+ """Determine the security principal from DKIM, if possible.
38+
39+ To qualify:
40+ * there must be a dkim signature from a trusted domain
41+ * the From or Sender must be in that domain
42+ * the address in this header must be verified for a person
43+
44+ :returns: (None, None), or (principal, trusted_addr).
45+ """
46+ log = logging.getLogger('mail-authenticate-dkim')
47+ authutil = getUtility(IPlacelessAuthUtility)
48+
49+ dkim_trusted_address = _verifyDkimOrigin(mail)
50+ if dkim_trusted_address is None:
51+ return None, None
52+
53+ log.debug('authenticated DKIM mail origin %s' % dkim_trusted_address)
54+ address = getUtility(IEmailAddressSet).getByEmail(dkim_trusted_address)
55+ if address is None:
56+ log.debug("valid dkim signature, but not from a known email address, "
57+ "therefore disregarding it")
58+ return None, None
59+ elif address.status not in (EmailAddressStatus.VALIDATED,
60+ EmailAddressStatus.PREFERRED):
61+ log.debug("valid dkim signature, "
62+ "but not from an active email address, "
63+ "therefore disregarding it")
64+ return None, None
65+ if address.person is None:
66+ log.debug("address is not associated with a person")
67+ return None, None
68+ account = address.person.account
69+ if account is None:
70+ log.debug("person does not have an account")
71+ return None, None
72+ dkim_principal = authutil.getPrincipal(account.id)
73+ return (dkim_principal, dkim_trusted_address)
74+
75+
76 def authenticateEmail(mail,
77 signature_timestamp_checker=None):
78 """Authenticates an email by verifying the PGP signature.
79@@ -207,20 +251,13 @@
80 """
81
82 log = logging.getLogger('process-mail')
83-
84- dkim_trusted_addr = _authenticateDkim(mail)
85- if dkim_trusted_addr is not None:
86- # The Sender field, if signed by a trusted domain, is the strong
87- # authenticator for this mail.
88- log.debug('trusted DKIM mail from %s' % dkim_trusted_addr)
89- email_addr = dkim_trusted_addr
90- else:
91- email_addr = parseaddr(mail['From'])[1]
92-
93 authutil = getUtility(IPlacelessAuthUtility)
94- principal = authutil.getPrincipalByLogin(email_addr)
95-
96- # Check that sender is registered in Launchpad and the email is signed.
97+
98+ principal, dkim_trusted_address = _getPrincipalByDkim(mail)
99+ if dkim_trusted_address is None:
100+ from_addr = parseaddr(mail['From'])[1]
101+ principal = authutil.getPrincipalByLogin(from_addr)
102+
103 if principal is None:
104 setupInteraction(authutil.unauthenticatedPrincipal())
105 return None
106@@ -230,9 +267,9 @@
107 raise InactiveAccount(
108 "Mail from a user with an inactive account.")
109
110- if dkim_trusted_addr is not None:
111+ if dkim_trusted_address:
112 log.debug('accepting dkim strongly authenticated mail')
113- setupInteraction(principal, dkim_trusted_addr)
114+ setupInteraction(principal, dkim_trusted_address)
115 return principal
116 else:
117 log.debug("attempt gpg authentication for %r" % person)
118
119=== modified file 'lib/lp/services/mail/tests/test_dkim.py'
120--- lib/lp/services/mail/tests/test_dkim.py 2012-01-01 02:58:52 +0000
121+++ lib/lp/services/mail/tests/test_dkim.py 2012-04-13 03:28:25 +0000
122@@ -1,4 +1,4 @@
123-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
124+# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
125 # GNU Affero General Public License version 3 (see the file LICENSE).
126
127 """Test DKIM-signed messages"""
128@@ -12,8 +12,13 @@
129 import dns.resolver
130
131 from lp.services.features.testing import FeatureFixture
132+from lp.services.identity.interfaces.account import AccountStatus
133+from lp.services.identity.interfaces.emailaddress import EmailAddressStatus
134 from lp.services.mail import incoming
135-from lp.services.mail.incoming import authenticateEmail
136+from lp.services.mail.incoming import (
137+ authenticateEmail,
138+ InactiveAccount,
139+ )
140 from lp.services.mail.interfaces import IWeaklyAuthenticatedPrincipal
141 from lp.services.mail.signedmessage import signed_message_from_string
142 from lp.testing import TestCaseWithFactory
143@@ -46,17 +51,6 @@
144 b/mPfjC0QJTocVBq6Za/PlzfV+Py92VaCak19F4WrbVTK5Gg5tW220MCAwEAAQ=="""
145
146
147-plain_content = """\
148-From: Foo Bar <foo.bar@canonical.com>
149-Date: Fri, 1 Apr 2010 00:00:00 +1000
150-Subject: yet another comment
151-To: 1@bugs.staging.launchpad.net
152-
153- importance critical
154-
155-Why isn't this fixed yet?"""
156-
157-
158 class TestDKIM(TestCaseWithFactory):
159 """Messages can be strongly authenticated by DKIM."""
160
161@@ -101,6 +95,24 @@
162
163 self.addCleanup(restore)
164
165+ def preload_dns_response(self, response_type='valid'):
166+ """Configure a fake DNS key response.
167+
168+ :param response_type: Describes what response to give back as the
169+ key. The default, 'valid', is to give the valid test signing key.
170+ 'broken' gives a key that's almost but not quite valid, 'garbage'
171+ gives one that doesn't look valid at all.
172+ """
173+ if response_type == 'valid':
174+ key = sample_dns
175+ elif response_type == 'broken':
176+ key = sample_dns.replace(';', '')
177+ elif response_type == 'garbage':
178+ key = 'abcdefg'
179+ else:
180+ raise ValueError(response_type)
181+ self._dns_responses['example._domainkey.canonical.com.'] = key
182+
183 def get_dkim_log(self):
184 return self._log_output.getvalue()
185
186@@ -119,15 +131,29 @@
187 if l.find(substring) == -1:
188 self.fail("didn't find %r in log: %s" % (substring, l))
189
190+ def makeMessageText(self, sender=None, from_address=None):
191+ if from_address is None:
192+ from_address = "Foo Bar <foo.bar@canonical.com>"
193+ text = ("From: " + from_address + "\n" + """\
194+Date: Fri, 1 Apr 2010 00:00:00 +1000
195+Subject: yet another comment
196+To: 1@bugs.staging.launchpad.net
197+
198+ importance critical
199+
200+Why isn't this fixed yet?""")
201+ if sender is not None:
202+ text = "Sender: " + sender + "\n" + text
203+ return text
204+
205 def test_dkim_broken_pubkey(self):
206 """Handle a subtly-broken pubkey like qq.com, see bug 881237.
207
208 The message is not trusted but inbound message processing does not
209 abort either.
210 """
211- signed_message = self.fake_signing(plain_content)
212- self._dns_responses['example._domainkey.canonical.com.'] = \
213- sample_dns.replace(';', '')
214+ signed_message = self.fake_signing(self.makeMessageText())
215+ self.preload_dns_response('broken')
216 principal = authenticateEmail(
217 signed_message_from_string(signed_message))
218 self.assertWeaklyAuthenticated(principal, signed_message)
219@@ -136,9 +162,8 @@
220 self.assertDkimLogContains('unexpected error in DKIM verification')
221
222 def test_dkim_garbage_pubkey(self):
223- signed_message = self.fake_signing(plain_content)
224- self._dns_responses['example._domainkey.canonical.com.'] = \
225- 'aothuaonu'
226+ signed_message = self.fake_signing(self.makeMessageText())
227+ self.preload_dns_response('garbage')
228 principal = authenticateEmail(
229 signed_message_from_string(signed_message))
230 self.assertWeaklyAuthenticated(principal, signed_message)
231@@ -155,10 +180,9 @@
232 self.test_dkim_valid_strict)
233
234 def test_dkim_valid_strict(self):
235- signed_message = self.fake_signing(plain_content,
236+ signed_message = self.fake_signing(self.makeMessageText(),
237 canonicalize=(dkim.Simple, dkim.Simple))
238- self._dns_responses['example._domainkey.canonical.com.'] = \
239- sample_dns
240+ self.preload_dns_response()
241 principal = authenticateEmail(
242 signed_message_from_string(signed_message))
243 self.assertStronglyAuthenticated(principal, signed_message)
244@@ -166,9 +190,8 @@
245 'foo.bar@canonical.com')
246
247 def test_dkim_valid(self):
248- signed_message = self.fake_signing(plain_content)
249- self._dns_responses['example._domainkey.canonical.com.'] = \
250- sample_dns
251+ signed_message = self.fake_signing(self.makeMessageText())
252+ self.preload_dns_response()
253 principal = authenticateEmail(
254 signed_message_from_string(signed_message))
255 self.assertStronglyAuthenticated(principal, signed_message)
256@@ -177,9 +200,8 @@
257
258 def test_dkim_untrusted_signer(self):
259 # Valid signature from an untrusted domain -> untrusted
260- signed_message = self.fake_signing(plain_content)
261- self._dns_responses['example._domainkey.canonical.com.'] = \
262- sample_dns
263+ signed_message = self.fake_signing(self.makeMessageText())
264+ self.preload_dns_response()
265 saved_domains = incoming._trusted_dkim_domains[:]
266
267 def restore():
268@@ -198,11 +220,10 @@
269 # that of the From-sender, if that domain is relaying the message.
270 # However, we shouldn't then trust the purported sender, because they
271 # might have just made it up rather than relayed it.
272- tweaked_message = plain_content.replace('foo.bar@canonical.com',
273- 'steve.alexander@ubuntulinux.com')
274+ tweaked_message = self.makeMessageText(
275+ from_address='steve.alexander@ubuntulinux.com')
276 signed_message = self.fake_signing(tweaked_message)
277- self._dns_responses['example._domainkey.canonical.com.'] = \
278- sample_dns
279+ self.preload_dns_response()
280 principal = authenticateEmail(
281 signed_message_from_string(signed_message))
282 self.assertWeaklyAuthenticated(principal, signed_message)
283@@ -215,9 +236,8 @@
284 # We still treat this as weakly authenticated by the purported
285 # From-header sender, though perhaps in future we would prefer
286 # to reject these messages.
287- signed_message = self.fake_signing(plain_content)
288- self._dns_responses['example._domainkey.canonical.com.'] = \
289- sample_dns
290+ signed_message = self.fake_signing(self.makeMessageText())
291+ self.preload_dns_response()
292 fiddled_message = signed_message.replace(
293 'From: Foo Bar <foo.bar@canonical.com>',
294 'From: Carlos <carlos@canonical.com>')
295@@ -230,9 +250,8 @@
296
297 def test_dkim_changed_from_realname(self):
298 # If the real name part of the message has changed, it's detected.
299- signed_message = self.fake_signing(plain_content)
300- self._dns_responses['example._domainkey.canonical.com.'] = \
301- sample_dns
302+ signed_message = self.fake_signing(self.makeMessageText())
303+ self.preload_dns_response()
304 fiddled_message = signed_message.replace(
305 'From: Foo Bar <foo.bar@canonical.com>',
306 'From: Evil Foo <foo.bar@canonical.com>')
307@@ -246,7 +265,7 @@
308 def test_dkim_nxdomain(self):
309 # If there's no DNS entry for the pubkey it should be handled
310 # decently.
311- signed_message = self.fake_signing(plain_content)
312+ signed_message = self.fake_signing(self.makeMessageText())
313 principal = authenticateEmail(
314 signed_message_from_string(signed_message))
315 self.assertWeaklyAuthenticated(principal, signed_message)
316@@ -258,8 +277,8 @@
317 # treated as weakly authenticated.
318 # The library doesn't log anything if there's no header at all.
319 principal = authenticateEmail(
320- signed_message_from_string(plain_content))
321- self.assertWeaklyAuthenticated(principal, plain_content)
322+ signed_message_from_string(self.makeMessageText()))
323+ self.assertWeaklyAuthenticated(principal, self.makeMessageText())
324 self.assertEqual(principal.person.preferredemail.email,
325 'foo.bar@canonical.com')
326
327@@ -267,10 +286,9 @@
328 # The message has a syntactically valid DKIM signature that
329 # doesn't actually correspond to what was signed. We log
330 # something about this but we don't want to drop the message.
331- signed_message = self.fake_signing(plain_content)
332+ signed_message = self.fake_signing(self.makeMessageText())
333 signed_message += 'blah blah'
334- self._dns_responses['example._domainkey.canonical.com.'] = \
335- sample_dns
336+ self.preload_dns_response()
337 principal = authenticateEmail(
338 signed_message_from_string(signed_message))
339 self.assertWeaklyAuthenticated(principal, signed_message)
340@@ -291,12 +309,85 @@
341 self.factory.makeEmail(
342 person=person,
343 address='dkimtest@example.com')
344- self._dns_responses['example._domainkey.canonical.com.'] = sample_dns
345- tweaked_message = (
346- "Sender: dkimtest@canonical.com\n" + plain_content.replace(
347- "From: Foo Bar <foo.bar@canonical.com>",
348- "From: DKIM Test <dkimtest@example.com>"))
349+ self.preload_dns_response()
350+ tweaked_message = self.makeMessageText(
351+ sender="dkimtest@canonical.com",
352+ from_address="DKIM Test <dkimtest@example.com>")
353 signed_message = self.fake_signing(tweaked_message)
354 principal = authenticateEmail(
355 signed_message_from_string(signed_message))
356 self.assertStronglyAuthenticated(principal, signed_message)
357+
358+ def test_dkim_signed_but_from_unknown_address(self):
359+ """Sent from trusted dkim address, but only the From address is known.
360+
361+ See https://bugs.launchpad.net/launchpad/+bug/925597
362+ """
363+ self.factory.makePerson(
364+ email='dkimtest@example.com',
365+ name='dkimtest',
366+ displayname='DKIM Test')
367+ self.preload_dns_response()
368+ tweaked_message = self.makeMessageText(
369+ sender="dkimtest@canonical.com",
370+ from_address="DKIM Test <dkimtest@example.com>")
371+ signed_message = self.fake_signing(tweaked_message)
372+ principal = authenticateEmail(
373+ signed_message_from_string(signed_message))
374+ self.assertEqual(principal.person.preferredemail.email,
375+ 'dkimtest@example.com')
376+ self.assertWeaklyAuthenticated(principal, signed_message)
377+ self.assertDkimLogContains(
378+ 'valid dkim signature, but not from a known email address')
379+
380+ def test_dkim_signed_but_from_unverified_address(self):
381+ """Sent from trusted dkim address, but only the From address is known.
382+
383+ The sender is a known, but unverified address.
384+
385+ See https://bugs.launchpad.net/launchpad/+bug/925597
386+ """
387+ from_address = "dkimtest@example.com"
388+ sender_address = "dkimtest@canonical.com"
389+ person = self.factory.makePerson(
390+ email=from_address,
391+ name='dkimtest',
392+ displayname='DKIM Test')
393+ self.factory.makeEmail(sender_address, person, EmailAddressStatus.NEW)
394+ self.preload_dns_response()
395+ tweaked_message = self.makeMessageText(
396+ sender=sender_address,
397+ from_address="DKIM Test <dkimtest@example.com>")
398+ signed_message = self.fake_signing(tweaked_message)
399+ principal = authenticateEmail(
400+ signed_message_from_string(signed_message))
401+ self.assertEqual(principal.person.preferredemail.email,
402+ from_address)
403+ self.assertWeaklyAuthenticated(principal, signed_message)
404+ self.assertDkimLogContains(
405+ 'valid dkim signature, but not from an active email address')
406+
407+ def test_dkim_signed_from_person_without_account(self):
408+ """You can have a person with no account.
409+
410+ We don't accept mail from them.
411+
412+ See https://bugs.launchpad.net/launchpad/+bug/925597
413+ """
414+ from_address = "dkimtest@example.com"
415+ # This is not quite the same as having account=None, but it seems as
416+ # close as the factory lets us get? -- mbp 2012-04-13
417+ self.factory.makePerson(
418+ email=from_address,
419+ name='dkimtest',
420+ displayname='DKIM Test',
421+ account_status=AccountStatus.NOACCOUNT)
422+ self.preload_dns_response()
423+ message_text = self.makeMessageText(
424+ sender=from_address,
425+ from_address=from_address)
426+ signed_message = self.fake_signing(message_text)
427+ self.assertRaises(
428+ InactiveAccount,
429+ authenticateEmail,
430+ signed_message_from_string(signed_message))