Repository: sissbruecker/linkding

Issue:
Custom CSS Escapes Quotes and other symbols
Linkding Version: 1.35.0
Deployment: Docker
Browser: Chromium 129

I noticed today while trying out the custom CSS feature that it escapes single and double quotes in the produced CSS which breaks anything that needs quotes. In this example it's `content` but it can also sometimes be necessary for things like font-family etc. 

Content in the Custom CSS textarea:

```
h1:after {
  content: 'Test String';
  display: block;
}
```
Content in the page HTML:
```

<style>h1:after {
  content: &#x27;Test String&#x27;;
  display: block;
  }</style>

```


Discussion:
Indeed, the content is escaped like any other text in a template. Even though the custom CSS setting is user specific I feel there should be some form of escaping to prevent injecting HTML tags. I gave it a quick try with [bleach](https://bleach.readthedocs.io/en/latest/index.html) which leaves quotes as is, but then escapes `<` and `>`.

Maybe it would be better to use a `link` tag to load the custom CSS through a URL instead of inlining it into a `style` tag. That would link to a view / route that then returns the custom CSS. That would require some work to prevent the CSS from getting cached after changing it in the settings.



Patch:
diff --git a/bookmarks/migrations/0042_userprofile_custom_css_hash.py b/bookmarks/migrations/0042_userprofile_custom_css_hash.py
new file mode 100644
index 00000000..bc027ac8
--- /dev/null
+++ b/bookmarks/migrations/0042_userprofile_custom_css_hash.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.1.1 on 2024-09-28 08:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("bookmarks", "0041_merge_metadata"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="userprofile",
+            name="custom_css_hash",
+            field=models.CharField(blank=True, max_length=32),
+        ),
+    ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index 2b94cfac..fe4530bb 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -1,4 +1,5 @@
 import binascii
+import hashlib
 import logging
 import os
 from typing import List
@@ -430,6 +431,7 @@ class UserProfile(models.Model):
     display_remove_bookmark_action = models.BooleanField(default=True, null=False)
     permanent_notes = models.BooleanField(default=False, null=False)
     custom_css = models.TextField(blank=True, null=False)
+    custom_css_hash = models.CharField(blank=True, null=False, max_length=32)
     auto_tagging_rules = models.TextField(blank=True, null=False)
     search_preferences = models.JSONField(default=dict, null=False)
     enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
@@ -439,6 +441,15 @@ class UserProfile(models.Model):
     )
     sticky_pagination = models.BooleanField(default=False, null=False)
 
+    def save(self, *args, **kwargs):
+        if self.custom_css:
+            self.custom_css_hash = hashlib.md5(
+                self.custom_css.encode("utf-8")
+            ).hexdigest()
+        else:
+            self.custom_css_hash = ""
+        super().save(*args, **kwargs)
+
 
 class UserProfileForm(forms.ModelForm):
     class Meta:
diff --git a/bookmarks/templates/bookmarks/head.html b/bookmarks/templates/bookmarks/head.html
index 61dd8fb6..b28a1caf 100644
--- a/bookmarks/templates/bookmarks/head.html
+++ b/bookmarks/templates/bookmarks/head.html
@@ -30,7 +30,7 @@
     <meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
   {% endif %}
   {% if request.user_profile.custom_css %}
-    <style>{{ request.user_profile.custom_css }}</style>
+    <link href="{% url 'bookmarks:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
   {% endif %}
   <meta name="turbo-cache-control" content="no-preview">
   {% if not request.global_settings.enable_link_prefetch %}
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index 97b8ab1f..0372e192 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -65,4 +65,6 @@
     path("health", views.health, name="health"),
     # Manifest
     path("manifest.json", views.manifest, name="manifest"),
+    # Custom CSS
+    path("custom_css", views.custom_css, name="custom_css"),
 ]
diff --git a/bookmarks/views/__init__.py b/bookmarks/views/__init__.py
index b9a9f0e4..53d66ad1 100644
--- a/bookmarks/views/__init__.py
+++ b/bookmarks/views/__init__.py
@@ -4,4 +4,5 @@
 from .toasts import *
 from .health import health
 from .manifest import manifest
+from .custom_css import custom_css
 from .root import root
diff --git a/bookmarks/views/custom_css.py b/bookmarks/views/custom_css.py
new file mode 100644
index 00000000..d2b7ebc5
--- /dev/null
+++ b/bookmarks/views/custom_css.py
@@ -0,0 +1,10 @@
+from django.http import HttpResponse
+
+custom_css_cache_max_age = 2592000  # 30 days
+
+
+def custom_css(request):
+    css = request.user_profile.custom_css
+    response = HttpResponse(css, content_type="text/css")
+    response["Cache-Control"] = f"public, max-age={custom_css_cache_max_age}"
+    return response


Test patch:
diff --git a/bookmarks/tests/test_custom_css.py b/bookmarks/tests/test_custom_css.py
deleted file mode 100644
index ce0e91ea..00000000
--- a/bookmarks/tests/test_custom_css.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from django.test import TestCase
-from django.urls import reverse
-
-from bookmarks.tests.helpers import BookmarkFactoryMixin
-
-
-class CustomCssTestCase(TestCase, BookmarkFactoryMixin):
-    def setUp(self):
-        self.client.force_login(self.get_or_create_test_user())
-
-    def test_does_not_render_custom_style_tag_by_default(self):
-        response = self.client.get(reverse("bookmarks:index"))
-        self.assertNotContains(response, "<style>")
-
-    def test_renders_custom_style_tag_if_user_has_custom_css(self):
-        profile = self.get_or_create_test_user().profile
-        profile.custom_css = "body { background-color: red; }"
-        profile.save()
-
-        response = self.client.get(reverse("bookmarks:index"))
-        self.assertContains(response, "<style>body { background-color: red; }</style>")
diff --git a/bookmarks/tests/test_custom_css_view.py b/bookmarks/tests/test_custom_css_view.py
new file mode 100644
index 00000000..e5d4c070
--- /dev/null
+++ b/bookmarks/tests/test_custom_css_view.py
@@ -0,0 +1,28 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class CustomCssViewTestCase(TestCase, BookmarkFactoryMixin):
+    def setUp(self) -> None:
+        user = self.get_or_create_test_user()
+        self.client.force_login(user)
+
+    def test_with_empty_css(self):
+        response = self.client.get(reverse("bookmarks:custom_css"))
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response["Content-Type"], "text/css")
+        self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000")
+        self.assertEqual(response.content.decode(), "")
+
+    def test_with_custom_css(self):
+        css = "body { background-color: red; }"
+        self.user.profile.custom_css = css
+        self.user.profile.save()
+
+        response = self.client.get(reverse("bookmarks:custom_css"))
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response["Content-Type"], "text/css")
+        self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000")
+        self.assertEqual(response.content.decode(), css)
diff --git a/bookmarks/tests/test_layout.py b/bookmarks/tests/test_layout.py
index e7fb2636..70122c02 100644
--- a/bookmarks/tests/test_layout.py
+++ b/bookmarks/tests/test_layout.py
@@ -2,10 +2,10 @@
 from django.urls import reverse
 
 from bookmarks.models import GlobalSettings
-from bookmarks.tests.helpers import BookmarkFactoryMixin
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
 
 
-class LayoutTestCase(TestCase, BookmarkFactoryMixin):
+class LayoutTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
 
     def setUp(self) -> None:
         user = self.get_or_create_test_user()
@@ -63,3 +63,38 @@ def test_metadata_should_respect_prefetch_links_setting(self):
             html,
             count=0,
         )
+
+    def test_does_not_link_custom_css_when_empty(self):
+        response = self.client.get(reverse("bookmarks:index"))
+        html = response.content.decode()
+        soup = self.make_soup(html)
+
+        link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
+        self.assertIsNone(link)
+
+    def test_does_link_custom_css_when_not_empty(self):
+        profile = self.get_or_create_test_user().profile
+        profile.custom_css = "body { background-color: red; }"
+        profile.save()
+
+        response = self.client.get(reverse("bookmarks:index"))
+        html = response.content.decode()
+        soup = self.make_soup(html)
+
+        link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
+        self.assertIsNotNone(link)
+
+    def test_custom_css_link_href(self):
+        profile = self.get_or_create_test_user().profile
+        profile.custom_css = "body { background-color: red; }"
+        profile.save()
+
+        response = self.client.get(reverse("bookmarks:index"))
+        html = response.content.decode()
+        soup = self.make_soup(html)
+
+        link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
+        expected_url = (
+            reverse("bookmarks:custom_css") + f"?hash={profile.custom_css_hash}"
+        )
+        self.assertEqual(link["href"], expected_url)
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index 534b952c..10f5cf72 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -1,3 +1,4 @@
+import hashlib
 import random
 from unittest.mock import patch, Mock
 
@@ -217,6 +218,31 @@ def test_update_profile_should_not_be_called_without_respective_form_action(self
         self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
         self.assertSuccessMessage(html, "Profile updated", count=0)
 
+    def test_update_profile_updates_custom_css_hash(self):
+        form_data = self.create_profile_form_data(
+            {
+                "custom_css": "body { background-color: #000; }",
+            }
+        )
+        self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
+        self.user.profile.refresh_from_db()
+
+        expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest()
+        self.assertEqual(expected_hash, self.user.profile.custom_css_hash)
+
+        form_data["custom_css"] = "body { background-color: #fff; }"
+        self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
+        self.user.profile.refresh_from_db()
+
+        expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest()
+        self.assertEqual(expected_hash, self.user.profile.custom_css_hash)
+
+        form_data["custom_css"] = ""
+        self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
+        self.user.profile.refresh_from_db()
+
+        self.assertEqual("", self.user.profile.custom_css_hash)
+
     def test_enable_favicons_should_schedule_icon_update(self):
         with patch.object(
             tasks, "schedule_bookmarks_without_favicons"