From 851c054f8bf1edd97cce81645f00a510d38d5071 Mon Sep 17 00:00:00 2001 From: skkzsh Date: Mon, 25 May 2026 23:51:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?lint:=20Bump=20dennis=20=E2=80=94=20adopt?= =?UTF-8?q?=20`--strict`=20mode=20and=20drop=20false-positive=20workaround?= =?UTF-8?q?s=20(#4182)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test-only.yml | 26 +++---------------- .pre-commit-config.yaml | 16 ++++++++++++ changedetectionio/forms.py | 2 -- .../templates/_common_fields.html | 1 - changedetectionio/translations/README.md | 13 ++++------ .../translations/cs/LC_MESSAGES/messages.po | 3 --- .../translations/de/LC_MESSAGES/messages.po | 3 --- .../en_GB/LC_MESSAGES/messages.po | 3 --- .../en_US/LC_MESSAGES/messages.po | 3 --- .../translations/es/LC_MESSAGES/messages.po | 3 --- .../translations/fr/LC_MESSAGES/messages.po | 3 --- .../translations/it/LC_MESSAGES/messages.po | 3 --- .../translations/ja/LC_MESSAGES/messages.po | 3 --- .../translations/ko/LC_MESSAGES/messages.po | 3 --- changedetectionio/translations/messages.pot | 5 +--- .../pt_BR/LC_MESSAGES/messages.po | 3 --- .../translations/tr/LC_MESSAGES/messages.po | 3 --- .../translations/uk/LC_MESSAGES/messages.po | 3 --- .../translations/zh/LC_MESSAGES/messages.po | 3 --- .../zh_Hant_TW/LC_MESSAGES/messages.po | 3 --- requirements.txt | 2 +- 21 files changed, 27 insertions(+), 80 deletions(-) diff --git a/.github/workflows/test-only.yml b/.github/workflows/test-only.yml index 4833ad9c..1c08b893 100644 --- a/.github/workflows/test-only.yml +++ b/.github/workflows/test-only.yml @@ -31,33 +31,15 @@ jobs: echo "Checking $f" msgfmt --check-format -o /dev/null "$f" done - - name: Lint .po/.pot files with dennis (errors only) + - name: Lint .pot template with dennis run: | pip install "$(grep -E '^dennis ?>=' requirements.txt)" - dennis-cmd lint --errorsonly changedetectionio/translations/ - - name: Lint .pot template with dennis (warnings) + dennis-cmd lint --strict changedetectionio/translations/messages.pot + - name: Lint .po files with dennis run: | - output=$(dennis-cmd lint changedetectionio/translations/messages.pot) - echo "$output" - warnings=$(echo "$output" | awk '/Warnings:/ {print $NF; exit}') - if (( ${warnings:-0} > 0 )); then - echo "ERROR: ${warnings} dennis warning(s) detected in messages.pot" - echo "Fix the warning(s)." - exit 1 - fi - - name: Lint .po files with dennis (warnings) + dennis-cmd lint --strict --excluderules=W302 changedetectionio/translations/*/LC_MESSAGES/messages.po # W302 (unchanged) is excluded due to high false-positive rate in this codebase: # many msgstrs intentionally match msgid (units like "AI", "LLM", and proper nouns). - run: | - output=$(dennis-cmd lint --excluderules=W302 \ - changedetectionio/translations/*/LC_MESSAGES/messages.po) - echo "$output" - warnings=$(echo "$output" | awk '/Total number of warnings:/ {print $NF; exit}') - if (( ${warnings:-0} > 0 )); then - echo "ERROR: ${warnings} dennis warning(s) detected in .po files" - echo "Fix the warning(s)." - exit 1 - fi - name: Check translation catalog is up-to-date run: | pip install "$(grep -E '^babel==' requirements.txt)" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb387e44..0dcb3c85 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,3 +7,19 @@ repos: args: [--fix] # Fomrat - id: ruff-format + + - repo: local + hooks: + - id: dennis-lint-pot + name: dennis lint pot + language: system + entry: dennis-cmd lint --strict + files: ^changedetectionio/translations/messages\.pot$ + pass_filenames: true + + - id: dennis-lint-po + name: dennis lint po + language: system + entry: dennis-cmd lint --strict --excluderules=W302 + files: ^changedetectionio/translations/\w+/LC_MESSAGES/messages\.po$ + pass_filenames: true diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 3ee24c7d..56d89e90 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -887,7 +887,6 @@ class processor_text_json_diff_form(commonSettingsForm): conditions_match_logic = RadioField(_l('Match'), choices=[('ALL', _l('Match all of the following')),('ANY', _l('Match any of the following'))], default='ALL') conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here - # dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213 use_page_title_in_list = TernaryNoneBooleanField(_l('Use page <title> in list'), default=None) history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={"style": "width: 5em;"}, validators=[validators.Optional(), validators.NumberRange(min=2)]) @@ -1036,7 +1035,6 @@ class globalSettingsApplicationUIForm(Form): open_diff_in_new_tab = BooleanField(_l("Open 'History' page in a new tab"), default=True, validators=[validators.Optional()]) socket_io_enabled = BooleanField(_l('Realtime UI Updates Enabled'), default=True, validators=[validators.Optional()]) favicons_enabled = BooleanField(_l('Favicons Enabled'), default=True, validators=[validators.Optional()]) - # dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 use_page_title_in_list = BooleanField(_l('Use page <title> in watch overview list')) #BooleanField=True # datastore.data['settings']['application'].. diff --git a/changedetectionio/templates/_common_fields.html b/changedetectionio/templates/_common_fields.html index 829ca1c4..d067f792 100644 --- a/changedetectionio/templates/_common_fields.html +++ b/changedetectionio/templates/_common_fields.html @@ -34,7 +34,6 @@ </tr> <tr> <td><code>{{ '{{watch_title}}' }}</code></td> - {# TRANSLATORS: dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #} <td>{{ _('The page title of the watch, uses <title> if not set, falls back to URL') }}</td> </tr> <tr> diff --git a/changedetectionio/translations/README.md b/changedetectionio/translations/README.md index 66718ccf..efd61df5 100644 --- a/changedetectionio/translations/README.md +++ b/changedetectionio/translations/README.md @@ -247,34 +247,31 @@ dennis-cmd lint --excluderules=W302 changedetectionio/translations/ The `W303` rule ensures that HTML tags in the `msgstr` match the `msgid`. This is crucial for catching broken markup (e.g., missing closing tags). -##### Handling intentional deviations and false positives +##### Handling intentional deviations -Some W303 warnings are intentional or result from upstream false positives. +Some W303 warnings are intentional. Use the `dennis-ignore: W303` comment in the source files (templates or Python code) within a `TRANSLATORS` comment to suppress these warnings. This ensures the ignore instruction is extracted into the `.po` files. - **CJK italic policy**: When replacing `<i>` with locale-conventional quotation marks, tags will no longer match. -- **Upstream false positive**: Dennis misinterprets certain HTML tags (e.g., `<title>`) within `msgstr`. See https://github.com/mozilla/dennis/issues/213. **Examples in Jinja2 templates:** ```jinja {# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #} <p>{{ _('These settings are <strong><i>added</i></strong> to any existing watch configurations.')|safe }}</p> - -{# TRANSLATORS: dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #} -<td>{{ _('The page title of the watch, uses <title> if not set, falls back to URL') }}</td> ``` **Example in Python source:** ```python -# dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 -use_page_title_in_list = BooleanField(_l('Use page <title> in watch overview list')) +# dennis-ignore: W303 - CJK fonts lack native italics; allow substitution with conventional local styling. +message = StringField(_l('This is <i>experimental</i> and may change')) ``` --- + ## CI linter A GitHub Actions job (`lint-template-i18n`) checks for adjacent `{{ _(...) }}` calls on the same line diff --git a/changedetectionio/translations/cs/LC_MESSAGES/messages.po b/changedetectionio/translations/cs/LC_MESSAGES/messages.po index f38ecd49..de8a5121 100644 --- a/changedetectionio/translations/cs/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/cs/LC_MESSAGES/messages.po @@ -2959,7 +2959,6 @@ msgstr "Spojit všechny následující položky" msgid "Match any of the following" msgstr "Přiřaďte kteroukoli z následujících možností" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "V seznamu použijte stránku <title>" @@ -3059,7 +3058,6 @@ msgstr "Aktualizace UI v reálném čase" msgid "Favicons Enabled" msgstr "Povolit favikony" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "Použijte stránku <title> v přehledu sledování" @@ -3453,7 +3451,6 @@ msgstr "" msgid "The UUID of the watch." msgstr "UUID monitoru." -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "" diff --git a/changedetectionio/translations/de/LC_MESSAGES/messages.po b/changedetectionio/translations/de/LC_MESSAGES/messages.po index 0b98c731..7e5f39a1 100644 --- a/changedetectionio/translations/de/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/de/LC_MESSAGES/messages.po @@ -3011,7 +3011,6 @@ msgstr "Passen Sie alle folgenden Punkte an" msgid "Match any of the following" msgstr "Entspricht einer der folgenden Bedingungen" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "Verwenden Sie Seite <title> in der Liste" @@ -3111,7 +3110,6 @@ msgstr "Echtzeit-UI-Updates aktiviert" msgid "Favicons Enabled" msgstr "Favicons Aktiviert" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "Verwenden Sie die Seite <title> in der Übersichtsliste der Beobachtungen" @@ -3507,7 +3505,6 @@ msgstr "" msgid "The UUID of the watch." msgstr "Die UUID der Überwachung." -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "" diff --git a/changedetectionio/translations/en_GB/LC_MESSAGES/messages.po b/changedetectionio/translations/en_GB/LC_MESSAGES/messages.po index 11b76ace..cabc108f 100644 --- a/changedetectionio/translations/en_GB/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/en_GB/LC_MESSAGES/messages.po @@ -2953,7 +2953,6 @@ msgstr "" msgid "Match any of the following" msgstr "" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "" @@ -3053,7 +3052,6 @@ msgstr "" msgid "Favicons Enabled" msgstr "" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "" @@ -3447,7 +3445,6 @@ msgstr "" msgid "The UUID of the watch." msgstr "" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "" diff --git a/changedetectionio/translations/en_US/LC_MESSAGES/messages.po b/changedetectionio/translations/en_US/LC_MESSAGES/messages.po index 4d956956..b0e37da1 100644 --- a/changedetectionio/translations/en_US/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/en_US/LC_MESSAGES/messages.po @@ -2953,7 +2953,6 @@ msgstr "" msgid "Match any of the following" msgstr "" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "" @@ -3053,7 +3052,6 @@ msgstr "" msgid "Favicons Enabled" msgstr "" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "" @@ -3447,7 +3445,6 @@ msgstr "" msgid "The UUID of the watch." msgstr "" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "" diff --git a/changedetectionio/translations/es/LC_MESSAGES/messages.po b/changedetectionio/translations/es/LC_MESSAGES/messages.po index a75da7ed..119ba06f 100644 --- a/changedetectionio/translations/es/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/es/LC_MESSAGES/messages.po @@ -3026,7 +3026,6 @@ msgstr "Coincide con todo lo siguiente" msgid "Match any of the following" msgstr "Coincide con cualquiera de los siguientes" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "Usar página <title> en la lista" @@ -3126,7 +3125,6 @@ msgstr "Actualizaciones de UI en tiempo real habilitadas" msgid "Favicons Enabled" msgstr "Favicones habilitados" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "Usar <title> de la página en la lista general de monitores" @@ -3520,7 +3518,6 @@ msgstr "La URL que se está viendo." msgid "The UUID of the watch." msgstr "El UUID del monitor." -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "El título de la página del monitor, utiliza <title> si no se establece, vuelve a la URL" diff --git a/changedetectionio/translations/fr/LC_MESSAGES/messages.po b/changedetectionio/translations/fr/LC_MESSAGES/messages.po index ae00bab2..d6443725 100644 --- a/changedetectionio/translations/fr/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/fr/LC_MESSAGES/messages.po @@ -2966,7 +2966,6 @@ msgstr "Faites correspondre tous les éléments suivants" msgid "Match any of the following" msgstr "Faites correspondre l'un des éléments suivants" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "Utiliser la page <title> dans la liste" @@ -3066,7 +3065,6 @@ msgstr "Mises à jour en temps réel hors ligne" msgid "Favicons Enabled" msgstr "Favicons Activés" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "Utiliser la page <title> dans la liste de présentation des moniteurs" @@ -3460,7 +3458,6 @@ msgstr "" msgid "The UUID of the watch." msgstr "L'UUID du moniteur." -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "" diff --git a/changedetectionio/translations/it/LC_MESSAGES/messages.po b/changedetectionio/translations/it/LC_MESSAGES/messages.po index 67f80a94..006fd328 100644 --- a/changedetectionio/translations/it/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/it/LC_MESSAGES/messages.po @@ -2955,7 +2955,6 @@ msgstr "Corrisponde a tutti i seguenti" msgid "Match any of the following" msgstr "Corrisponde a uno qualsiasi dei seguenti" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "Usa <title> pagina nell'elenco" @@ -3055,7 +3054,6 @@ msgstr "Aggiornamenti UI in tempo reale attivi" msgid "Favicons Enabled" msgstr "Favicon attive" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "Usa <title> pagina nell'elenco osservati" @@ -3449,7 +3447,6 @@ msgstr "" msgid "The UUID of the watch." msgstr "L'UUID del monitor." -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "" diff --git a/changedetectionio/translations/ja/LC_MESSAGES/messages.po b/changedetectionio/translations/ja/LC_MESSAGES/messages.po index 85ffffba..2d4095a3 100644 --- a/changedetectionio/translations/ja/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/ja/LC_MESSAGES/messages.po @@ -2972,7 +2972,6 @@ msgstr "以下のすべてに一致" msgid "Match any of the following" msgstr "以下のいずれかに一致" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "リストでページの <title> を使用" @@ -3072,7 +3071,6 @@ msgstr "リアルタイムUI更新を有効化" msgid "Favicons Enabled" msgstr "ファビコンを有効化" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "ウォッチ一覧リストでページの <title> を使用" @@ -3466,7 +3464,6 @@ msgstr "監視中のURL。" msgid "The UUID of the watch." msgstr "ウォッチのUUID。" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "ウォッチのページタイトル。設定されていない場合は <title> を使用し、それもなければURLにフォールバックします。" diff --git a/changedetectionio/translations/ko/LC_MESSAGES/messages.po b/changedetectionio/translations/ko/LC_MESSAGES/messages.po index 682e4f8f..5b71946c 100644 --- a/changedetectionio/translations/ko/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/ko/LC_MESSAGES/messages.po @@ -2963,7 +2963,6 @@ msgstr "다음 모두와 일치" msgid "Match any of the following" msgstr "다음 중 하나와 일치" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "목록에 페이지 <title> 사용" @@ -3063,7 +3062,6 @@ msgstr "실시간 UI 업데이트 활성화" msgid "Favicons Enabled" msgstr "파비콘 활성화" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "모니터링 목록에 페이지 <title> 사용" @@ -3457,7 +3455,6 @@ msgstr "모니터링 중인 URL입니다." msgid "The UUID of the watch." msgstr "모니터링 UUID입니다." -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "모니터링의 페이지 제목입니다. 설정되지 않았으면 <title> 을 사용하고, 없으면 URL을 사용합니다." diff --git a/changedetectionio/translations/messages.pot b/changedetectionio/translations/messages.pot index 4293f352..7bb5a966 100644 --- a/changedetectionio/translations/messages.pot +++ b/changedetectionio/translations/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: changedetection.io 0.55.5\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-05-25 10:09+0200\n" +"POT-Creation-Date: 2026-05-25 01:33+0900\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" @@ -2952,7 +2952,6 @@ msgstr "" msgid "Match any of the following" msgstr "" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "" @@ -3052,7 +3051,6 @@ msgstr "" msgid "Favicons Enabled" msgstr "" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "" @@ -3446,7 +3444,6 @@ msgstr "" msgid "The UUID of the watch." msgstr "" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "" diff --git a/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po b/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po index 2a75a5c8..de019278 100644 --- a/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po @@ -3003,7 +3003,6 @@ msgstr "Corresponder a TODOS os seguintes" msgid "Match any of the following" msgstr "Corresponder a QUALQUER um dos seguintes" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "Usar <title> da página na lista" @@ -3103,7 +3102,6 @@ msgstr "Atualizações de Interface em Tempo Real Ativadas" msgid "Favicons Enabled" msgstr "Favicons Ativados" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "Usar <title> da página na lista de visão geral" @@ -3497,7 +3495,6 @@ msgstr "A URL que está sendo monitorada." msgid "The UUID of the watch." msgstr "O UUID do monitoramento." -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "O título da página do monitoramento, usa <title> se não definido, ou a URL" diff --git a/changedetectionio/translations/tr/LC_MESSAGES/messages.po b/changedetectionio/translations/tr/LC_MESSAGES/messages.po index ba5d7b54..d21696bc 100644 --- a/changedetectionio/translations/tr/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/tr/LC_MESSAGES/messages.po @@ -3006,7 +3006,6 @@ msgstr "Aşağıdakilerin tümünü eşleştir" msgid "Match any of the following" msgstr "Aşağıdakilerden herhangi birini eşleştir" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "Listede sayfa <title>'ını kullan" @@ -3106,7 +3105,6 @@ msgstr "Gerçek Zamanlı Arayüz Güncellemeleri Etkin" msgid "Favicons Enabled" msgstr "Favicon'lar Etkin" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "İzleyici genel bakış listesinde sayfa <title>'ını kullan" @@ -3500,7 +3498,6 @@ msgstr "İzlenen URL." msgid "The UUID of the watch." msgstr "İzleyicinin UUID'si." -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "İzleyicinin sayfa başlığı, ayarlanmamışsa <title> kullanır, URL'ye geri döner" diff --git a/changedetectionio/translations/uk/LC_MESSAGES/messages.po b/changedetectionio/translations/uk/LC_MESSAGES/messages.po index 01f340ed..b0c06e2f 100644 --- a/changedetectionio/translations/uk/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/uk/LC_MESSAGES/messages.po @@ -2985,7 +2985,6 @@ msgstr "Збіг усіх наступних умов" msgid "Match any of the following" msgstr "Збіг будь-якої з наступних умов" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "Використовувати <title> сторінки у списку" @@ -3085,7 +3084,6 @@ msgstr "Оновлення UI в реальному часі увімкнено" msgid "Favicons Enabled" msgstr "Фавіконки увімкнено" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "Використовувати <title> сторінки у списку огляду завдань" @@ -3479,7 +3477,6 @@ msgstr "URL, за яким ведеться спостереження." msgid "The UUID of the watch." msgstr "UUID завдання." -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "Заголовок сторінки завдання, використовує <title>, якщо не задано - URL" diff --git a/changedetectionio/translations/zh/LC_MESSAGES/messages.po b/changedetectionio/translations/zh/LC_MESSAGES/messages.po index fda1e8a6..79df8fa8 100644 --- a/changedetectionio/translations/zh/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/zh/LC_MESSAGES/messages.po @@ -2958,7 +2958,6 @@ msgstr "匹配以下全部" msgid "Match any of the following" msgstr "匹配以下任意" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "列表中使用页面 <title>" @@ -3058,7 +3057,6 @@ msgstr "启用实时界面更新" msgid "Favicons Enabled" msgstr "启用站点图标" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "在监控概览列表中使用页面 <title>" @@ -3452,7 +3450,6 @@ msgstr "被监控的 URL。" msgid "The UUID of the watch." msgstr "监视器的UUID。" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "监控项的页面标题,未设置时使用 <title>,否则回退为 URL" diff --git a/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po b/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po index 3f9d40ad..7dca220d 100644 --- a/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po +++ b/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po @@ -2957,7 +2957,6 @@ msgstr "符合以下所有條件" msgid "Match any of the following" msgstr "符合以下任一條件" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in list" msgstr "在列表中使用頁面 <title>" @@ -3057,7 +3056,6 @@ msgstr "已啟用即時 UI 更新" msgid "Favicons Enabled" msgstr "啟用網站圖示 (Favicons)" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/forms.py msgid "Use page <title> in watch overview list" msgstr "在監測概覽列表中使用頁面 <title>" @@ -3451,7 +3449,6 @@ msgstr "" msgid "The UUID of the watch." msgstr "監測任務的 UUID。" -#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #: changedetectionio/templates/_common_fields.html msgid "The page title of the watch, uses <title> if not set, falls back to URL" msgstr "" diff --git a/requirements.txt b/requirements.txt index 5fa90d1e..5aa090e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -159,7 +159,7 @@ psutil==7.2.2 ruff >= 0.11.2 pre_commit >= 4.2.0 -dennis >= 1.2.0 +dennis >= 1.3.0 # For events between checking and socketio updates blinker From 08017d66d6df809a7cebdf938555d9bccead9c1f Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Mon, 25 May 2026 17:57:04 +0200 Subject: [PATCH 2/3] Security - SSRF in ChangeDetection.io via urlparse/urllib3 Parser Differential --- .../content_fetchers/requests.py | 16 +-- .../apprise_plugin/custom_handlers.py | 12 +- changedetectionio/processors/base.py | 12 +- changedetectionio/tests/test_security.py | 113 +++++++++++++++++- changedetectionio/validate_url.py | 53 ++++++++ 5 files changed, 186 insertions(+), 20 deletions(-) diff --git a/changedetectionio/content_fetchers/requests.py b/changedetectionio/content_fetchers/requests.py index a97d1674..08e8b327 100644 --- a/changedetectionio/content_fetchers/requests.py +++ b/changedetectionio/content_fetchers/requests.py @@ -9,7 +9,7 @@ import asyncio from changedetectionio import strtobool from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived from changedetectionio.content_fetchers.base import Fetcher -from changedetectionio.validate_url import is_private_hostname +from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused # "html_requests" is listed as the default fetcher in store.py! @@ -87,10 +87,12 @@ class fetcher(Fetcher): try: # Fresh DNS check at fetch time — catches DNS rebinding regardless of add-time cache. + # Validates every hostname both urlparse and urllib3 see, so parser-differential + # payloads (GHSA-rph4-96w6-q594) cannot smuggle an internal target past the gate. if not allow_iana_restricted: - parsed_initial = urlparse(url) - if parsed_initial.hostname and is_private_hostname(parsed_initial.hostname): - raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address. " + if is_url_private_or_parser_confused(url): + raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address " + f"or contains a parser-differential payload. " f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow.") r = session.request(method=request_method, @@ -111,9 +113,9 @@ class fetcher(Fetcher): location = r.headers.get('Location', '') redirect_url = urljoin(current_url, location) if not allow_iana_restricted: - parsed_redirect = urlparse(redirect_url) - if parsed_redirect.hostname and is_private_hostname(parsed_redirect.hostname): - raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address.") + if is_url_private_or_parser_confused(redirect_url): + raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address " + f"or contains a parser-differential payload.") current_url = redirect_url r = session.request('GET', redirect_url, headers=request_headers, diff --git a/changedetectionio/notification/apprise_plugin/custom_handlers.py b/changedetectionio/notification/apprise_plugin/custom_handlers.py index c6270a13..023f661c 100644 --- a/changedetectionio/notification/apprise_plugin/custom_handlers.py +++ b/changedetectionio/notification/apprise_plugin/custom_handlers.py @@ -60,7 +60,7 @@ from apprise.utils.logic import dict_full_update from loguru import logger from requests.structures import CaseInsensitiveDict -from changedetectionio.validate_url import is_private_hostname +from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused SUPPORTED_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head"} @@ -198,12 +198,14 @@ def apprise_http_custom_handler( url = re.sub(rf"^{schema}", "https" if schema.endswith("s") else "http", parsed_url.get("url")) - # SSRF protection — block private/loopback addresses unless explicitly allowed + # SSRF protection — block private/loopback addresses unless explicitly allowed. + # Uses parser-agnostic check so urlparse/urllib3 differentials (GHSA-rph4-96w6-q594) + # can't smuggle an internal target past the gate. if not os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', '').lower() in ('true', '1', 'yes'): - hostname = urlparse(url).hostname or '' - if hostname and is_private_hostname(hostname): + if is_url_private_or_parser_confused(url): raise ValueError( - f"Notification target '{hostname}' is a private/reserved address. " + f"Notification target '{url}' is a private/reserved address " + f"or contains a parser-differential payload. " f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow." ) diff --git a/changedetectionio/processors/base.py b/changedetectionio/processors/base.py index 062c5604..26d38f73 100644 --- a/changedetectionio/processors/base.py +++ b/changedetectionio/processors/base.py @@ -5,7 +5,7 @@ import hashlib from changedetectionio.browser_steps.browser_steps import browser_steps_get_valid_steps from changedetectionio.content_fetchers.base import Fetcher from changedetectionio.strtobool import strtobool -from changedetectionio.validate_url import is_private_hostname +from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused from copy import deepcopy from abc import abstractmethod import os @@ -104,13 +104,13 @@ class difference_detection_processor(): """ if strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')): return - parsed = urlparse(self.watch.link) - if not parsed.hostname: - return loop = asyncio.get_running_loop() - if await loop.run_in_executor(None, is_private_hostname, parsed.hostname): + # Use the parser-agnostic check so urlparse/urllib3 differentials (GHSA-rph4-96w6-q594) + # can't slip a private/internal hostname past this pre-flight gate. + if await loop.run_in_executor(None, is_url_private_or_parser_confused, self.watch.link): raise Exception( - f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address. " + f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address " + f"or contains a parser-differential payload. " f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow." ) diff --git a/changedetectionio/tests/test_security.py b/changedetectionio/tests/test_security.py index 24956a2e..ab393609 100644 --- a/changedetectionio/tests/test_security.py +++ b/changedetectionio/tests/test_security.py @@ -760,7 +760,9 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor f = RequestsFetcher() - with patch('changedetectionio.content_fetchers.requests.is_private_hostname', return_value=True): + # Patch the underlying is_private_hostname in validate_url — the fetcher now goes through + # is_url_private_or_parser_confused() (GHSA-rph4-96w6-q594), which calls it transitively. + with patch('changedetectionio.validate_url.is_private_hostname', return_value=True): with pytest.raises(Exception, match='private/reserved'): f._run_sync( url='http://example.com/', @@ -784,7 +786,7 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor return hostname in {'169.254.169.254', '10.0.0.1', '172.16.0.1', '192.168.0.1', '127.0.0.1', '::1'} - with patch('changedetectionio.content_fetchers.requests.is_private_hostname', + with patch('changedetectionio.validate_url.is_private_hostname', side_effect=_private_only_for_redirect): with patch('requests.Session.request', return_value=mock_redirect): with pytest.raises(Exception, match='Redirect blocked'): @@ -829,6 +831,113 @@ def test_unresolvable_hostname_is_allowed(client, live_server, monkeypatch): "Unresolvable hostname watch should appear in the watch overview list" +def test_ghsa_rph4_96w6_q594_urlparse_urllib3_parser_differential_ssrf(client, live_server, monkeypatch, measure_memory_usage, datastore_path): + """ + GHSA-rph4-96w6-q594: SSRF via urlparse/urllib3 parser differential. + + A URL like http://INTERNAL:8888\\@PUBLIC/ is parsed two different ways: + - urlparse() treats \\@ as a credential separator → hostname = PUBLIC + - urllib3 treats \\ as a path character → hostname = INTERNAL + The pre-fetch SSRF check used urlparse(), but requests/urllib3 actually connected + to INTERNAL. Fix: parser-agnostic gate that (a) blocks any URL containing a + backslash and (b) validates every hostname both parsers produce. + + Covers: + 1. extract_url_hostnames() reveals BOTH hostnames for the payload + 2. is_url_private_or_parser_confused() blocks backslash payloads outright + 3. is_safe_valid_url() rejects backslash payloads at add-time + 4. The /api/v1/watch add endpoint rejects the payload + 5. The requests fetcher refuses the payload at fetch-time + 6. The redirect-following loop refuses a backslash payload in Location + """ + from unittest.mock import patch, MagicMock + from changedetectionio.validate_url import ( + extract_url_hostnames, + is_safe_valid_url, + is_url_private_or_parser_confused, + ) + + monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false') + + # The published proof-of-concept payload — backslash splits the two parsers' views. + payload = "http://169.254.169.254:8888" + chr(92) + "@httpbin.org/latest/meta-data/" + + # --------------------------------------------------------------- + # 1. extract_url_hostnames() returns BOTH parsers' hostnames + # --------------------------------------------------------------- + hosts = extract_url_hostnames(payload) + assert '169.254.169.254' in hosts, \ + f"urllib3 sees 169.254.169.254 as the connect target; extract_url_hostnames must surface it. Got {hosts!r}" + assert 'httpbin.org' in hosts, \ + f"urlparse sees httpbin.org; extract_url_hostnames must surface it too. Got {hosts!r}" + + # --------------------------------------------------------------- + # 2. Parser-agnostic gate blocks the payload + # --------------------------------------------------------------- + assert is_url_private_or_parser_confused(payload), \ + "Parser-differential payload must be blocked by the SSRF gate" + + # And a plain backslash anywhere in the URL is enough to block, even without a private IP + assert is_url_private_or_parser_confused("http://example.com" + chr(92) + "@evil.com/"), \ + "Any backslash in a URL must trigger the parser-differential block" + + # Sanity: a regular public URL is not blocked + assert not is_url_private_or_parser_confused("http://example.com/path"), \ + "Plain public URLs must continue to pass the gate" + + # --------------------------------------------------------------- + # 3. is_safe_valid_url() rejects backslash payloads at add-time + # --------------------------------------------------------------- + assert not is_safe_valid_url(payload), \ + "is_safe_valid_url must reject URLs containing a backslash (parser-differential vector)" + + # --------------------------------------------------------------- + # 4. The watch-add API endpoint rejects the payload + # --------------------------------------------------------------- + api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token') + res = client.post( + url_for('createwatch'), + data='{"url": "%s", "fetch_backend": "html_requests"}' % payload, + headers={'x-api-key': api_key, 'Content-Type': 'application/json'}, + ) + assert res.status_code >= 400, \ + f"API must refuse to create a watch for parser-differential URL; got status {res.status_code} body {res.data!r}" + + # --------------------------------------------------------------- + # 5. Requests fetcher refuses the payload at fetch-time + # --------------------------------------------------------------- + from changedetectionio.content_fetchers.requests import fetcher as RequestsFetcher + + f = RequestsFetcher() + with pytest.raises(Exception, match='private/reserved|parser-differential'): + f._run_sync( + url=payload, + timeout=5, + request_headers={}, + request_body=None, + request_method='GET', + ) + + # --------------------------------------------------------------- + # 6. A 302 Location header pointing at a backslash payload is blocked + # (open-redirect → SSRF via parser differential) + # --------------------------------------------------------------- + mock_redirect = MagicMock() + mock_redirect.is_redirect = True + mock_redirect.status_code = 302 + mock_redirect.headers = {'Location': payload} + + with patch('requests.Session.request', return_value=mock_redirect): + with pytest.raises(Exception, match='Redirect blocked'): + f._run_sync( + url='http://example.com/', + timeout=5, + request_headers={}, + request_body=None, + request_method='GET', + ) + + def test_ghsa_8757_69j2_hx56_backup_restore_history_path_traversal(client, live_server, measure_memory_usage, datastore_path): """ GHSA-8757-69j2-hx56: Crafted backup ZIP with absolute path in history.txt must not diff --git a/changedetectionio/validate_url.py b/changedetectionio/validate_url.py index 8bbfd5d2..85dfd25e 100644 --- a/changedetectionio/validate_url.py +++ b/changedetectionio/validate_url.py @@ -80,6 +80,52 @@ def is_private_hostname(hostname): return False +def extract_url_hostnames(url): + """Return every hostname this URL could resolve to under different URL parsers. + + Why: urllib's urlparse() and urllib3's parse_url() disagree on URLs containing + a backslash (e.g. http://INTERNAL:8888\\@PUBLIC/ — urlparse extracts PUBLIC, but + urllib3/requests will actually connect to INTERNAL). Any SSRF check that trusts + only one parser can be bypassed by the other. Callers should reject the fetch + if ANY hostname returned here is private/reserved. + + See GHSA-rph4-96w6-q594. + """ + hostnames = set() + try: + h = urlparse(url).hostname + if h: + hostnames.add(h) + except Exception: + pass + try: + from urllib3.util.url import parse_url as _u3_parse_url + u3 = _u3_parse_url(url) + if u3.host: + # urllib3 keeps IPv6 brackets in `.host`; strip them so socket.getaddrinfo() accepts the literal. + hostnames.add(u3.host.strip('[]')) + except Exception: + pass + return hostnames + + +def is_url_private_or_parser_confused(url): + """SSRF gate that defends against urlparse/urllib3 parser-differential attacks. + + Returns True (block the fetch) when: + * the URL contains a backslash — no legitimate URL needs one, and it is the + established vector for the parser-differential bypass (GHSA-rph4-96w6-q594), OR + * any hostname produced by urlparse OR urllib3 resolves to a private/reserved IP. + """ + if '\\' in url: + logger.warning(f"URL '{url}' contains a backslash — rejected to prevent urlparse/urllib3 parser-differential SSRF.") + return True + for hostname in extract_url_hostnames(url): + if is_private_hostname(hostname): + return True + return False + + def is_llm_api_base_safe(api_base): """SSRF guard for the LLM `api_base` setting (GHSA-jrxm-qjfh-g54f). @@ -178,6 +224,13 @@ def is_safe_valid_url(test_url): logger.warning(f'URL "{test_url}" contains suspicious characters') return False + # Reject backslashes — urllib's urlparse and urllib3's parse_url disagree on URLs containing + # a backslash (e.g. http://INTERNAL:8888\@PUBLIC/), which is the documented SSRF bypass in + # GHSA-rph4-96w6-q594. A backslash has no legitimate use in an HTTP URL, so block at add-time. + if '\\' in test_url: + logger.warning(f'URL "{test_url}" contains a backslash — rejected (parser-differential SSRF vector).') + return False + # Normalize URL encoding - handle both encoded and unencoded query parameters test_url = normalize_url_encoding(test_url) From 230fef0f64cfc9ae5e0fefb92b7f0d54bcb31667 Mon Sep 17 00:00:00 2001 From: dgtlmoon <dgtlmoon@gmail.com> Date: Mon, 25 May 2026 17:59:18 +0200 Subject: [PATCH 3/3] 0.55.6 --- changedetectionio/__init__.py | 2 +- changedetectionio/translations/messages.pot | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 9a210943..9b010ba1 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -2,7 +2,7 @@ # Read more https://github.com/dgtlmoon/changedetection.io/wiki # Semver means never use .01, or 00. Should be .1. -__version__ = '0.55.5' +__version__ = '0.55.6' from changedetectionio.strtobool import strtobool from json.decoder import JSONDecodeError diff --git a/changedetectionio/translations/messages.pot b/changedetectionio/translations/messages.pot index 7bb5a966..b7d8cec5 100644 --- a/changedetectionio/translations/messages.pot +++ b/changedetectionio/translations/messages.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: changedetection.io 0.55.5\n" +"Project-Id-Version: changedetection.io 0.55.6\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-05-25 01:33+0900\n" +"POT-Creation-Date: 2026-05-25 17:59+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"