Repository: getsentry/sentry

Issue:
Don't attempt to parse HTML documents as JS
We have observed more than a few times that some websites are returning 200 OK for "Not Found" HTML documents. As a result, Sentry thinks this is a JavaScript file and attempts to parse it / perform sourcemap transforms. This leads to issues like this:

![image](https://cloud.githubusercontent.com/assets/2153/16697806/3d78acfc-4500-11e6-9602-daa6abcf300a.png)

This isn't a straight forward solution, because what if you have an error originating from a 404 page? That page could have inline `<script>` tags where the error is occurring, so in that case we'll want to parse the page and apply it. We could maybe discriminate a little more depending on the type of file (i.e. JS vs HTML), but even then, we can't guarantee the "type" of a file based on extension or content-type.



Discussion:
~~Pretty confused, because this is [already in the app](https://github.com/getsentry/sentry/blob/master/src/sentry/lang/javascript/processor.py#L359), and [there's a test for it](https://github.com/getsentry/sentry/blob/master/tests/sentry/lang/javascript/test_plugin.py#L529).~~

~~Can anyone think of a scenario where this wouldn't occur? It could be possible that for a brief period, the server belonging to the screenshot above _did_ return 200 for the JS file in question (it's 404 now).~~

Update – originally this ticket assumed that we were scraping 404 pages, but it turns out these pages are returning 200 OK for Not Found pages. Updated title appropriately.

My current thinking is that we just do some light checking of the start of the document, i.e. check for `<!DOCTYPE` and `<!doctype`, and bail out if found.


Resolving patch:
diff --git a/src/sentry/lang/javascript/processor.py b/src/sentry/lang/javascript/processor.py
--- a/src/sentry/lang/javascript/processor.py
+++ b/src/sentry/lang/javascript/processor.py
@@ -376,6 +376,21 @@ def fetch_file(url, project=None, release=None, allow_scraping=True):
         }
         raise CannotFetchSource(error)
 
+    # For JavaScript files, check if content is something other than JavaScript/JSON (i.e. HTML)
+    # NOTE: possible to have JS files that don't actually end w/ ".js", but this should catch 99% of cases
+    if url.endswith('.js'):
+        # Check if response is HTML by looking if the first non-whitespace character is an open tag ('<').
+        # This cannot parse as valid JS/JSON.
+        # NOTE: not relying on Content-Type header because apps often don't set this correctly
+        body_start = result[1][:20].lstrip()  # Discard leading whitespace (often found before doctype)
+
+        if body_start[:1] == '<':
+            error = {
+                'type': EventError.JS_INVALID_CONTENT,
+                'url': url,
+            }
+            raise CannotFetchSource(error)
+
     # Make sure the file we're getting back is unicode, if it's not,
     # it's either some encoding that we don't understand, or it's binary
     # data which we can't process.
diff --git a/src/sentry/models/eventerror.py b/src/sentry/models/eventerror.py
--- a/src/sentry/models/eventerror.py
+++ b/src/sentry/models/eventerror.py
@@ -11,13 +11,13 @@ class EventError(object):
 
     JS_GENERIC_FETCH_ERROR = 'js_generic_fetch_error'
     JS_INVALID_HTTP_CODE = 'js_invalid_http_code'
+    JS_INVALID_CONTENT = 'js_invalid_content'
     JS_NO_COLUMN = 'js_no_column'
     JS_MISSING_SOURCE = 'js_no_source'
     JS_INVALID_SOURCEMAP = 'js_invalid_source'
     JS_TOO_MANY_REMOTE_SOURCES = 'js_too_many_sources'
     JS_INVALID_SOURCE_ENCODING = 'js_invalid_source_encoding'
     JS_INVALID_SOURCEMAP_LOCATION = 'js_invalid_sourcemap_location'
-
     NATIVE_NO_CRASHED_THREAD = 'native_no_crashed_thread'
     NATIVE_INTERNAL_FAILURE = 'native_internal_failure'
     NATIVE_NO_SYMSYND = 'native_no_symsynd'
@@ -31,6 +31,7 @@ class EventError(object):
         RESTRICTED_IP: u'Cannot fetch resource due to restricted IP address on {url}',
         JS_GENERIC_FETCH_ERROR: u'Unable to fetch resource: {url}',
         JS_INVALID_HTTP_CODE: u'HTTP returned {value} response on {url}',
+        JS_INVALID_CONTENT: u'Source file was not JavaScript: {url}',
         JS_NO_COLUMN: u'Cannot expand sourcemap due to no column information for {url}',
         JS_MISSING_SOURCE: u'Source code was not found for {url}',
         JS_INVALID_SOURCEMAP: u'Sourcemap was invalid or not parseable: {url}',


Test patch:
diff --git a/tests/sentry/lang/javascript/test_plugin.py b/tests/sentry/lang/javascript/test_plugin.py
--- a/tests/sentry/lang/javascript/test_plugin.py
+++ b/tests/sentry/lang/javascript/test_plugin.py
@@ -643,3 +643,53 @@ def test_failed_sourcemap_expansion_data_url(self):
 
         event = Event.objects.get()
         assert event.data['errors'] == [{'url': u'<data url>', 'type': 'js_no_source'}]
+
+    @responses.activate
+    def test_html_response_for_js(self):
+        responses.add(responses.GET, 'http://example.com/file1.js',
+                      body='       <!DOCTYPE html><html><head></head><body></body></html>')
+        responses.add(responses.GET, 'http://example.com/file2.js',
+                      body='<!doctype html><html><head></head><body></body></html>')
+        responses.add(responses.GET, 'http://example.com/file.html',
+                      body='<!doctype html><html><head></head><body><script>/*legit case*/</script></body></html>')
+
+        data = {
+            'message': 'hello',
+            'platform': 'javascript',
+            'sentry.interfaces.Exception': {
+                'values': [{
+                    'type': 'Error',
+                    'stacktrace': {
+                        'frames': [
+                            {
+                                'abs_path': 'http://example.com/file1.js',
+                                'filename': 'file.min.js',
+                                'lineno': 1,
+                                'colno': 39,
+                            },
+                            {
+                                'abs_path': 'http://example.com/file2.js',
+                                'filename': 'file.min.js',
+                                'lineno': 1,
+                                'colno': 39,
+                            },
+                            {
+                                'abs_path': 'http://example.com/file.html',
+                                'filename': 'file.html',
+                                'lineno': 1,
+                                'colno': 1,
+                            },
+                        ],
+                    },
+                }],
+            }
+        }
+
+        resp = self._postWithHeader(data)
+        assert resp.status_code, 200
+
+        event = Event.objects.get()
+        assert event.data['errors'] == [
+            {'url': u'http://example.com/file1.js', 'type': 'js_invalid_content'},
+            {'url': u'http://example.com/file2.js', 'type': 'js_invalid_content'}
+        ]