VYPR
High severityNVD Advisory· Published Jun 23, 2025· Updated Apr 15, 2026

CVE-2025-52558

CVE-2025-52558

Description

changedetection.io is a free open source web page change detection, website watcher, restock monitor and notification service. Prior to version 0.50.4, errors in filters from website page change detection watches were not being filtered resulting in a cross-site scripting (XSS) vulnerability. This issue has been patched in version 0.50.4

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
changedetection.ioPyPI
< 0.50.40.50.4

Patches

2
3d5a544ea674

CVE-2025-52558 - Fixing XSS in error handling output of watch overview list

6 files changed · +61 79
  • changedetectionio/blueprint/watchlist/templates/watch-overview.html+1 1 modified
    @@ -146,7 +146,7 @@
                         {%- if watch.is_pdf  -%}<img class="status-icon" src="{{url_for('static_content', group='images', filename='pdf-icon.svg')}}" alt="Converting PDF to text" >{%- endif -%}
                         {%- if watch.has_browser_steps -%}<img class="status-icon status-browsersteps" src="{{url_for('static_content', group='images', filename='steps.svg')}}" alt="Browser Steps is enabled" >{%- endif -%}
     
    -                    <div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list)|safe }}</div>
    +                    <div class="error-text" style="display:none;">{{ watch.compile_error_texts(has_proxies=datastore.proxy_list) }}</div>
     
                         {%- if watch['processor'] == 'text_json_diff'  -%}
                             {%- if watch['has_ldjson_price_data'] and not watch['track_ldjson_price_data']  -%}
    
  • changedetectionio/model/Watch.py+4 3 modified
    @@ -8,6 +8,7 @@
     from pathlib import Path
     from loguru import logger
     
    +from .. import safe_jinja
     from ..html_tools import TRANSLATE_WHITESPACE_TABLE
     
     # Allowable protocols, protects against javascript: etc
    @@ -691,11 +692,11 @@ def compile_error_texts(self, has_proxies=None):
                     output.append(str(Markup(f"<div class=\"notification-error\"><a href=\"{url_for('settings.notification_logs')}\">{ self.get('last_notification_error') }</a></div>")))
     
             else:
    -            # Lo_Fi version
    +            # Lo_Fi version - no app context, cant rely on Jinja2 Markup
                 if last_error:
    -                output.append(str(Markup(last_error)))
    +                output.append(safe_jinja.render_fully_escaped(last_error))
                 if self.get('last_notification_error'):
    -                output.append(str(Markup(self.get('last_notification_error'))))
    +                output.append(safe_jinja.render_fully_escaped(self.get('last_notification_error')))
     
             res = "\n".join(output)
             return res
    
  • changedetectionio/safe_jinja.py+7 1 modified
    @@ -10,9 +10,15 @@
     
     JINJA2_MAX_RETURN_PAYLOAD_SIZE = 1024 * int(os.getenv("JINJA2_MAX_RETURN_PAYLOAD_SIZE_KB", 1024 * 10))
     
    -
    +# This is used for notifications etc, so actually it's OK to send custom HTML such as <a href> etc, but it should limit what data is available.
    +# (Which also limits available functions that could be called)
     def render(template_str, **args: t.Any) -> str:
         jinja2_env = jinja2.sandbox.ImmutableSandboxedEnvironment(extensions=['jinja2_time.TimeExtension'])
         output = jinja2_env.from_string(template_str).render(args)
         return output[:JINJA2_MAX_RETURN_PAYLOAD_SIZE]
     
    +def render_fully_escaped(content):
    +    env = jinja2.sandbox.ImmutableSandboxedEnvironment(autoescape=True)
    +    template = env.from_string("{{ some_html|e }}")
    +    return template.render(some_html=content)
    +
    
  • changedetectionio/tests/realtime/test_socketio.py+0 72 removed
    @@ -1,72 +0,0 @@
    -import asyncio
    -import socketio
    -from aiohttp import web
    -
    -SOCKETIO_URL = 'ws://localhost.localdomain:5005'
    -SOCKETIO_PATH = "/socket.io"
    -NUM_CLIENTS = 1
    -
    -clients = []
    -shutdown_event = asyncio.Event()
    -
    -class WatchClient:
    -    def __init__(self, client_id: int):
    -        self.client_id = client_id
    -        self.i_got_watch_update_event = False
    -        self.sio = socketio.AsyncClient(reconnection_attempts=50, reconnection_delay=1)
    -
    -        @self.sio.event
    -        async def connect():
    -            print(f"[Client {self.client_id}] Connected")
    -
    -        @self.sio.event
    -        async def disconnect():
    -            print(f"[Client {self.client_id}] Disconnected")
    -
    -        @self.sio.on("watch_update")
    -        async def on_watch_update(watch):
    -            self.i_got_watch_update_event = True
    -            print(f"[Client {self.client_id}] Received update: {watch}")
    -
    -    async def run(self):
    -        try:
    -            await self.sio.connect(SOCKETIO_URL, socketio_path=SOCKETIO_PATH, transports=["websocket", "polling"])
    -            await self.sio.wait()
    -        except Exception as e:
    -            print(f"[Client {self.client_id}] Connection error: {e}")
    -
    -async def handle_check(request):
    -    all_received = all(c.i_got_watch_update_event for c in clients)
    -    result = "yes" if all_received else "no"
    -    print(f"Received HTTP check — returning '{result}'")
    -    shutdown_event.set()  # Signal shutdown
    -    return web.Response(text=result)
    -
    -async def start_http_server():
    -    app = web.Application()
    -    app.add_routes([web.get('/did_all_clients_get_watch_update', handle_check)])
    -    runner = web.AppRunner(app)
    -    await runner.setup()
    -    site = web.TCPSite(runner, '0.0.0.0', 6666)
    -    await site.start()
    -
    -async def main():
    -    #await start_http_server()
    -
    -    for i in range(NUM_CLIENTS):
    -        client = WatchClient(i)
    -        clients.append(client)
    -        asyncio.create_task(client.run())
    -
    -    await shutdown_event.wait()
    -
    -    print("Shutting down...")
    -    # Graceful disconnect
    -    for c in clients:
    -        await c.sio.disconnect()
    -
    -if __name__ == "__main__":
    -    try:
    -        asyncio.run(main())
    -    except KeyboardInterrupt:
    -        print("Interrupted")
    
  • changedetectionio/tests/test_security.py+46 2 modified
    @@ -5,8 +5,22 @@
     from .. import strtobool
     
     
    -# def test_setup(client, live_server, measure_memory_usage):
    -   #  live_server_setup(live_server) # Setup on conftest per function
    +def set_original_response():
    +    test_return_data = """<html>
    +    <head><title>head title</title></head>
    +    <body>
    +     Some initial text<br>
    +     <p>Which is across multiple lines</p>
    +     <br>
    +     So let's see what happens.  <br>
    +     <span class="foobar-detection" style='display:none'></span>
    +     </body>
    +     </html>
    +    """
    +
    +    with open("test-datastore/endpoint-content.txt", "w") as f:
    +        f.write(test_return_data)
    +    return None
     
     def test_bad_access(client, live_server, measure_memory_usage):
         
    @@ -118,3 +132,33 @@ def test_xss(client, live_server, measure_memory_usage):
         assert b"<img src=x onerror=alert(" not in res.data
         assert b"&lt;img" in res.data
     
    +
    +def test_xss_watch_last_error(client, live_server, measure_memory_usage):
    +    set_original_response()
    +    # Add our URL to the import page
    +    res = client.post(
    +        url_for("imports.import_page"),
    +        data={"urls": url_for('test_endpoint', _external=True)},
    +        follow_redirects=True
    +    )
    +
    +    assert b"1 Imported" in res.data
    +
    +    wait_for_all_checks(client)
    +    res = client.post(
    +        url_for("ui.ui_edit.edit_page", uuid="first"),
    +        data={
    +            "include_filters": '<a href="https://foobar"></a><script>alert(123);</script>',
    +            "url": url_for('test_endpoint', _external=True),
    +            'fetch_backend': "html_requests"
    +        },
    +        follow_redirects=True
    +    )
    +    assert b"Updated watch." in res.data
    +    wait_for_all_checks(client)
    +    res = client.get(url_for("watchlist.index"))
    +
    +    assert b"<script>alert(123);</script>" not in res.data  # this text should be there
    +    assert b'&lt;a href=&#34;https://foobar&#34;&gt;&lt;/a&gt;&lt;script&gt;alert(123);&lt;/script&gt;' in res.data
    +    assert b"https://foobar" in res.data # this text should be there
    +
    
  • changedetectionio/tests/unit/test_jinja2_security.py+3 0 modified
    @@ -51,6 +51,9 @@ def test_exception_empty_calls(self):
             for attempt in attempt_list:
                 self.assertEqual(len(safe_jinja.render(attempt)), 0, f"string test '{attempt}' is correctly empty")
     
    +    def test_jinja2_escaped_html(self):
    +        x = safe_jinja.render_fully_escaped('woo <a href="https://google.com">dfdfd</a>')
    +        self.assertEqual(x, "woo &lt;a href=&#34;https://google.com&#34;&gt;dfdfd&lt;/a&gt;")
     
     
     if __name__ == '__main__':
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.