Index: mailer.py |
diff --git a/mailer.py b/mailer.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..5ff2dc62b2680671e3e0b7f635ee37a6affa6ff5 |
--- /dev/null |
+++ b/mailer.py |
@@ -0,0 +1,195 @@ |
+import hashlib |
+import hmac |
+import json |
+import logging |
+import time |
+ |
+from google.appengine.api import app_identity |
+from google.appengine.api import mail |
+from google.appengine.ext import ndb |
+import webapp2 |
+from webapp2_extras import jinja2 |
+ |
+import gatekeeper_mailer |
+ |
+ |
+class MailerSecret(ndb.Model): |
+ """Model to represent the shared secret for the mail endpoint.""" |
+ secret = ndb.StringProperty() |
+ |
+ |
+class BaseHandler(webapp2.RequestHandler): |
+ """Provide a cached Jinja environment to each request.""" |
+ @webapp2.cached_property |
+ def jinja2(self): |
+ # Returns a Jinja2 renderer cached in the app registry. |
+ return jinja2.get_jinja2(app=self.app) |
+ |
+ def render_response(self, _template, **context): |
+ # Renders a template and writes the result to the response. |
+ rv = self.jinja2.render_template(_template, **context) |
+ self.response.write(rv) |
+ |
+ |
+class MainPage(BaseHandler): |
+ def get(self): |
+ context = {'title': 'Chromium Gatekeeper Mailer'} |
+ self.render_response('main_mailer.html', **context) |
+ |
+ |
+class Email(BaseHandler): |
+ @staticmethod |
+ def linear_compare(a, b): |
+ """Scan through the entire string even if a mismatch is detected early. |
+ |
+ This thwarts timing attacks attempting to guess the key one byte at a |
+ time. |
+ """ |
+ if len(a) != len(b): |
+ return False |
+ result = 0 |
+ for x, y in zip(a, b): |
+ result |= ord(x) ^ ord(y) |
+ return result == 0 |
+ |
+ @staticmethod |
+ def _validate_message(message, url, secret): |
+ """Cryptographically validates the message.""" |
+ mytime = time.time() |
+ |
+ if abs(mytime - message['time']) > 60: |
+ logging.error('message was rejected due to time') |
+ return False |
+ |
+ cleaned_url = url.rstrip('/') + '/' |
+ cleaned_message_url = message['url'].rstrip('/') + '/' |
+ |
+ if cleaned_message_url != cleaned_url: |
+ logging.error('message URL did not match: %s vs %s', cleaned_message_url, |
+ cleaned_url) |
+ return False |
+ |
+ hasher = hmac.new(str(secret), message['message'], hashlib.sha256) |
+ hasher.update(str(message['time'])) |
+ hasher.update(str(message['salt'])) |
+ |
+ client_hash = hasher.hexdigest() |
+ |
+ return Email.linear_compare(client_hash, message['hmac-sha256']) |
+ |
+ @staticmethod |
+ def _verify_json(build_data): |
+ """Verifies that the submitted JSON contains all the proper fields.""" |
+ fields = ['waterfall_url', |
+ 'build_url', |
+ 'project_name', |
+ 'builderName', |
+ 'steps', |
+ 'unsatisfied', |
+ 'revisions', |
+ 'blamelist', |
+ 'result', |
+ 'number', |
+ 'changes', |
+ 'reason', |
+ 'recipients'] |
+ |
+ for field in fields: |
+ if field not in build_data: |
+ logging.error('build_data did not contain field %s' % field) |
+ return False |
+ |
+ step_fields = ['started', |
+ 'text', |
+ 'results', |
+ 'name', |
+ 'logs', |
+ 'urls'] |
+ |
+ if not build_data['steps']: |
+ logging.error('build_data did not contain any steps') |
+ return False |
+ for step in build_data['steps']: |
+ for field in step_fields: |
+ if field not in step: |
+ logging.error('build_step did not contain field %s' % field) |
+ return False |
+ |
+ return True |
+ |
+ def post(self): |
+ blob = self.request.get('json') |
+ if not blob: |
+ self.response.out.write('no json data sent') |
+ logging.error('error no json sent') |
+ self.error(400) |
+ return |
+ |
+ message = {} |
+ try: |
+ message = json.loads(blob) |
+ except ValueError as e: |
+ self.response.out.write('couldn\'t decode json') |
+ logging.error('error decoding incoming json: %s' % e) |
+ self.error(400) |
+ return |
+ |
+ secret = MailerSecret.get_or_insert('mailer_secret').secret |
+ if not secret: |
+ self.response.out.write('unauthorized') |
+ logging.critical('mailer shared secret has not been set!') |
+ self.error(500) |
+ return |
+ |
+ if not self._validate_message(message, self.request.url, secret): |
+ self.response.out.write('unauthorized') |
+ logging.error('incoming message did not validate') |
+ self.error(403) |
+ return |
+ |
+ try: |
+ build_data = json.loads(message['message']) |
+ except ValueError as e: |
+ self.response.out.write('couldn\'t decode payload json') |
+ logging.error('error decoding incoming json: %s' % e) |
+ self.error(400) |
+ return |
+ |
+ if not self._verify_json(build_data): |
+ logging.error('error verifying incoming json: %s' % build_data) |
+ self.response.out.write('json build format is incorrect') |
+ self.error(400) |
+ return |
+ |
+ # Emails can only come from the app ID, so we split on '@' here just in |
+ # case the user specified a full email address. |
+ from_addr_prefix = build_data.get('from_addr', 'buildbot').split('@')[0] |
+ from_addr = from_addr_prefix + '@%s.appspotmail.com' % ( |
+ app_identity.get_application_id()) |
+ |
+ recipients = ', '.join(build_data['recipients']) |
+ |
+ template = gatekeeper_mailer.MailTemplate(build_data['waterfall_url'], |
+ build_data['build_url'], |
+ build_data['project_name'], |
+ from_addr) |
+ |
+ |
+ text_content, html_content, subject = template.genMessageContent(build_data) |
+ |
+ message = mail.EmailMessage(sender=from_addr, |
+ subject=subject, |
+ #to=recipients, |
+ to=['xusydoc@chromium.org'], |
+ body=text_content, |
+ html=html_content) |
+ logging.info('sending email to %s', recipients) |
+ logging.info('sending from %s', from_addr) |
+ logging.info('subject is %s', subject) |
+ message.send() |
+ self.response.out.write('email sent') |
+ |
+ |
+app = webapp2.WSGIApplication([('/mailer', MainPage), |
+ ('/mailer/email', Email)], |
+ debug=True) |