Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ def create_partitioned_queues(name):
'template': 'sentry.interfaces.template.Template',
'query': 'sentry.interfaces.query.Query',
'user': 'sentry.interfaces.user.User',
'csp': 'sentry.interfaces.csp.Csp',

'sentry.interfaces.Exception': 'sentry.interfaces.exception.Exception',
'sentry.interfaces.Message': 'sentry.interfaces.message.Message',
Expand All @@ -664,6 +665,7 @@ def create_partitioned_queues(name):
'sentry.interfaces.Query': 'sentry.interfaces.query.Query',
'sentry.interfaces.Http': 'sentry.interfaces.http.Http',
'sentry.interfaces.User': 'sentry.interfaces.user.User',
'sentry.interfaces.Csp': 'sentry.interfaces.csp.Csp',
}

# Should users without superuser permissions be allowed to
Expand Down
37 changes: 37 additions & 0 deletions src/sentry/coreapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
MAX_TAG_KEY_LENGTH
)
from sentry.interfaces.base import get_interface, InterfaceValidationError
from sentry.interfaces.csp import Csp
from sentry.models import EventError, Project, ProjectKey
from sentry.tasks.store import preprocess_event
from sentry.utils import is_float, json
Expand Down Expand Up @@ -569,3 +570,39 @@ def insert_data_to_database(self, data):
cache_key = 'e:{1}:{0}'.format(data['project'], data['event_id'])
default_cache.set(cache_key, data, timeout=3600)
preprocess_event.delay(cache_key=cache_key, start_time=time())


class CspApiHelper(ClientApiHelper):
def origin_from_request(self, request):
# We don't use an origin here
return None

def validate_data(self, project, data):
# All keys are sent with hyphens, so we want to conver to underscores
report = dict(map(lambda v: (v[0].replace('-', '_'), v[1]), data.iteritems()))
inst = Csp.to_python(report)

# Construct a faux Http interface based on the little information we have
headers = {}
if self.context.agent:
headers['User-Agent'] = self.context.agent
if inst.referrer:
headers['Referer'] = inst.referrer

return {
'project': project.id,
'message': inst.get_message(),
'culprit': inst.get_culprit(),
inst.get_path(): inst.to_json(),
# This is a bit weird, since we don't have nearly enough
# information to create an Http interface, but
# this automatically will pick up tags for the User-Agent
# which is actually important here for CSP
'sentry.interfaces.Http': {
'url': inst.document_uri,
'headers': headers,
},
'sentry.interfaces.User': {
'ip_address': self.context.ip_address,
}
}
119 changes: 119 additions & 0 deletions src/sentry/interfaces/csp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""
sentry.interfaces.csp
~~~~~~~~~~~~~~~~~~~~~

:copyright: (c) 2010-2015 by the Sentry Team, see AUTHORS for more details.
:license: BSD, see LICENSE for more details.
"""

from __future__ import absolute_import

__all__ = ('Csp',)

from urlparse import urlsplit
from sentry.interfaces.base import Interface
from sentry.utils.safe import trim


# Sourced from https://developer.mozilla.org/en-US/docs/Web/Security/CSP/CSP_policy_directives
REPORT_KEYS = frozenset((
'blocked_uri', 'document_uri', 'effective_directive', 'original_policy',
'referrer', 'status_code', 'violated_directive', 'source_file',
'line_number', 'column_number',

# FireFox specific keys
'script_sample',
))

KEYWORDS = frozenset((
"'none'", "'self'", "'unsafe-inline'", "'unsafe-eval'",
))

ALL_SCHEMES = (
'data:', 'mediastream:', 'blob:', 'filesystem:',
'http:', 'https:', 'file:',
)


class Csp(Interface):
"""
A CSP violation report.

See also: http://www.w3.org/TR/CSP/#violation-reports

>>> {
>>> "document_uri": "http://example.com/",
>>> "violated_directive": "style-src cdn.example.com",
>>> "blocked_uri": "http://example.com/style.css",
>>> }
"""
@classmethod
def to_python(cls, data):
kwargs = {k: trim(data.get(k, None), 1024) for k in REPORT_KEYS}
# Inline script violations are confusing and don't say what uri blocked them
# because they're inline. FireFox sends along "blocked-uri": "self", which is
# vastly more useful, so we want to emulate that
if kwargs['effective_directive'] == 'script-src' and not kwargs['blocked_uri']:
kwargs['blocked_uri'] = 'self'
return cls(**kwargs)

def get_hash(self):
# The hash of a CSP report is it's normalized `violated-directive`.
# This normalization has to be done for FireFox because they send
# weird stuff compared to Safari and Chrome.
# NOTE: this may or may not be great, not sure until we see it in the wild
return [':'.join(self.get_violated_directive()), ':'.join(self.get_culprit_directive())]

def get_violated_directive(self):
return 'violated-directive', self._normalize_directive(self.violated_directive)

def get_culprit_directive(self):
if self.blocked_uri:
return 'blocked-uri', self.blocked_uri
return 'effective-directive', self._normalize_directive(self.effective_directive)

def get_path(self):
return 'sentry.interfaces.Csp'

def get_message(self):
return 'CSP Violation: %s %r' % self.get_culprit_directive()

def get_culprit(self):
return '%s in %r' % self.get_violated_directive()

def _normalize_directive(self, directive):
if not directive:
return directive
bits = filter(None, directive.split(' '))
return ' '.join([bits[0]] + map(self._normalize_value, bits[1:]))

def _normalize_value(self, value):
# > If no scheme is specified, the same scheme as the one used to
# > access the protected document is assumed.
# Source: https://developer.mozilla.org/en-US/docs/Web/Security/CSP/CSP_policy_directives
if value in KEYWORDS:
return value

# normalize a value down to 'self' if it matches the origin of document-uri
# FireFox transforms a 'self' value into the spelled out origin, so we
# want to reverse this and bring it back
if value.startswith(ALL_SCHEMES):
if _get_origin(self.document_uri) == value:
return "'self'"
return value

# Now we need to stitch on a scheme to the value
scheme = self.document_uri.split(':', 1)[0]
# These schemes need to have an additional '//' to be a url
if scheme in ('http', 'https', 'file'):
return '%s://%s' % (scheme, value)
# The others do not
return '%s:%s' % (scheme, value)


def _get_origin(value):
"Extract the origin out of a url, which is just scheme+host"
scheme, hostname = urlsplit(value)[:2]
if scheme in ('http', 'https', 'file'):
return '%s://%s' % (scheme, hostname)
return '%s:%s' % (scheme, hostname)
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ var EventEntries = React.createClass({
exception: require("./interfaces/exception"),
request: require("./interfaces/request"),
stacktrace: require("./interfaces/stacktrace"),
template: require("./interfaces/template")
template: require("./interfaces/template"),
csp: require("./interfaces/csp"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trailing comma is invalid // @benvinegar how do we get lint for this

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not invalid. Only IE7 and below throw error on this.

On Monday, October 12, 2015, David Cramer notifications@github.com wrote:

In src/sentry/static/sentry/app/components/events/eventEntries.jsx
#2154 (comment):

@@ -31,7 +31,8 @@ var EventEntries = React.createClass({
exception: require("./interfaces/exception"),
request: require("./interfaces/request"),
stacktrace: require("./interfaces/stacktrace"),

  • template: require("./interfaces/template")
  • template: require("./interfaces/template"),
  • csp: require("./interfaces/csp"),

trailing comma is invalid // @benvinegar https://github.com/benvinegar
how do we get lint for this


Reply to this email directly or view it on GitHub
https://github.com/getsentry/sentry/pull/2154/files#r41730286.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, right. Yeah, @benvinegar, we talked about this before. You have a preference of no trailing commas just as convention, but a linter to enforce would be nice for those of us who don't do that instinctively. :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I added the rule to raven-js. I can do the same here.

},

shouldComponentUpdate(nextProps, nextState) {
Expand Down
35 changes: 35 additions & 0 deletions src/sentry/static/sentry/app/components/events/interfaces/csp.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from "react";
import _ from "underscore";
import PropTypes from "../../../proptypes";

import EventDataSection from "../eventDataSection";
import DefinitionList from "./definitionList";

var CSPInterface = React.createClass({
propTypes: {
group: PropTypes.Group.isRequired,
event: PropTypes.Event.isRequired,
type: React.PropTypes.string.isRequired,
data: React.PropTypes.object.isRequired,
},

render() {
let {group, event, data} = this.props;

let extraDataArray = _.chain(data)
.map((val, key) => [key.replace(/_/g, '-'), val])
.value();

return (
<EventDataSection
group={group}
event={event}
type="csp"
title="CSP Report">
<DefinitionList data={extraDataArray} isContextData={true}/>
</EventDataSection>
);
}
});

export default CSPInterface;
15 changes: 15 additions & 0 deletions src/sentry/testutils/cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@ def _postWithHeader(self, data, key=None, secret=None, protocol=None):
)
return resp

def _postCspWithHeader(self, data, key=None, **extra):
if isinstance(data, dict):
body = json.dumps({'csp-report': data})
elif isinstance(data, basestring):
body = data
path = reverse('sentry-api-csp-report', kwargs={'project_id': self.project.id})
path += '?sentry_key=%s&sentry_version=5' % self.projectkey.public_key
with self.tasks():
return self.client.post(
path, data=body,
content_type='application/csp-report',
HTTP_USER_AGENT='awesome',
**extra
)

def _getWithReferer(self, data, key=None, referer='getsentry.com', protocol='4'):
if key is None:
key = self.projectkey.public_key
Expand Down
97 changes: 86 additions & 11 deletions src/sentry/web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from django.core.cache import cache
from django.core.urlresolvers import reverse
from django.db.models import Sum, Q
from django.http import HttpResponse, HttpResponseRedirect
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseNotAllowed
from django.utils import timezone
from django.utils.encoding import force_bytes
from django.views.decorators.cache import never_cache, cache_control
Expand All @@ -27,7 +27,7 @@
from sentry import app
from sentry.app import tsdb
from sentry.coreapi import (
APIError, APIForbidden, APIRateLimited, ClientApiHelper
APIError, APIForbidden, APIRateLimited, ClientApiHelper, CspApiHelper,
)
from sentry.event_manager import EventManager
from sentry.models import (
Expand Down Expand Up @@ -71,6 +71,8 @@ def wrapped(request, *args, **kwargs):


class APIView(BaseView):
helper_cls = ClientApiHelper

def _get_project_from_id(self, project_id):
if not project_id:
return
Expand All @@ -95,7 +97,7 @@ def _parse_header(self, request, helper, project):
@csrf_exempt
@never_cache
def dispatch(self, request, project_id=None, *args, **kwargs):
helper = ClientApiHelper(
helper = self.helper_cls(
agent=request.META.get('HTTP_USER_AGENT'),
project_id=project_id,
ip_address=request.META['REMOTE_ADDR'],
Expand Down Expand Up @@ -329,16 +331,17 @@ def process(self, request, project, auth, helper, data, **kwargs):

content_encoding = request.META.get('HTTP_CONTENT_ENCODING', '')

if content_encoding == 'gzip':
data = helper.decompress_gzip(data)
elif content_encoding == 'deflate':
data = helper.decompress_deflate(data)
elif not data.startswith('{'):
data = helper.decode_and_decompress_data(data)
data = helper.safely_load_json_string(data)
if isinstance(data, basestring):
if content_encoding == 'gzip':
data = helper.decompress_gzip(data)
elif content_encoding == 'deflate':
data = helper.decompress_deflate(data)
elif not data.startswith('{'):
data = helper.decode_and_decompress_data(data)
data = helper.safely_load_json_string(data)

# mutates data
helper.validate_data(project, data)
data = helper.validate_data(project, data)

# mutates data
manager = EventManager(data, version=auth.version)
Expand Down Expand Up @@ -378,6 +381,78 @@ def process(self, request, project, auth, helper, data, **kwargs):
return event_id


class CspReportView(StoreView):
helper_cls = CspApiHelper
content_types = ('application/csp-report', 'application/json')

def _dispatch(self, request, helper, project_id=None, origin=None,
*args, **kwargs):
# NOTE: We need to override the auth flow for a CSP report!
# A CSP report is sent as a POST request with no Origin or Referer
# header. What we're left with is a 'document-uri' key which is
# inside of the JSON body of the request. This 'document-uri' value
# should be treated as an origin check since it refers to the page
# that triggered the report. The Content-Type is supposed to be
# `application/csp-report`, but FireFox sends it as `application/json`.
if request.method != 'POST':
return HttpResponseNotAllowed(['POST'])

if request.META.get('CONTENT_TYPE') not in self.content_types:
raise APIError('Invalid Content-Type')

request.user = AnonymousUser()

project = self._get_project_from_id(project_id)
helper.context.bind_project(project)
Raven.tags_context(helper.context.get_tags_context())

# This is yanking the auth from the querystring since it's not
# in the POST body. This means we expect a `sentry_key` and
# `sentry_version` to be set in querystring
auth = self._parse_header(request, helper, project)

project_ = helper.project_from_auth(auth)
if project_ != project:
raise APIError('Two different project were specified')

helper.context.bind_auth(auth)
Raven.tags_context(helper.context.get_tags_context())

return super(APIView, self).dispatch(
request=request,
project=project,
auth=auth,
helper=helper,
**kwargs
)

def post(self, request, project, auth, helper, **kwargs):
data = helper.safely_load_json_string(request.body)

# Do origin check based on the `document-uri` key as explained
# in `_dispatch`.
try:
report = data['csp-report']
except KeyError:
raise APIError('Missing csp-report')

origin = report.get('document-uri')
if not is_valid_origin(origin, project):
raise APIForbidden('Invalid document-uri')

response_or_event_id = self.process(
request,
project=project,
auth=auth,
helper=helper,
data=report,
**kwargs
)
if isinstance(response_or_event_id, HttpResponse):
return response_or_event_id
return HttpResponse(status=201)


@never_cache
@csrf_exempt
@has_access
Expand Down
Loading