Repository: django-compressor/django-compressor

Issue:
CSS URL_PATTERN regex will choke on nested parenthesis (e.g. another URL)
Consider this valid CSS which uses SVG URL:

```
body { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><linearGradient id='gradient'><stop offset='10%' stop-color='%23F00'/><stop offset='90%' stop-color='%23fcc'/> </linearGradient><rect fill='url(%23gradient)' x='0' y='0' width='100%' height='100%'/></svg>");}
```

The [URL_PATTERN](https://github.com/django-compressor/django-compressor/blob/develop/compressor/filters/css_default.py#L9) will choke and resulting in an improper match because it will stop at first closing parentheses `)` and gives:

```
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><linearGradient id='gradient'><stop offset='10%' stop-color='%23F00'/><stop offset='90%' stop-color='%23fcc'/> </linearGradient><rect fill='url(%23gradient
```

Instead of returning the full URL till `</svg>"` because if you check the SVG has `fill` property which is also a URL.

CSS URL_PATTERN regex will choke on nested parenthesis (e.g. another URL)
Consider this valid CSS which uses SVG URL:

```
body { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><linearGradient id='gradient'><stop offset='10%' stop-color='%23F00'/><stop offset='90%' stop-color='%23fcc'/> </linearGradient><rect fill='url(%23gradient)' x='0' y='0' width='100%' height='100%'/></svg>");}
```

The [URL_PATTERN](https://github.com/django-compressor/django-compressor/blob/develop/compressor/filters/css_default.py#L9) will choke and resulting in an improper match because it will stop at first closing parentheses `)` and gives:

```
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'><linearGradient id='gradient'><stop offset='10%' stop-color='%23F00'/><stop offset='90%' stop-color='%23fcc'/> </linearGradient><rect fill='url(%23gradient
```

Instead of returning the full URL till `</svg>"` because if you check the SVG has `fill` property which is also a URL.



Discussion:

Resolving patch:
diff --git a/compressor/filters/css_default.py b/compressor/filters/css_default.py
--- a/compressor/filters/css_default.py
+++ b/compressor/filters/css_default.py
@@ -6,9 +6,16 @@
 from compressor.conf import settings
 from compressor.filters import FilterBase, FilterError
 
-URL_PATTERN = re.compile(r'url\(([^\)]+)\)')
-SRC_PATTERN = re.compile(r'src=([\'"])(.+?)\1')
-SCHEMES = ('http://', 'https://', '/', 'data:')
+URL_PATTERN = re.compile(r"""
+    url\(
+    \s*      # any amount of whitespace
+    ([\'"]?) # optional quote
+    (.*?)    # any amount of anything, non-greedily (this is the actual url)
+    \1       # matching quote (or nothing if there was none)
+    \s*      # any amount of whitespace
+    \)""", re.VERBOSE)
+SRC_PATTERN = re.compile(r'src=([\'"])(.*?)\1')
+SCHEMES = ('http://', 'https://', '/')
 
 
 class CssAbsoluteFilter(FilterBase):
@@ -56,51 +63,50 @@ def guess_filename(self, url):
 
     def add_suffix(self, url):
         filename = self.guess_filename(url)
+        if not filename:
+            return url
+        if settings.COMPRESS_CSS_HASHING_METHOD is None:
+            return url
+        if not url.startswith(SCHEMES):
+            return url
+
         suffix = None
-        if filename:
-            if settings.COMPRESS_CSS_HASHING_METHOD == "mtime":
-                suffix = get_hashed_mtime(filename)
-            elif settings.COMPRESS_CSS_HASHING_METHOD in ("hash", "content"):
-                suffix = get_hashed_content(filename)
-            elif settings.COMPRESS_CSS_HASHING_METHOD is None:
-                suffix = None
-            else:
-                raise FilterError('COMPRESS_CSS_HASHING_METHOD is configured '
+        if settings.COMPRESS_CSS_HASHING_METHOD == "mtime":
+            suffix = get_hashed_mtime(filename)
+        elif settings.COMPRESS_CSS_HASHING_METHOD in ("hash", "content"):
+            suffix = get_hashed_content(filename)
+        else:
+            raise FilterError('COMPRESS_CSS_HASHING_METHOD is configured '
                                   'with an unknown method (%s).' %
                                   settings.COMPRESS_CSS_HASHING_METHOD)
-        if suffix is None:
-            return url
-        if url.startswith(SCHEMES):
-            fragment = None
-            if "#" in url:
-                url, fragment = url.rsplit("#", 1)
-            if "?" in url:
-                url = "%s&%s" % (url, suffix)
-            else:
-                url = "%s?%s" % (url, suffix)
-            if fragment is not None:
-                url = "%s#%s" % (url, fragment)
+        fragment = None
+        if "#" in url:
+            url, fragment = url.rsplit("#", 1)
+        if "?" in url:
+            url = "%s&%s" % (url, suffix)
+        else:
+            url = "%s?%s" % (url, suffix)
+        if fragment is not None:
+            url = "%s#%s" % (url, fragment)
         return url
 
-    def _converter(self, matchobj, group, template):
-        url = matchobj.group(group)
-
-        url = url.strip()
-        wrap = '"' if url[0] == '"' else "'"
-        url = url.strip('\'"')
-
-        if url.startswith('#'):
-            return template % (wrap, url, wrap)
+    def _converter(self, url):
+        if url.startswith(('#', 'data:')):
+            return url
         elif url.startswith(SCHEMES):
-            return template % (wrap, self.add_suffix(url), wrap)
+            return self.add_suffix(url)
         full_url = posixpath.normpath('/'.join([str(self.directory_name),
                                                 url]))
         if self.has_scheme:
             full_url = "%s%s" % (self.protocol, full_url)
-        return template % (wrap, self.add_suffix(full_url), wrap)
+        return self.add_suffix(full_url)
 
     def url_converter(self, matchobj):
-        return self._converter(matchobj, 1, "url(%s%s%s)")
+        quote = matchobj.group(1)
+        converted_url = self._converter(matchobj.group(2))
+        return "url(%s%s%s)" % (quote, converted_url, quote)
 
     def src_converter(self, matchobj):
-        return self._converter(matchobj, 2, "src=%s%s%s")
+        quote = matchobj.group(1)
+        converted_url = self._converter(matchobj.group(2))
+        return "src=%s%s%s" % (quote, converted_url, quote)


Test patch:
diff --git a/compressor/tests/test_filters.py b/compressor/tests/test_filters.py
--- a/compressor/tests/test_filters.py
+++ b/compressor/tests/test_filters.py
@@ -214,12 +214,6 @@ def setUp(self):
         self.override_settings = self.settings(COMPRESS_CSS_HASHING_METHOD=self.hashing_method)
         self.override_settings.__enter__()
 
-        self.css = """
-        <link rel="stylesheet" href="/static/css/url/url1.css" type="text/css">
-        <link rel="stylesheet" href="/static/css/url/2/url2.css" type="text/css">
-        """
-        self.css_node = CssCompressor(self.css)
-
     def tearDown(self):
         self.override_settings.__exit__(None, None, None)
 
@@ -316,21 +310,27 @@ def test_css_hunks(self):
 
         css1 = """\
 p { background: url('%(compress_url)simg/python.png?%(hash)s'); }
-p { background: url('%(compress_url)simg/python.png?%(hash)s'); }
-p { background: url('%(compress_url)simg/python.png?%(hash)s'); }
+p { background: url(%(compress_url)simg/python.png?%(hash)s); }
+p { background: url(%(compress_url)simg/python.png?%(hash)s); }
 p { background: url('%(compress_url)simg/python.png?%(hash)s'); }
 p { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='%(compress_url)simg/python.png?%(hash)s'); }
 """ % dict(compress_url=settings.COMPRESS_URL, hash=hash_python_png)
 
         css2 = """\
 p { background: url('%(compress_url)simg/add.png?%(hash)s'); }
-p { background: url('%(compress_url)simg/add.png?%(hash)s'); }
-p { background: url('%(compress_url)simg/add.png?%(hash)s'); }
+p { background: url(%(compress_url)simg/add.png?%(hash)s); }
+p { background: url(%(compress_url)simg/add.png?%(hash)s); }
 p { background: url('%(compress_url)simg/add.png?%(hash)s'); }
 p { filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='%(compress_url)simg/add.png?%(hash)s'); }
 """ % dict(compress_url=settings.COMPRESS_URL, hash=hash_add_png)
 
-        self.assertEqual([css1, css2], list(self.css_node.hunks()))
+        css = """
+        <link rel="stylesheet" href="/static/css/url/url1.css" type="text/css">
+        <link rel="stylesheet" href="/static/css/url/2/url2.css" type="text/css">
+        """
+        css_node = CssCompressor(css)
+
+        self.assertEqual([css1, css2], list(css_node.hunks()))
 
     def test_guess_filename(self):
         url = '%s/img/python.png' % settings.COMPRESS_URL.rstrip('/')
@@ -354,6 +354,19 @@ def test_filenames_with_space(self):
         filter = CssAbsoluteFilter(content)
         self.assertEqual(output, filter.input(filename=filename, basename='css/url/test.css'))
 
+    def test_does_not_change_nested_urls(self):
+        css = """body { background-image: url("data:image/svg+xml;utf8,<svg><rect fill='url(%23gradient)'/></svg>");}"""
+        filter = CssAbsoluteFilter(css, filename="doesntmatter")
+        self.assertEqual(css, filter.input(filename="doesntmatter", basename="doesntmatter"))
+
+    def test_does_not_change_quotes_in_src(self):
+        filename = os.path.join(settings.COMPRESS_ROOT, 'css/url/test.css')
+        hash_add_png = self.hashing_func(os.path.join(settings.COMPRESS_ROOT, 'img/add.png'))
+        css = """p { filter: Alpha(src="/img/add.png%(hash)s") }"""
+        filter = CssAbsoluteFilter(css % dict(hash=""))
+        expected = css % dict(hash='?' + hash_add_png)
+        self.assertEqual(expected, filter.input(filename=filename, basename='css/url/test.css'))
+
 
 @override_settings(COMPRESS_URL='http://static.example.com/')
 class CssAbsolutizingTestCaseWithDifferentURL(CssAbsolutizingTestCase):