GHSA-4j9m-cxq4-9c36 - Improved validation Stored CSS Injection via tag_colour Field

This commit is contained in:
dgtlmoon
2026-08-20 16:28:31 +02:00
parent 50389b0778
commit b2b73460ab
37 changed files with 266 additions and 11 deletions
+23
View File
@@ -10,6 +10,21 @@ from . import auth
from . import validate_openapi_request, strip_internal_api_fields
def validate_tag_colour(json_data):
"""Return an error message when tag_colour is set to anything but a hex colour.
The value is rendered into a <style> block, so anything else is CSS injection.
The OpenAPI schema carries the same pattern, this is the belt to that's braces.
"""
from changedetectionio.blueprint.tags.colour import is_safe_css_colour
tag_colour = json_data.get('tag_colour')
if tag_colour and not is_safe_css_colour(tag_colour):
return "tag_colour: must be a hex colour, for example #4f8ef7"
return None
class Tag(Resource):
def __init__(self, **kwargs):
# datastore is a black box dependency
@@ -148,6 +163,10 @@ class Tag(Resource):
if unknown_fields:
return f"Unknown field(s): {', '.join(sorted(unknown_fields))}", 400
colour_error = validate_tag_colour(json_data)
if colour_error:
return colour_error, 400
tag.update(json_data)
tag.commit()
@@ -178,6 +197,10 @@ class Tag(Resource):
if unknown_fields:
return f"Unknown field(s): {', '.join(sorted(unknown_fields))}", 400
colour_error = validate_tag_colour(json_data)
if colour_error:
return colour_error, 400
new_uuid = self.datastore.add_tag(title=title)
if new_uuid:
# Apply any extra fields (e.g. processor_config_restock_diff) beyond just title
@@ -3,6 +3,7 @@ from flask import Blueprint, request, render_template, flash, url_for, redirect
from flask_babel import gettext
from loguru import logger
from changedetectionio.blueprint.tags.colour import safe_css_colour
from changedetectionio.store import ChangeDetectionStore
from changedetectionio.flask_app import login_optionally_required
from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
@@ -11,6 +12,9 @@ from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config
def construct_blueprint(datastore: ChangeDetectionStore):
tags_blueprint = Blueprint('tags', __name__, template_folder="templates")
# Used by any template that writes a tag colour into a <style> block
tags_blueprint.add_app_template_filter(safe_css_colour, 'safe_css_colour')
@tags_blueprint.route("/list", methods=['GET'])
@login_optionally_required
def tags_overview_page():
@@ -250,6 +254,13 @@ def construct_blueprint(datastore: ChangeDetectionStore):
# flash(','.join(l), 'error')
# return redirect(url_for('tags.form_tag_edit_submit', uuid=uuid))
# Until then, validate the fields that must not be taken on trust - tag_colour is
# rendered into a <style> block, where anything but a hex colour is CSS injection
if not form.tag_colour.validate(form):
for message in form.tag_colour.errors:
flash(message, 'error')
return redirect(url_for('tags.form_tag_edit', uuid=uuid))
tag.update(form.data)
tag['processor'] = 'restock_diff'
tag.commit()
@@ -0,0 +1,36 @@
"""Validation for the user supplied tag colour, which ends up inside a CSS context.
Tag colours are rendered into `<style>` blocks (watch-overview.html and
groups-overview.html). Jinja2's HTML autoescaping does not help there - CSS injection
needs none of the characters it escapes, so a value like
red} *{background-image:url(https://attacker.example.com/exfil)} .x{color:
would break out of the `background-color:` declaration and inject arbitrary rules for
every user viewing the page.
So only a plain hex colour is accepted - checked on the way in (form + API) and again
on the way out when rendering, so a value stored by an older version can't be rendered
either.
"""
import re
# What <input type="color"> produces (#rrggbb), plus the #rgb shorthand.
CSS_HEX_COLOUR_REGEX = r'^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$'
_RE_CSS_HEX_COLOUR = re.compile(CSS_HEX_COLOUR_REGEX)
def is_safe_css_colour(value) -> bool:
"""True if `value` is a hex colour that is safe to write into a CSS declaration."""
return isinstance(value, str) and bool(_RE_CSS_HEX_COLOUR.match(value.strip()))
def safe_css_colour(value) -> str:
"""Return `value` as a hex colour, or an empty string when it is not one.
Used at render time so anything unsafe falls back to the auto-generated colour
instead of being written into the stylesheet.
"""
return value.strip() if is_safe_css_colour(value) else ''
+7 -1
View File
@@ -9,6 +9,7 @@ from wtforms import (
from wtforms.fields.simple import BooleanField
from flask_babel import lazy_gettext as _l
from changedetectionio.blueprint.tags.colour import CSS_HEX_COLOUR_REGEX
from changedetectionio.processors.restock_diff.forms import processor_settings_form as restock_settings_form
from changedetectionio.llm.ui_strings import LLM_INTENT_TAG_PLACEHOLDER
from changedetectionio.llm.evaluator import (
@@ -21,7 +22,12 @@ class group_restock_settings_form(restock_settings_form):
overrides_watch = BooleanField(_l('Activate for individual watches in this tag/group?'), default=False)
url_match_pattern = StringField(_l('Auto-apply to watches with URLs matching'),
render_kw={"placeholder": _l("e.g. *://example.com/* or github.com/myorg")})
tag_colour = StringField(_l('Tag colour'), default='')
# Rendered into a <style> block, so only a plain hex colour is accepted, see colour.py
tag_colour = StringField(_l('Tag colour'),
default='',
validators=[validators.Optional(),
validators.Regexp(CSS_HEX_COLOUR_REGEX,
message=_l('Must be a hex colour, for example #4f8ef7'))])
llm_intent = TextAreaField('AI Change Intent',
validators=[validators.Optional(), validators.Length(max=2000)],
render_kw={"rows": "5", "placeholder": LLM_INTENT_TAG_PLACEHOLDER})
@@ -64,15 +64,16 @@
{% endif %}
<div class="pure-control-group">
<label>{{ _('Tag colour') }}</label>
{%- set tag_colour = data.get('tag_colour')|safe_css_colour -%}
<div style="display:flex; align-items:center; gap:0.75em;">
<input type="checkbox" id="use_custom_colour"
{% if data.get('tag_colour') %}checked{% endif %}>
{% if tag_colour %}checked{% endif %}>
<label for="use_custom_colour" style="margin:0">{{ _('Custom colour') }}</label>
<input type="color" id="tag_colour_picker"
value="{{ data.get('tag_colour') or '#4f8ef7' }}"
{% if not data.get('tag_colour') %}disabled{% endif %}>
value="{{ tag_colour or '#4f8ef7' }}"
{% if not tag_colour %}disabled{% endif %}>
<input type="hidden" name="tag_colour" id="tag_colour_hidden"
value="{{ data.get('tag_colour', '') }}">
value="{{ tag_colour }}">
</div>
<span class="pure-form-message-inline">{{ _('Leave unchecked to use the auto-generated colour based on the tag name.') }}</span>
</div>
@@ -7,8 +7,10 @@
{%- for uuid, tag in available_tags -%}
{%- if tag and tag.title -%}
{%- set class_name = tag.title|sanitize_tag_class -%}
{%- if tag.get('tag_colour') -%}
.watch-tag-list.tag-{{ class_name }} { background-color: {{ tag.tag_colour }}; color: {{ wcag_text_color(tag.tag_colour) }}; }
{#- Anything but a hex colour would inject CSS rules here, fall back to the generated colour -#}
{%- set tag_colour = tag.get('tag_colour')|safe_css_colour -%}
{%- if tag_colour -%}
.watch-tag-list.tag-{{ class_name }} { background-color: {{ tag_colour }}; color: {{ wcag_text_color(tag_colour) }}; }
{%- else -%}
{%- set colors = generate_tag_colors(tag.title) -%}
.watch-tag-list.tag-{{ class_name }} {
@@ -91,11 +91,13 @@ document.addEventListener('DOMContentLoaded', function() {
{%- for uuid, tag in tags -%}
{%- if tag and tag.title -%}
{%- set class_name = tag.title|sanitize_tag_class -%}
{%- if tag.get('tag_colour') -%}
{#- Anything but a hex colour would inject CSS rules here, fall back to the generated colour -#}
{%- set tag_colour = tag.get('tag_colour')|safe_css_colour -%}
{%- if tag_colour -%}
.button-tag.tag-{{ class_name }},
.watch-tag-list.tag-{{ class_name }} {
background-color: {{ tag.tag_colour }};
color: {{ wcag_text_color(tag.tag_colour) }};
background-color: {{ tag_colour }};
color: {{ wcag_text_color(tag_colour) }};
}
{%- else -%}
{%- set colors = generate_tag_colors(tag.title) -%}
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Tag colour is rendered into a <style> block, so it must only ever be a hex colour.
Anything else is stored CSS injection (GHSA-4j9m-cxq4-9c36) - Jinja2's HTML autoescaping
does not protect a CSS context, the payload below needs none of the characters it escapes.
Covers the three ways a colour can be set/rendered:
- the tag edit form
- the API (PUT and POST)
- rendering a value already in the datastore (from before this was validated)
"""
import json
from flask import url_for
from .util import get_UUID_for_tag_name
CSS_INJECTION = 'red} *{background-image:url(https://attacker.example.com/exfil)} .x{color:'
def _add_tag(client, name):
res = client.post(url_for("tags.form_tag_add"), data={"name": name}, follow_redirects=True)
assert b"Tag added" in res.data
return get_UUID_for_tag_name(client, name=name)
def test_tag_colour_form_rejects_css_injection(client, live_server, measure_memory_usage, datastore_path):
tag_uuid = _add_tag(client, "css-injection-tag")
res = client.post(
url_for("tags.form_tag_edit_submit", uuid=tag_uuid),
data={"name": "css-injection-tag", "tag_colour": CSS_INJECTION},
follow_redirects=True
)
assert b"Updated" not in res.data
assert b"Must be a hex colour" in res.data
datastore = client.application.config.get('DATASTORE')
assert not datastore.data['settings']['application']['tags'][tag_uuid].get('tag_colour')
# ..and it never reaches the stylesheet on either page that renders tag colours
for page in (url_for("watchlist.index"), url_for("tags.tags_overview_page")):
assert b"attacker.example.com" not in client.get(page).data
def test_tag_colour_form_accepts_hex(client, live_server, measure_memory_usage, datastore_path):
tag_uuid = _add_tag(client, "hex-colour-tag")
res = client.post(
url_for("tags.form_tag_edit_submit", uuid=tag_uuid),
data={"name": "hex-colour-tag", "tag_colour": "#4f8ef7"},
follow_redirects=True
)
assert b"Updated" in res.data
datastore = client.application.config.get('DATASTORE')
assert datastore.data['settings']['application']['tags'][tag_uuid].get('tag_colour') == '#4f8ef7'
res = client.get(url_for("tags.tags_overview_page"))
assert b"background-color: #4f8ef7" in res.data
# An empty value must still be allowed - that's "use the auto-generated colour"
res = client.post(
url_for("tags.form_tag_edit_submit", uuid=tag_uuid),
data={"name": "hex-colour-tag", "tag_colour": ""},
follow_redirects=True
)
assert b"Updated" in res.data
assert not datastore.data['settings']['application']['tags'][tag_uuid].get('tag_colour')
def test_tag_colour_api_rejects_css_injection(client, live_server, measure_memory_usage, datastore_path):
api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
headers = {'content-type': 'application/json', 'x-api-key': api_key}
res = client.post(url_for("tag"), data=json.dumps({"title": "api-colour-tag"}), headers=headers)
assert res.status_code == 201, res.data
tag_uuid = res.json['uuid']
res = client.put(url_for("tag", uuid=tag_uuid), data=json.dumps({"tag_colour": CSS_INJECTION}), headers=headers)
assert res.status_code == 400, res.data
# Creating a tag with a bad colour in one shot must fail too
res = client.post(url_for("tag"), data=json.dumps({"title": "api-colour-tag-2", "tag_colour": CSS_INJECTION}),
headers=headers)
assert res.status_code == 400, res.data
# A hex colour is accepted and round-trips
res = client.put(url_for("tag", uuid=tag_uuid), data=json.dumps({"tag_colour": "#00ff00"}), headers=headers)
assert res.status_code == 200, res.data
assert client.get(url_for("tag", uuid=tag_uuid), headers=headers).json.get('tag_colour') == '#00ff00'
assert b"attacker.example.com" not in client.get(url_for("watchlist.index")).data
def test_tag_colour_already_stored_is_not_rendered(client, live_server, measure_memory_usage, datastore_path):
"""A value stored by an older version must still not make it into the stylesheet."""
tag_uuid = _add_tag(client, "poisoned-tag")
datastore = client.application.config.get('DATASTORE')
tag = datastore.data['settings']['application']['tags'][tag_uuid]
tag['tag_colour'] = CSS_INJECTION
tag.commit()
for page in (url_for("watchlist.index"),
url_for("tags.tags_overview_page"),
url_for("tags.form_tag_edit", uuid=tag_uuid)):
res = client.get(page)
assert res.status_code == 200
assert b"attacker.example.com" not in res.data, f"CSS injection rendered on {page}"
@@ -1309,6 +1309,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Musí být hexadecimální barva, například #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1325,6 +1325,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Muss eine Hex-Farbe sein, zum Beispiel #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1307,6 +1307,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1307,6 +1307,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1345,6 +1345,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Debe ser un color hexadecimal, por ejemplo #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1313,6 +1313,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Doit être une couleur hexadécimale, par exemple #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1309,6 +1309,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Deve essere un colore esadecimale, ad esempio #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1314,6 +1314,10 @@ msgstr "例: *://example.com/* や github.com/myorg"
msgid "Tag colour"
msgstr "タグの色"
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "16進数のカラーコードを指定してください(例: #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr "タグ名"
@@ -1315,6 +1315,10 @@ msgstr "예: *://example.com/* 또는 github.com/myorg"
msgid "Tag colour"
msgstr "태그 색상"
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "16진수 색상이어야 합니다. 예: #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr "태그 이름"
+5 -1
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: changedetection.io 0.55.8\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-08-16 18:32+0200\n"
"POT-Creation-Date: 2026-08-20 16:23+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -1306,6 +1306,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1332,6 +1332,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Deve ser uma cor hexadecimal, por exemplo #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1396,6 +1396,10 @@ msgstr "например *://example.com/* или github.com/myorg"
msgid "Tag colour"
msgstr "Цвет тега"
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Должен быть шестнадцатеричный цвет, например #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr "Имя тега"
@@ -1342,6 +1342,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Onaltılık bir renk olmalıdır, örneğin #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1322,6 +1322,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "Має бути шістнадцятковий колір, наприклад #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1312,6 +1312,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "必须是十六进制颜色,例如 #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""
@@ -1311,6 +1311,10 @@ msgstr ""
msgid "Tag colour"
msgstr ""
#: changedetectionio/blueprint/tags/form.py
msgid "Must be a hex colour, for example #4f8ef7"
msgstr "必須是十六進位色碼,例如 #4f8ef7"
#: changedetectionio/blueprint/tags/form.py
msgid "Tag name"
msgstr ""