Skip to content

Re #2866 - Make sure any HTML type notifications have their content escaped - except for our added/remove/changed markup #2893

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/test-stack-reusable-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,22 +107,22 @@ jobs:
if: ${{ inputs.skip-pypuppeteer == false }}
run: |
# Playwright via Sockpuppetbrowser fetch
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_content.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_errorhandling.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/visualselector/test_fetch_data.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/fetchers/test_custom_js_before_content.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 --live-server-wait=20 tests/fetchers/test_content.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 --live-server-wait=20 tests/test_errorhandling.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 --live-server-wait=20 tests/visualselector/test_fetch_data.py'
docker run --rm -e "FLASK_SERVER_NAME=cdio" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network --hostname=cdio test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-host=0.0.0.0 --live-server-port=5004 --live-server-wait=20 tests/fetchers/test_custom_js_before_content.py'

- name: Pyppeteer and SocketPuppetBrowser - Headers and requests checks
if: ${{ inputs.skip-pypuppeteer == false }}
run: |
# Settings headers playwright tests - Call back in from Sockpuppetbrowser, check headers
docker run --name "changedet" --hostname changedet --rm -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
docker run --name "changedet" --hostname changedet --rm -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "FLASK_SERVER_NAME=changedet" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000?dumpio=true" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 --live-server-wait=20 tests/test_request.py'

- name: Pyppeteer and SocketPuppetBrowser - Restock detection
if: ${{ inputs.skip-pypuppeteer == false }}
run: |
# restock detection via playwright - added name=changedet here so that playwright and sockpuppetbrowser can connect to it
docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 tests/restock/test_restock.py'
docker run --rm --name "changedet" -e "FLASK_SERVER_NAME=changedet" -e "FAST_PUPPETEER_CHROME_FETCHER=True" -e "PLAYWRIGHT_DRIVER_URL=ws://sockpuppetbrowser:3000" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio;pytest --live-server-port=5004 --live-server-host=0.0.0.0 --live-server-wait=20 tests/restock/test_restock.py'

# SELENIUM
- name: Specific tests in built container for Selenium
Expand All @@ -132,7 +132,7 @@ jobs:

- name: Specific tests in built container for headers and requests checks with Selenium
run: |
docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 tests/test_request.py'
docker run --name "changedet" --hostname changedet --rm -e "FLASK_SERVER_NAME=changedet" -e "WEBDRIVER_URL=http://selenium:4444/wd/hub" --network changedet-network test-changedetectionio bash -c 'cd changedetectionio; pytest --live-server-host=0.0.0.0 --live-server-port=5004 --live-server-wait=20 tests/test_request.py'

# OTHER STUFF
- name: Test SMTP notification mime types
Expand Down
8 changes: 4 additions & 4 deletions changedetectionio/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,19 @@ def customSequenceMatcher(
yield before[alo:ahi]
elif include_removed and tag == 'delete':
if html_colour:
yield [f'<span style="{REMOVED_STYLE}">{line}</span>' for line in same_slicer(before, alo, ahi)]
yield [f'<span class="cdio" style="{REMOVED_STYLE}">{line}</span>' for line in same_slicer(before, alo, ahi)]
else:
yield [f"(removed) {line}" for line in same_slicer(before, alo, ahi)] if include_change_type_prefix else same_slicer(before, alo, ahi)
elif include_replaced and tag == 'replace':
if html_colour:
yield [f'<span style="{REMOVED_STYLE}">{line}</span>' for line in same_slicer(before, alo, ahi)] + \
[f'<span style="{ADDED_STYLE}">{line}</span>' for line in same_slicer(after, blo, bhi)]
yield [f'<span class="cdio" style="{REMOVED_STYLE}">{line}</span>' for line in same_slicer(before, alo, ahi)] + \
[f'<span class="cdio" style="{ADDED_STYLE}">{line}</span>' for line in same_slicer(after, blo, bhi)]
else:
yield [f"(changed) {line}" for line in same_slicer(before, alo, ahi)] + \
[f"(into) {line}" for line in same_slicer(after, blo, bhi)] if include_change_type_prefix else same_slicer(before, alo, ahi) + same_slicer(after, blo, bhi)
elif include_added and tag == 'insert':
if html_colour:
yield [f'<span style="{ADDED_STYLE}">{line}</span>' for line in same_slicer(after, blo, bhi)]
yield [f'<span class="cdio" style="{ADDED_STYLE}">{line}</span>' for line in same_slicer(after, blo, bhi)]
else:
yield [f"(added) {line}" for line in same_slicer(after, blo, bhi)] if include_change_type_prefix else same_slicer(after, blo, bhi)

Expand Down
15 changes: 13 additions & 2 deletions changedetectionio/flask_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import time
import timeago

from .html_tools import escape_mixed_content
from .processors import find_processors, get_parent_module, get_custom_watch_obj_for_processor
from .safe_jinja import render as jinja_render
from changedetectionio.strtobool import strtobool
Expand Down Expand Up @@ -539,6 +540,9 @@ def ajax_callback_send_notification_test(watch_uuid=None):
import apprise
import random
from .apprise_asset import asset
from .notification import default_notification_format
from .update_worker import build_notification_object_for_watch

apobj = apprise.Apprise(asset=asset)

# so that the custom endpoints are registered
Expand Down Expand Up @@ -595,6 +599,8 @@ def ajax_callback_send_notification_test(watch_uuid=None):
# Only use if present, if not set in n_object it should use the default system value
if 'notification_format' in request.form and request.form['notification_format'].strip():
n_object['notification_format'] = request.form.get('notification_format', '').strip()
else:
n_object['notification_format'] = default_notification_format

if 'notification_title' in request.form and request.form['notification_title'].strip():
n_object['notification_title'] = request.form.get('notification_title', '').strip()
Expand All @@ -610,9 +616,14 @@ def ajax_callback_send_notification_test(watch_uuid=None):
else:
n_object['notification_body'] = "Test body"

n_object['as_async'] = False
n_object.update(watch.extra_notification_token_values())
n_object = build_notification_object_for_watch(watch, n_object, datastore.data['settings']['application'].get('notification_body'))

if n_object['notification_format'].startswith('HTML'):
n_object['notification_body'] = escape_mixed_content(n_object['notification_body'])

from .notification import process_notification
n_object['as_async'] = False
# Now we send the notification_body after everything is compiled
sent_obj = process_notification(n_object, datastore)

except Exception as e:
Expand Down
37 changes: 37 additions & 0 deletions changedetectionio/html_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,3 +500,40 @@ def get_triggered_text(content, trigger_text):
i += 1

return triggered_text


from bs4 import BeautifulSoup
import html


def escape_mixed_content(document):
import uuid
# Parse the document as HTML

# Generate a single random hash for placeholders
random_hash = f"__PLACEHOLDER_{uuid.uuid4().hex}__"
placeholder_map = []

# <br> to something else so we can preserve them
random_hash_br = f"__BR_{uuid.uuid4().hex}__"
document = document.replace('<br>', random_hash_br)

soup = BeautifulSoup(document, 'html.parser')

# Find all <span class="cdio"> and <br>/<br/>
for tag in soup.find_all("span", class_="cdio"):
placeholder_map.append(str(tag)) # Save the tag as a string
tag.replace_with(random_hash) # Replace tag with the placeholder



# Escape the entire document
escaped_html = html.escape(str(soup))

# Restore all occurrences of placeholders with the original tags
for original_tag in placeholder_map:
escaped_html = escaped_html.replace(random_hash, original_tag, 1) # Replace one occurrence at a time

escaped_html = escaped_html.replace( random_hash_br, "<br>")
return escaped_html

4 changes: 3 additions & 1 deletion changedetectionio/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import apprise
from loguru import logger

from changedetectionio.html_tools import escape_mixed_content

valid_tokens = {
'base_url': '',
Expand Down Expand Up @@ -85,6 +86,8 @@ def process_notification(n_object, datastore):
n_body = n_body.replace("\n", '<br>')

n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)
if n_object['notification_format'].startswith('HTML'):
n_body = escape_mixed_content(n_body)

url = url.strip()
if url.startswith('#'):
Expand Down Expand Up @@ -161,7 +164,6 @@ def process_notification(n_object, datastore):
attach=n_object.get('screenshot', None)
)


# Returns empty string if nothing found, multi-line string otherwise
log_value = logs.getvalue()

Expand Down
17 changes: 14 additions & 3 deletions changedetectionio/tests/test_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,8 +454,18 @@ def test_global_send_test_notification(client, live_server, measure_memory_usage
assert b"Error: You must have atleast one watch configured for 'test notification' to work" in res.data


client.get(
url_for("form_delete", uuid="all"),
follow_redirects=True
)

def _test_color_notifications(client, notification_body_token):

client.get(
url_for("form_delete", uuid="all"),
follow_redirects=True
)

from changedetectionio.diff import ADDED_STYLE, REMOVED_STYLE

set_original_response()
Expand Down Expand Up @@ -494,17 +504,18 @@ def _test_color_notifications(client, notification_body_token):
wait_for_all_checks(client)

set_modified_response()



res = client.get(url_for("form_watch_checknow"), follow_redirects=True)

assert b'1 watches queued for rechecking.' in res.data

wait_for_all_checks(client)
time.sleep(3)

with open("test-datastore/notification.txt", 'r') as f:
x = f.read()
assert f'<span style="{REMOVED_STYLE}">Which is across multiple lines' in x
assert f'<span class="cdio" style="{REMOVED_STYLE}">Which is across multiple lines' in x
assert f'<br>' in x


client.get(
Expand Down
Loading
Loading