Compare commits

...

32 Commits

Author SHA1 Message Date
dgtlmoon
d579e26cd6 this logic also not needed 2024-05-14 13:27:55 +02:00
dgtlmoon
f8fe5c8d41 Set min to 3 seconds 2024-05-14 13:20:38 +02:00
dgtlmoon
bddf9af584 Explain system min settings better 2024-05-14 13:13:52 +02:00
dgtlmoon
643a45b5e2 Tidy up logic to be more readable 2024-05-14 13:07:09 +02:00
dgtlmoon
e9f1cc91c7 remove unused var 2024-05-14 13:02:29 +02:00
dgtlmoon
080526eb65 Remove test for old logic 2024-05-14 12:51:38 +02:00
dgtlmoon
c9552d2319 could be 0 or None, same thing 2024-05-14 12:24:03 +02:00
dgtlmoon
3c14273021 Updat text 2024-05-14 12:22:09 +02:00
dgtlmoon
547ab70ea3 Initial commit 2024-05-14 12:19:18 +02:00
dgtlmoon
5df5d0fbe7 UI - Search should scan/search error messages (#2353) 2024-05-10 18:20:49 +02:00
dgtlmoon
815cba11ca UI - 'stats' tab should show what the server-type detected is ( #2348 ) 2024-05-07 15:23:42 +02:00
dgtlmoon
3aed4e5af9 Update README.md 2024-05-05 18:21:10 +02:00
dgtlmoon
3618c389c6 Notifications - Setting set minimum version for mqtt:// library notifications (#2334 / #2333) 2024-05-02 16:51:56 +02:00
dgtlmoon
d127214d8f 0.45.22 2024-05-02 12:09:45 +02:00
dgtlmoon
c0f000b1d1 Merge pull request from GHSA-pwgc-w4x9-gw67
* Auto-escape was not enabled GHSA-pwgc-w4x9-gw67

* Auto-escape was not enabled because the filenames were not something jinja2 enables it for.
2024-05-02 11:46:31 +02:00
dgtlmoon
ee5294740a 0.45.21 2024-04-25 22:29:38 +02:00
dgtlmoon
bd6eda696c Merge pull request from GHSA-4r7v-whpg-8rx3
* CVE-2024-32651 - Security fix - Server Side Template Injection in Jinja2 allows Remote Command Execution

* use ImmutableSandboxedEnvironment also in validation
2024-04-25 22:06:09 +02:00
dgtlmoon
1ba29655f5 UI - Wrap tag names in solid background to make it easier to read when theres multiple tags 2024-04-20 20:34:52 +02:00
dgtlmoon
830a0a3a82 UI - Error text on exception should contain the word Exception (#2322) 2024-04-19 08:54:25 +02:00
dgtlmoon
e110b3ee93 0.45.20 2024-04-18 11:55:46 +02:00
dgtlmoon
3ae9bfa6f9 Bug fix - further work on lxml filter extract (#2313 #2312 #2317) 2024-04-18 11:53:45 +02:00
dgtlmoon
6f3c3b7dfb 0.45.19 2024-04-17 20:01:35 +02:00
dgtlmoon
74707909f1 Bug fix for newer lxml module - module 'lxml.etree' has no attribute '_ElementStringResult' - reimplement _ElementStringResult (#2313 #2312) 2024-04-17 19:55:45 +02:00
dgtlmoon
d4dac23ba1 0.45.18 2024-04-16 18:50:14 +02:00
dgtlmoon
f9954f93f3 UI - Adding UI notice if watch has group options set (#2311 #2307) 2024-04-16 18:48:51 +02:00
dgtlmoon
1a43b112dc dependabot - automatically follow apprise 2024-04-15 11:17:50 +02:00
dgtlmoon
db59bf73e1 "Send Test Notification" - In "Group" settings form it should not fallback to the system wide notifications when sending a test if nothing is set. 2024-04-03 17:10:13 +02:00
dgtlmoon
8aac7bccbe "Send Test Notification" - Now provides better feedback and works with the actual values in system settings form 2024-04-03 16:52:42 +02:00
dgtlmoon
9449c59fbb Code - Getting ready for newer python versions - packing our own strtobool (#2291) 2024-04-03 16:17:15 +02:00
dgtlmoon
21f4ba2208 UI - BrowserSteps - Show step screenshot/pic should use absolute URL #2243 2024-04-03 16:15:33 +02:00
dgtlmoon
daef1cd036 UI - Remove unique check for URLs entered on the "quick watch add" form ( #2286 #2292 ) 2024-04-03 16:08:33 +02:00
dgtlmoon
56b365df40 UI - Improvements to tag/groups page, show number of watches under each group, link group name to list (#2290) 2024-04-03 16:01:24 +02:00
48 changed files with 440 additions and 269 deletions

View File

@@ -4,6 +4,10 @@ updates:
directory: /
schedule:
interval: "weekly"
"caronc/apprise":
versioning-strategy: "increase"
schedule:
interval: "daily"
groups:
all:
patterns:

View File

@@ -59,6 +59,7 @@ jobs:
echo "run test with unittest"
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
# All tests
echo "run test with pytest"

View File

@@ -257,13 +257,7 @@ Supports managing the website watch list [via our API](https://changedetection.i
Do you use changedetection.io to make money? does it save you time or money? Does it make your life easier? less stressful? Remember, we write this software when we should be doing actual paid work, we have to buy food and pay rent just like you.
Firstly, consider taking out an officially supported [website change detection subscription](https://changedetection.io?src=github) , even if you don't use it, you still get the warm fuzzy feeling of helping out the project. (And who knows, you might just use it!)
Or directly donate an amount PayPal [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/donate/?hosted_button_id=7CP6HR9ZCNDYJ)
Or BTC `1PLFN327GyUarpJd7nVe7Reqg9qHx5frNn`
<img src="https://raw.githubusercontent.com/dgtlmoon/changedetection.io/master/docs/btc-support.png" style="max-width:50%;" alt="Support us!" />
Consider taking out an officially supported [website change detection subscription](https://changedetection.io?src=github) , even if you don't use it, you still get the warm fuzzy feeling of helping out the project. (And who knows, you might just use it!)
## Commercial Support

View File

@@ -2,12 +2,12 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
__version__ = '0.45.17'
__version__ = '0.45.22'
from distutils.util import strtobool
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
import os
#os.environ['EVENTLET_NO_GREENDNS'] = 'yes'
os.environ['EVENTLET_NO_GREENDNS'] = 'yes'
import eventlet
import eventlet.wsgi
import getopt

View File

@@ -1,5 +1,5 @@
import os
from distutils.util import strtobool
from changedetectionio.strtobool import strtobool
from flask_expects_json import expects_json
from changedetectionio import queuedWatchMetaData

View File

@@ -12,7 +12,7 @@
#
#
from distutils.util import strtobool
from changedetectionio.strtobool import strtobool
from flask import Blueprint, request, make_response
import os

View File

@@ -7,6 +7,7 @@ from random import randint
from loguru import logger
from changedetectionio.content_fetchers.base import manage_user_agent
from changedetectionio.safe_jinja import render as jinja_render
# Two flags, tell the JS which of the "Selector" or "Value" field should be enabled in the front end
# 0- off, 1- on
@@ -64,14 +65,12 @@ class steppable_browser_interface():
action_handler = getattr(self, "action_" + call_action_name)
# Support for Jinja2 variables in the value and selector
from jinja2 import Environment
jinja2_env = Environment(extensions=['jinja2_time.TimeExtension'])
if selector and ('{%' in selector or '{{' in selector):
selector = str(jinja2_env.from_string(selector).render())
selector = jinja_render(template_str=selector)
if optional_value and ('{%' in optional_value or '{{' in optional_value):
optional_value = str(jinja2_env.from_string(optional_value).render())
optional_value = jinja_render(template_str=optional_value)
action_handler(selector, optional_value)
self.page.wait_for_timeout(1.5 * 1000)

View File

@@ -31,9 +31,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
import time
from changedetectionio.content_fetchers import exceptions as content_fetcher_exceptions
from changedetectionio.processors import text_json_diff
from changedetectionio.safe_jinja import render as jinja_render
status = {'status': '', 'length': 0, 'text': ''}
from jinja2 import Environment, BaseLoader
contents = ''
now = time.time()
@@ -64,7 +64,9 @@ def construct_blueprint(datastore: ChangeDetectionStore):
status.update({'status': 'OK', 'length': len(contents), 'text': ''})
if status.get('text'):
status['text'] = Environment(loader=BaseLoader()).from_string('{{text|e}}').render({'text': status['text']})
# parse 'text' as text for safety
v = {'text': status['text']}
status['text'] = jinja_render(template_str='{{text|e}}', **v)
status['time'] = "{:.2f}s".format(time.time() - now)

View File

@@ -1,5 +1,5 @@
from distutils.util import strtobool
from changedetectionio.strtobool import strtobool
from flask import Blueprint, flash, redirect, url_for
from flask_login import login_required
from changedetectionio.store import ChangeDetectionStore

View File

@@ -12,9 +12,15 @@ def construct_blueprint(datastore: ChangeDetectionStore):
from .form import SingleTag
add_form = SingleTag(request.form)
sorted_tags = sorted(datastore.data['settings']['application'].get('tags').items(), key=lambda x: x[1]['title'])
from collections import Counter
tag_count = Counter(tag for watch in datastore.data['watching'].values() if watch.get('tags') for tag in watch['tags'])
output = render_template("groups-overview.html",
form=add_form,
available_tags=sorted_tags,
form=add_form,
tag_count=tag_count
)
return output

View File

@@ -1,9 +1,9 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %}
{% from '_common_fields.jinja' import render_common_settings_form %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
{% from '_common_fields.html' import render_common_settings_form %}
<script>
const notification_base_url="{{url_for('ajax_callback_send_notification_test', watch_uuid=uuid)}}";
const notification_base_url="{{url_for('ajax_callback_send_notification_test', mode="group-settings")}}";
</script>
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>

View File

@@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.jinja' import render_simple_field, render_field %}
{% from '_helpers.html' import render_simple_field, render_field %}
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<div class="box">
@@ -27,6 +27,7 @@
<thead>
<tr>
<th></th>
<th># Watches</th>
<th>Tag / Label name</th>
<th></th>
</tr>
@@ -45,7 +46,8 @@
<td class="watch-controls">
<a class="link-mute state-{{'on' if tag.notification_muted else 'off'}}" href="{{url_for('tags.mute', uuid=tag.uuid)}}"><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="Mute notifications" title="Mute notifications" class="icon icon-mute" ></a>
</td>
<td class="title-col inline">{{tag.title}}</td>
<td>{{ "{:,}".format(tag_count[uuid]) if uuid in tag_count else 0 }}</td>
<td class="title-col inline"> <a href="{{url_for('index', tag=uuid) }}">{{ tag.title }}</a></td>
<td>
<a class="pure-button pure-button-primary" href="{{ url_for('tags.form_tag_edit', uuid=uuid) }}">Edit</a>&nbsp;
<a class="pure-button pure-button-primary" href="{{ url_for('tags.delete', uuid=uuid) }}" title="Deletes and removes tag">Delete</a>

View File

@@ -1,5 +1,5 @@
import sys
from distutils.util import strtobool
from changedetectionio.strtobool import strtobool
from loguru import logger
from changedetectionio.content_fetchers.exceptions import BrowserStepsStepException
import os

View File

@@ -123,8 +123,7 @@ class Fetcher():
def iterate_browser_steps(self):
from changedetectionio.blueprint.browser_steps.browser_steps import steppable_browser_interface
from playwright._impl._errors import TimeoutError, Error
from jinja2 import Environment
jinja2_env = Environment(extensions=['jinja2_time.TimeExtension'])
from changedetectionio.safe_jinja import render as jinja_render
step_n = 0
@@ -143,9 +142,9 @@ class Fetcher():
selector = step['selector']
# Support for jinja2 template in step values, with date module added
if '{%' in step['optional_value'] or '{{' in step['optional_value']:
optional_value = str(jinja2_env.from_string(step['optional_value']).render())
optional_value = jinja_render(template_str=step['optional_value'])
if '{%' in step['selector'] or '{{' in step['selector']:
selector = str(jinja2_env.from_string(step['selector']).render())
selector = jinja_render(template_str=step['selector'])
getattr(interface, "call_action")(action_name=step['operation'],
selector=selector,

View File

@@ -5,11 +5,11 @@ import os
import queue
import threading
import time
from .safe_jinja import render as jinja_render
from changedetectionio.strtobool import strtobool
from copy import deepcopy
from distutils.util import strtobool
from functools import wraps
from threading import Event
import flask_login
import pytz
import timeago
@@ -319,8 +319,6 @@ def changedetection_app(config=None, datastore_o=None):
@app.route("/rss", methods=['GET'])
def rss():
from jinja2 import Environment, BaseLoader
jinja2_env = Environment(loader=BaseLoader)
now = time.time()
# Always requires token set
app_rss_token = datastore.data['settings']['application'].get('rss_access_token')
@@ -388,7 +386,7 @@ def changedetection_app(config=None, datastore_o=None):
# @todo Make this configurable and also consider html-colored markup
# @todo User could decide if <link> goes to the diff page, or to the watch link
rss_template = "<html><body>\n<h4><a href=\"{{watch_url}}\">{{watch_title}}</a></h4>\n<p>{{html_diff}}</p>\n</body></html>\n"
content = jinja2_env.from_string(rss_template).render(watch_title=watch_title, html_diff=html_diff, watch_url=watch.link)
content = jinja_render(template_str=rss_template, watch_title=watch_title, html_diff=html_diff, watch_url=watch.link)
fe.content(content=content, type='CDATA')
@@ -452,6 +450,8 @@ def changedetection_app(config=None, datastore_o=None):
if search_q:
if (watch.get('title') and search_q in watch.get('title').lower()) or search_q in watch.get('url', '').lower():
sorted_watches.append(watch)
elif watch.get('last_error') and search_q in watch.get('last_error').lower():
sorted_watches.append(watch)
else:
sorted_watches.append(watch)
@@ -519,26 +519,29 @@ def changedetection_app(config=None, datastore_o=None):
notification_urls = request.form['notification_urls'].strip().splitlines()
if not notification_urls:
logger.debug("Test notification - Trying by group/tag")
if request.form['tags'].strip():
logger.debug("Test notification - Trying by group/tag in the edit form if available")
# On an edit page, we should also fire off to the tags if they have notifications
if request.form.get('tags') and request.form['tags'].strip():
for k in request.form['tags'].split(','):
tag = datastore.tag_exists_by_name(k.strip())
notification_urls = tag.get('notifications_urls') if tag and tag.get('notifications_urls') else None
if not notification_urls:
is_global_settings_form = request.args.get('mode', '') == 'global-settings'
is_group_settings_form = request.args.get('mode', '') == 'group-settings'
if not notification_urls and not is_global_settings_form and not is_group_settings_form:
# In the global settings, use only what is typed currently in the text box
logger.debug("Test notification - Trying by global system settings notifications")
if datastore.data['settings']['application'].get('notification_urls'):
notification_urls = datastore.data['settings']['application']['notification_urls']
if not notification_urls:
return make_response({'error': 'No Notification URLs set'}, 400)
return 'No Notification URLs set/found'
for n_url in notification_urls:
if len(n_url.strip()):
if not apobj.add(n_url):
message = '{} is not a valid AppRise URL.'.format(n_url)
return make_response({'error': message}, 400)
return f'Error - {n_url} is not a valid AppRise URL.'
try:
# use the same as when it is triggered, but then override it with the form test values
@@ -563,7 +566,7 @@ def changedetection_app(config=None, datastore_o=None):
except Exception as e:
return make_response({'error': str(e)}, 400)
return 'OK'
return 'OK - Sent test notifications'
@app.route("/clear_history/<string:uuid>", methods=['GET'])
@@ -600,6 +603,12 @@ def changedetection_app(config=None, datastore_o=None):
output = render_template("clear_all_history.html")
return output
def _watch_has_tag_options_set(watch):
"""This should be fixed better so that Tag is some proper Model, a tag is just a Watch also"""
for tag_uuid, tag in datastore.data['settings']['application'].get('tags', {}).items():
if tag_uuid in watch.get('tags', []) and (tag.get('include_filters') or tag.get('subtractive_selectors')):
return True
@app.route("/edit/<string:uuid>", methods=['GET', 'POST'])
@login_optionally_required
# https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists
@@ -610,7 +619,6 @@ def changedetection_app(config=None, datastore_o=None):
from .blueprint.browser_steps.browser_steps import browser_step_ui_config
from . import processors
using_default_check_time = True
# More for testing, possible to return the first/only
if not datastore.data['watching'].keys():
flash("No watches to edit", "error")
@@ -635,10 +643,6 @@ def changedetection_app(config=None, datastore_o=None):
# be sure we update with a copy instead of accidently editing the live object by reference
default = deepcopy(datastore.data['watching'][uuid])
# Show system wide default if nothing configured
if all(value == 0 or value == None for value in datastore.data['watching'][uuid]['time_between_check'].values()):
default['time_between_check'] = deepcopy(datastore.data['settings']['requests']['time_between_check'])
# Defaults for proxy choice
if datastore.proxy_list is not None: # When enabled
# @todo
@@ -676,18 +680,8 @@ def changedetection_app(config=None, datastore_o=None):
if request.args.get('unpause_on_save'):
extra_update_obj['paused'] = False
# Re #110, if they submit the same as the default value, set it to None, so we continue to follow the default
# Assume we use the default value, unless something relevant is different, then use the form value
# values could be None, 0 etc.
# Set to None unless the next for: says that something is different
extra_update_obj['time_between_check'] = dict.fromkeys(form.time_between_check.data)
for k, v in form.time_between_check.data.items():
if v and v != datastore.data['settings']['requests']['time_between_check'][k]:
extra_update_obj['time_between_check'] = form.time_between_check.data
using_default_check_time = False
break
extra_update_obj['time_between_check'] = form.time_between_check.data
# Ignore text
form_ignore_text = form.ignore_text.data
@@ -768,8 +762,8 @@ def changedetection_app(config=None, datastore_o=None):
extra_title=f" - Edit - {watch.label}",
form=form,
has_default_notification_urls=True if len(datastore.data['settings']['application']['notification_urls']) else False,
has_empty_checktime=using_default_check_time,
has_extra_headers_file=len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,
has_special_tag_options=_watch_has_tag_options_set(watch=watch),
is_html_webdriver=is_html_webdriver,
jq_support=jq_support,
playwright_enabled=os.getenv('PLAYWRIGHT_DRIVER_URL', False),
@@ -853,11 +847,13 @@ def changedetection_app(config=None, datastore_o=None):
flash("An error occurred, please see below.", "error")
output = render_template("settings.html",
form=form,
hide_remove_pass=os.getenv("SALTED_PASS", False),
api_key=datastore.data['settings']['application'].get('api_access_token'),
emailprefix=os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False),
settings_application=datastore.data['settings']['application'])
form=form,
hide_remove_pass=os.getenv("SALTED_PASS", False),
min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)),
settings_application=datastore.data['settings']['application']
)
return output
@@ -1293,9 +1289,8 @@ def changedetection_app(config=None, datastore_o=None):
url = request.form.get('url').strip()
if datastore.url_exists(url):
flash('The URL {} already exists'.format(url), "error")
return redirect(url_for('index'))
flash(f'Warning, URL {url} already exists', "notice")
add_paused = request.form.get('edit_and_watch_submit_button') != None
processor = request.form.get('processor', 'text_json_diff')
new_uuid = datastore.add_watch(url=url, tag=request.form.get('tags').strip(), extras={'paused': add_paused, 'processor': processor})
@@ -1659,14 +1654,14 @@ def notification_runner():
# Trim the log length
notification_debug_log = notification_debug_log[-100:]
# Thread runner to check every minute, look for new watches to feed into the Queue.
# Threaded runner, look for new watches to feed into the Queue.
def ticker_thread_check_time_launch_checks():
import random
from changedetectionio import update_worker
proxy_last_called_time = {}
recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 20))
recheck_time_minimum_seconds = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
logger.debug(f"System env MINIMUM_SECONDS_RECHECK_TIME {recheck_time_minimum_seconds}")
# Spin up Workers that do the fetching
@@ -1720,9 +1715,7 @@ def ticker_thread_check_time_launch_checks():
continue
# If they supplied an individual entry minutes to threshold.
watch_threshold_seconds = watch.threshold_seconds()
threshold = watch_threshold_seconds if watch_threshold_seconds > 0 else recheck_time_system_seconds
threshold = recheck_time_system_seconds if watch.get('time_between_check_use_default') else watch.threshold_seconds()
# #580 - Jitter plus/minus amount of time to make the check seem more random to the server
jitter = datastore.data['settings']['requests'].get('jitter_seconds', 0)

View File

@@ -1,6 +1,6 @@
import os
import re
from distutils.util import strtobool
from changedetectionio.strtobool import strtobool
from wtforms import (
BooleanField,
@@ -236,21 +236,26 @@ class ValidateJinja2Template(object):
def __call__(self, form, field):
from changedetectionio import notification
from jinja2 import Environment, BaseLoader, TemplateSyntaxError, UndefinedError
from jinja2 import BaseLoader, TemplateSyntaxError, UndefinedError
from jinja2.sandbox import ImmutableSandboxedEnvironment
from jinja2.meta import find_undeclared_variables
import jinja2.exceptions
# Might be a list of text, or might be just text (like from the apprise url list)
joined_data = ' '.join(map(str, field.data)) if isinstance(field.data, list) else f"{field.data}"
try:
jinja2_env = Environment(loader=BaseLoader)
jinja2_env = ImmutableSandboxedEnvironment(loader=BaseLoader)
jinja2_env.globals.update(notification.valid_tokens)
rendered = jinja2_env.from_string(field.data).render()
jinja2_env.from_string(joined_data).render()
except TemplateSyntaxError as e:
raise ValidationError(f"This is not a valid Jinja2 template: {e}") from e
except UndefinedError as e:
raise ValidationError(f"A variable or function is not defined: {e}") from e
except jinja2.exceptions.SecurityError as e:
raise ValidationError(f"This is not a valid Jinja2 template: {e}") from e
ast = jinja2_env.parse(field.data)
ast = jinja2_env.parse(joined_data)
undefined = ", ".join(find_undeclared_variables(ast))
if undefined:
raise ValidationError(
@@ -415,7 +420,7 @@ class quickWatchForm(Form):
# Common to a single watch and the global settings
class commonSettingsForm(Form):
notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers()])
notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()])
notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])
notification_body = TextAreaField('Notification Body', default='{{ watch_url }} had a change.', validators=[validators.Optional(), ValidateJinja2Template()])
notification_format = SelectField('Notification format', choices=valid_notification_formats.keys())
@@ -448,6 +453,7 @@ class watchForm(commonSettingsForm):
tags = StringTagUUID('Group tag', [validators.Optional()], default='')
time_between_check = FormField(TimeBetweenCheckForm)
time_between_check_use_default = BooleanField('Use global settings for time between check', default=False)
include_filters = StringListField('CSS/JSONPath/JQ/XPath Filters', [ValidateCSSJSONXPATHInput()], default='')
@@ -499,11 +505,9 @@ class watchForm(commonSettingsForm):
result = False
# Attempt to validate jinja2 templates in the URL
from jinja2 import Environment
# Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/
jinja2_env = Environment(extensions=['jinja2_time.TimeExtension'])
try:
ready_url = str(jinja2_env.from_string(self.url.data).render())
from changedetectionio.safe_jinja import render as jinja_render
jinja_render(template_str=self.url.data)
except Exception as e:
self.url.errors.append('Invalid template syntax')
result = False

View File

@@ -169,14 +169,14 @@ def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=Fals
# And where the matched result doesn't include something that will cause Inscriptis to add a newline
# (This way each 'match' reliably has a new-line in the diff)
# Divs are converted to 4 whitespaces by inscriptis
if append_pretty_line_formatting and len(html_block) and (not hasattr( element, 'tag' ) or not element.tag in (['br', 'hr', 'div', 'p'])):
if append_pretty_line_formatting and len(html_block) and (not hasattr(element, 'tag') or not element.tag in (['br', 'hr', 'div', 'p'])):
html_block += TEXT_FILTER_LIST_LINE_SUFFIX
if type(element) == etree._ElementStringResult:
html_block += str(element)
elif type(element) == etree._ElementUnicodeResult:
html_block += str(element)
# Some kind of text, UTF-8 or other
if isinstance(element, (str, bytes)):
html_block += element
else:
# Return the HTML which will get parsed as text
html_block += etree.tostring(element, pretty_print=True).decode('utf-8')
return html_block

View File

@@ -1,4 +1,6 @@
from distutils.util import strtobool
from changedetectionio.strtobool import strtobool
from changedetectionio.safe_jinja import render as jinja_render
import os
import re
import time
@@ -10,7 +12,7 @@ from loguru import logger
# file:// is further checked by ALLOW_FILE_URI
SAFE_PROTOCOL_REGEX='^(http|https|ftp|file):'
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 60))
minimum_seconds_recheck_time = int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3))
mtable = {'seconds': 1, 'minutes': 60, 'hours': 3600, 'days': 86400, 'weeks': 86400 * 7}
from changedetectionio.notification import (
@@ -67,6 +69,7 @@ base_config = {
# Requires setting to None on submit if it's the same as the default
# Should be all None by default, so we use the system default in this case.
'time_between_check': {'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': None},
'time_between_check_use_default': True,
'title': None,
'trigger_text': [], # List of text or regex to wait for until a change is detected
'url': '',
@@ -137,12 +140,11 @@ class model(dict):
ready_url = url
if '{%' in url or '{{' in url:
from jinja2 import Environment
# Jinja2 available in URLs along with https://pypi.org/project/jinja2-time/
jinja2_env = Environment(extensions=['jinja2_time.TimeExtension'])
try:
ready_url = str(jinja2_env.from_string(url).render())
ready_url = jinja_render(template_str=url)
except Exception as e:
logger.critical(f"Invalid URL template for: '{url}' - {str(e)}")
from flask import (
flash, Markup, url_for
)
@@ -362,6 +364,7 @@ class model(dict):
# @todo bump static cache of the last timestamp so we dont need to examine the file to set a proper ''viewed'' status
return snapshot_fname
@property
@property
def has_empty_checktime(self):
# using all() + dictionary comprehension

View File

@@ -1,6 +1,5 @@
import apprise
import time
from jinja2 import Environment, BaseLoader
from apprise import NotifyFormat
import json
from loguru import logger
@@ -116,6 +115,7 @@ def apprise_custom_api_call_wrapper(body, title, notify_type, *args, **kwargs):
def process_notification(n_object, datastore):
from .safe_jinja import render as jinja_render
now = time.time()
if n_object.get('notification_timestamp'):
logger.trace(f"Time since queued {now-n_object['notification_timestamp']:.3f}s")
@@ -123,9 +123,9 @@ def process_notification(n_object, datastore):
notification_parameters = create_notification_parameters(n_object, datastore)
# Get the notification body from datastore
jinja2_env = Environment(loader=BaseLoader)
n_body = jinja2_env.from_string(n_object.get('notification_body', '')).render(**notification_parameters)
n_title = jinja2_env.from_string(n_object.get('notification_title', '')).render(**notification_parameters)
n_body = jinja_render(template_str=n_object.get('notification_body', ''), **notification_parameters)
n_title = jinja_render(template_str=n_object.get('notification_title', ''), **notification_parameters)
n_format = valid_notification_formats.get(
n_object.get('notification_format', default_notification_format),
valid_notification_formats[default_notification_format],
@@ -157,7 +157,7 @@ def process_notification(n_object, datastore):
continue
logger.info(">> Process Notification: AppRise notifying {}".format(url))
url = jinja2_env.from_string(url).render(**notification_parameters)
url = jinja_render(template_str=url, **notification_parameters)
# Re 323 - Limit discord length to their 2000 char limit total or it wont send.
# Because different notifications may require different pre-processing, run each sequentially :(

View File

@@ -3,7 +3,7 @@ import os
import hashlib
import re
from copy import deepcopy
from distutils.util import strtobool
from changedetectionio.strtobool import strtobool
from loguru import logger
class difference_detection_processor():

View File

@@ -0,0 +1,18 @@
"""
Safe Jinja2 render with max payload sizes
See https://jinja.palletsprojects.com/en/3.1.x/sandbox/#security-considerations
"""
import jinja2.sandbox
import typing as t
import os
JINJA2_MAX_RETURN_PAYLOAD_SIZE = 1024 * int(os.getenv("JINJA2_MAX_RETURN_PAYLOAD_SIZE_KB", 1024 * 10))
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]

View File

@@ -45,7 +45,7 @@ $(document).ready(function() {
}
}).done(function(data){
console.log(data);
alert('Sent');
alert(data);
}).fail(function(data){
console.log(data);
alert('There was an error communicating with the server.');

View File

@@ -1,3 +1,17 @@
function toggleOpacity(checkboxSelector, fieldSelector) {
const checkbox = document.querySelector(checkboxSelector);
const fields = document.querySelectorAll(fieldSelector);
function updateOpacity() {
const opacityValue = checkbox.checked ? 0.6 : 1;
fields.forEach(field => {
field.style.opacity = opacityValue;
});
}
// Initial setup
updateOpacity();
checkbox.addEventListener('change', updateOpacity);
}
$(document).ready(function () {
$('#notification-setting-reset-to-default').click(function (e) {
$('#notification_title').val('');
@@ -10,4 +24,7 @@ $(document).ready(function () {
e.preventDefault();
$('#notification-tokens-info').toggle();
});
toggleOpacity('#time_between_check_use_default', '#time_between_check');
});

View File

@@ -68,7 +68,7 @@
--color-last-checked: #bbb;
--color-text-footer: #444;
--color-border-watch-table-cell: #eee;
--color-text-watch-tag-list: #e70069;
--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);
--color-background-new-watch-form: rgba(0, 0, 0, 0.05);
--color-background-new-watch-input: var(--color-white);
--color-text-new-watch-input: var(--color-text);
@@ -111,7 +111,7 @@ html[data-darkmode="true"] {
--color-background-input: var(--color-grey-350);
--color-text-input-description: var(--color-grey-600);
--color-text-input-placeholder: var(--color-grey-600);
--color-text-watch-tag-list: #fa3e92;
--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);
--color-background-code: var(--color-grey-200);
--color-background-tab: rgba(0, 0, 0, 0.2);
--color-background-tab-hover: rgba(0, 0, 0, 0.5);

View File

@@ -75,7 +75,7 @@
--color-text-footer: #444;
--color-border-watch-table-cell: #eee;
--color-text-watch-tag-list: #e70069;
--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);
--color-background-new-watch-form: rgba(0, 0, 0, 0.05);
--color-background-new-watch-input: var(--color-white);
--color-text-new-watch-input: var(--color-text);
@@ -127,7 +127,7 @@ html[data-darkmode="true"] {
--color-background-input: var(--color-grey-350);
--color-text-input-description: var(--color-grey-600);
--color-text-input-placeholder: var(--color-grey-600);
--color-text-watch-tag-list: #fa3e92;
--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);
--color-background-code: var(--color-grey-200);
--color-background-tab: rgba(0, 0, 0, 0.2);

View File

@@ -187,8 +187,11 @@ code {
}
.watch-tag-list {
color: var(--color-text-watch-tag-list);
color: var(--color-white);
white-space: nowrap;
background: var(--color-text-watch-tag-list);
border-radius: 5px;
padding: 2px 5px;
}
.box {
@@ -925,23 +928,26 @@ body.full-width {
font-size: .875em;
}
}
.text-filtering {
h3 {
margin-top: 0;
}
border: 1px solid #ccc;
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem;
fieldset:last-of-type {
}
.border-fieldset {
h3 {
margin-top: 0;
}
border: 1px solid #ccc;
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem;
fieldset:last-of-type {
padding-bottom: 0;
.pure-control-group {
padding-bottom: 0;
.pure-control-group {
padding-bottom: 0;
}
}
}
}
ul {
padding-left: 1em;
padding-top: 0px;

View File

@@ -284,7 +284,7 @@ ul#requests-extra_browsers {
--color-last-checked: #bbb;
--color-text-footer: #444;
--color-border-watch-table-cell: #eee;
--color-text-watch-tag-list: #e70069;
--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);
--color-background-new-watch-form: rgba(0, 0, 0, 0.05);
--color-background-new-watch-input: var(--color-white);
--color-text-new-watch-input: var(--color-text);
@@ -327,7 +327,7 @@ html[data-darkmode="true"] {
--color-background-input: var(--color-grey-350);
--color-text-input-description: var(--color-grey-600);
--color-text-input-placeholder: var(--color-grey-600);
--color-text-watch-tag-list: #fa3e92;
--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);
--color-background-code: var(--color-grey-200);
--color-background-tab: rgba(0, 0, 0, 0.2);
--color-background-tab-hover: rgba(0, 0, 0, 0.5);
@@ -532,8 +532,11 @@ code {
margin: 0 3px 0 5px; }
.watch-tag-list {
color: var(--color-text-watch-tag-list);
white-space: nowrap; }
color: var(--color-white);
white-space: nowrap;
background: var(--color-text-watch-tag-list);
border-radius: 5px;
padding: 2px 5px; }
.box {
max-width: 80%;
@@ -1038,17 +1041,18 @@ body.full-width .edit-form {
color: var(--color-text-input-description); }
.edit-form .pure-form-message-inline code {
font-size: .875em; }
.edit-form .text-filtering {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem; }
.edit-form .text-filtering h3 {
margin-top: 0; }
.edit-form .text-filtering fieldset:last-of-type {
.border-fieldset {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem; }
.border-fieldset h3 {
margin-top: 0; }
.border-fieldset fieldset:last-of-type {
padding-bottom: 0; }
.border-fieldset fieldset:last-of-type .pure-control-group {
padding-bottom: 0; }
.edit-form .text-filtering fieldset:last-of-type .pure-control-group {
padding-bottom: 0; }
ul {
padding-left: 1em;

View File

@@ -1,4 +1,4 @@
from distutils.util import strtobool
from changedetectionio.strtobool import strtobool
from flask import (
flash
@@ -872,3 +872,16 @@ class ChangeDetectionStore:
self.__data["watching"][awatch]['include_filters'][num] = 'xpath1:' + selector
if selector.startswith('xpath:'):
self.__data["watching"][awatch]['include_filters'][num] = selector.replace('xpath:', 'xpath1:', 1)
# Use more obvious default time setting
def update_15(self):
for uuid in self.__data["watching"]:
if self.__data["watching"][uuid]['time_between_check'] == self.__data['settings']['requests']['time_between_check']:
# What the old logic was, which was pretty confusing
self.__data["watching"][uuid]['time_between_check_use_default'] = True
elif all(value is None or value == 0 for value in self.__data["watching"][uuid]['time_between_check'].values()):
self.__data["watching"][uuid]['time_between_check_use_default'] = True
else:
# Something custom here
self.__data["watching"][uuid]['time_between_check_use_default'] = False

View File

@@ -0,0 +1,23 @@
# Because strtobool was removed in python 3.12 distutils
_MAP = {
'y': True,
'yes': True,
't': True,
'true': True,
'on': True,
'1': True,
'n': False,
'no': False,
'f': False,
'false': False,
'off': False,
'0': False
}
def strtobool(value):
try:
return _MAP[str(value).lower()]
except KeyError:
raise ValueError('"{}" is not a valid bool value'.format(value))

View File

@@ -0,0 +1,6 @@
# Important notes about templates
Template names should always end in ".html", ".htm", ".xml", ".xhtml", ".svg", even the `import`'ed templates.
Jinja2's `def select_jinja_autoescape(self, filename: str) -> bool:` will check the filename extension and enable autoescaping

View File

@@ -1,5 +1,5 @@
{% from '_helpers.jinja' import render_field %}
{% from '_helpers.html' import render_field %}
{% macro render_common_settings_form(form, emailprefix, settings_application) %}
<div class="pure-control-group">

View File

@@ -1,5 +1,5 @@
{% extends 'base.html' %}
{% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
{% block content %}
<script>
const screenshot_url="{{url_for('static_content', group='screenshot', filename=uuid)}}";

View File

@@ -1,13 +1,14 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %}
{% from '_common_fields.jinja' import render_common_settings_form %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
{% from '_common_fields.html' import render_common_settings_form %}
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
<script>
const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');
const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
const browser_steps_fetch_screenshot_image_url="{{url_for('browser_steps.browser_steps_fetch_screenshot_image', uuid=uuid)}}";
<!-- Should be _external so that firefox and others load it more reliably -->
const browser_steps_fetch_screenshot_image_url="{{url_for('browser_steps.browser_steps_fetch_screenshot_image', uuid=uuid, _external=True)}}";
const browser_steps_last_error_step={{ watch.browser_steps_last_error_step|tojson }};
const browser_steps_start_url="{{url_for('browser_steps.browsersteps_start_session', uuid=uuid)}}";
const browser_steps_sync_url="{{url_for('browser_steps.browsersteps_ui_update', uuid=uuid)}}";
@@ -31,6 +32,7 @@
<script src="{{url_for('static_content', group='js', filename='browser-steps.js')}}" defer></script>
{% endif %}
{% set has_tag_filters_extra="WARNING: Watch has tag/groups set with special filters\n" if has_special_tag_options else '' %}
<script src="{{url_for('static_content', group='js', filename='recheck-proxy.js')}}" defer></script>
<div class="edit-form monospaced-textarea">
@@ -85,15 +87,9 @@
{{ render_field(form.tags) }}
<span class="pure-form-message-inline">Organisational tag/group name used in the main listing page</span>
</div>
<div class="pure-control-group">
<div class="pure-control-group time-between-check border-fieldset">
{{ render_field(form.time_between_check, class="time-check-widget") }}
{% if has_empty_checktime %}
<span class="pure-form-message-inline">Currently using the <a
href="{{ url_for('settings_page', uuid=uuid) }}">default global settings</a>, change to another value if you want to be specific.</span>
{% else %}
<span class="pure-form-message-inline">Set to blank to use the <a
href="{{ url_for('settings_page', uuid=uuid) }}">default global settings</a>.</span>
{% endif %}
{{ render_checkbox_field(form.time_between_check_use_default, class="use-default-timecheck") }}
</div>
<div class="pure-control-group">
{{ render_checkbox_field(form.extract_title_as_title) }}
@@ -280,7 +276,7 @@ User-Agent: wonderbra 1.0") }}
<div class="pure-control-group">
{% set field = render_field(form.include_filters,
rows=5,
placeholder="#example
placeholder=has_tag_filters_extra+"#example
xpath://body/div/span[contains(@class, 'example-class')]",
class="m-d")
%}
@@ -316,7 +312,7 @@ xpath://body/div/span[contains(@class, 'example-class')]",
</span>
</div>
<fieldset class="pure-control-group">
{{ render_field(form.subtractive_selectors, rows=5, placeholder="header
{{ render_field(form.subtractive_selectors, rows=5, placeholder=has_tag_filters_extra+"header
footer
nav
.stockticker") }}
@@ -328,7 +324,7 @@ nav
</ul>
</span>
</fieldset>
<div class="text-filtering">
<div class="text-filtering border-fieldset">
<fieldset class="pure-group" id="text-filtering-type-options">
<h3>Text filtering</h3>
Limit trigger/ignore/block/extract to;<br>
@@ -485,13 +481,17 @@ Unavailable") }}
<td>{{ "{:,}".format(watch.history|length) }}</td>
</tr>
<tr>
<td>Last fetch time</td>
<td>Last fetch duration</td>
<td>{{ watch.fetch_time }}s</td>
</tr>
<tr>
<td>Notification alert count</td>
<td>{{ watch.notification_alert_count }}</td>
</tr>
<tr>
<td>Server type reply</td>
<td>{{ watch.get('remote_server_reply') }}</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.jinja' import render_field %}
{% from '_helpers.html' import render_field %}
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
<div class="edit-form monospaced-textarea">

View File

@@ -1,10 +1,10 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.jinja' import render_field, render_checkbox_field, render_button %}
{% from '_common_fields.jinja' import render_common_settings_form %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
{% from '_common_fields.html' import render_common_settings_form %}
<script>
const notification_base_url="{{url_for('ajax_callback_send_notification_test', watch_uuid=uuid)}}";
const notification_base_url="{{url_for('ajax_callback_send_notification_test', mode="global-settings")}}";
{% if emailprefix %}
const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}');
{% endif %}
@@ -31,7 +31,7 @@
<fieldset>
<div class="pure-control-group">
{{ render_field(form.requests.form.time_between_check, class="time-check-widget") }}
<span class="pure-form-message-inline">Default time for all watches, when the watch does not have a specific time setting.</span>
<span class="pure-form-message-inline">Default recheck time for all watches, current system minimum is <i>{{min_system_recheck_seconds}}</i> seconds (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Misc-system-settings#enviroment-variables">more info</a>).</span>
</div>
<div class="pure-control-group">
{{ render_field(form.requests.form.jitter_seconds, class="jitter_seconds") }}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block content %}
{% from '_helpers.jinja' import render_simple_field, render_field, render_nolabel_field, sort_by_title %}
{% from '_helpers.html' import render_simple_field, render_field, render_nolabel_field, sort_by_title %}
<script src="{{url_for('static_content', group='js', filename='jquery-3.6.0.min.js')}}"></script>
<script src="{{url_for('static_content', group='js', filename='watch-overview.js')}}" defer></script>
@@ -169,7 +169,7 @@
<td>
<a {% if watch.uuid in queued_uuids %}disabled="true"{% endif %} href="{{ url_for('form_watch_checknow', uuid=watch.uuid, tag=request.args.get('tag')) }}"
class="recheck pure-button pure-button-primary">{% if watch.uuid in queued_uuids %}Queued{% else %}Recheck{% endif %}</a>
<a href="{{ url_for('edit_page', uuid=watch.uuid)}}" class="pure-button pure-button-primary">Edit</a>
<a href="{{ url_for('edit_page', uuid=watch.uuid)}}#general" class="pure-button pure-button-primary">Edit</a>
{% if watch.history_n >= 2 %}
{% if is_unviewed %}

View File

@@ -1,5 +1,5 @@
#!/usr/bin/python3
import os.path
import time
from flask import url_for
from .util import live_server_setup, wait_for_all_checks
@@ -107,7 +107,6 @@ def test_check_add_line_contains_trigger(client, live_server):
#live_server_setup(live_server)
# Give the endpoint time to spin up
time.sleep(1)
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://') + "?xxx={{ watch_url }}"
res = client.post(
@@ -166,6 +165,7 @@ def test_check_add_line_contains_trigger(client, live_server):
# Takes a moment for apprise to fire
time.sleep(3)
assert os.path.isfile("test-datastore/notification.txt"), "Notification fired because I can see the output file"
with open("test-datastore/notification.txt", 'r') as f:
response= f.read()
assert '-Oh yes please-' in response

View File

@@ -100,6 +100,12 @@ def test_setup_group_tag(client, live_server):
assert b'Should be only this' in res.data
assert b'And never this' not in res.data
res = client.get(
url_for("edit_page", uuid="first"),
follow_redirects=True
)
# 2307 the UI notice should appear in the placeholder
assert b'WARNING: Watch has tag/groups set with special filters' in res.data
# RSS Group tag filter
# An extra one that should be excluded

View File

@@ -2,15 +2,15 @@
import time
from flask import url_for
from .util import live_server_setup
from .util import live_server_setup, wait_for_all_checks
def test_setup(client, live_server):
live_server_setup(live_server)
# If there was only a change in the whitespacing, then we shouldnt have a change detected
def test_jinja2_in_url_query(client, live_server):
live_server_setup(live_server)
# Give the endpoint time to spin up
time.sleep(1)
#live_server_setup(live_server)
# Add our URL to the import page
test_url = url_for('test_return_query', _external=True)
@@ -24,10 +24,35 @@ def test_jinja2_in_url_query(client, live_server):
follow_redirects=True
)
assert b"Watch added" in res.data
time.sleep(3)
wait_for_all_checks(client)
# It should report nothing found (no new 'unviewed' class)
res = client.get(
url_for("preview_page", uuid="first"),
follow_redirects=True
)
assert b'date=2' in res.data
# https://techtonics.medium.com/secure-templating-with-jinja2-understanding-ssti-and-jinja2-sandbox-environment-b956edd60456
def test_jinja2_security_url_query(client, live_server):
#live_server_setup(live_server)
# Add our URL to the import page
test_url = url_for('test_return_query', _external=True)
# because url_for() will URL-encode the var, but we dont here
full_url = "{}?{}".format(test_url,
"date={{ ''.__class__.__mro__[1].__subclasses__()}}", )
res = client.post(
url_for("form_quick_watch_add"),
data={"url": full_url, "tags": "test"},
follow_redirects=True
)
assert b"Watch added" in res.data
wait_for_all_checks(client)
# It should report nothing found (no new 'unviewed' class)
res = client.get(url_for("index"))
assert b'is invalid and cannot be used' in res.data
# Some of the spewed output from the subclasses
assert b'dict_values' not in res.data

View File

@@ -2,9 +2,11 @@ from flask import url_for
from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks
import time
def test_setup(client, live_server):
live_server_setup(live_server)
def test_bad_access(client, live_server):
live_server_setup(live_server)
#live_server_setup(live_server)
res = client.post(
url_for("import_page"),
data={"urls": 'https://localhost'},
@@ -63,4 +65,25 @@ def test_bad_access(client, live_server):
wait_for_all_checks(client)
res = client.get(url_for("index"))
assert b'file:// type access is denied for security reasons.' in res.data
assert b'file:// type access is denied for security reasons.' in res.data
def test_xss(client, live_server):
#live_server_setup(live_server)
from changedetectionio.notification import (
default_notification_format
)
# the template helpers were named .jinja which meant they were not having jinja2 autoescape enabled.
res = client.post(
url_for("settings_page"),
data={"application-notification_urls": '"><img src=x onerror=alert(document.domain)>',
"application-notification_title": '"><img src=x onerror=alert(document.domain)>',
"application-notification_body": '"><img src=x onerror=alert(document.domain)>',
"application-notification_format": default_notification_format,
"requests-time_between_check-minutes": 180,
'application-fetch_backend': "html_requests"},
follow_redirects=True
)
assert b"<img src=x onerror=alert(" not in res.data
assert b"&lt;img" in res.data

View File

@@ -54,102 +54,3 @@ def test_check_watch_field_storage(client, live_server):
assert b"woohoo" in res.data
assert b"curl: foo" in res.data
# Re https://github.com/dgtlmoon/changedetection.io/issues/110
def test_check_recheck_global_setting(client, live_server):
res = client.post(
url_for("settings_page"),
data={
"requests-time_between_check-minutes": 1566,
'application-fetch_backend': "html_requests"
},
follow_redirects=True
)
assert b"Settings updated." in res.data
# Now add a record
test_url = "http://somerandomsitewewatch.com"
res = client.post(
url_for("import_page"),
data={"urls": test_url},
follow_redirects=True
)
assert b"1 Imported" in res.data
# Now visit the edit page, it should have the default minutes
res = client.get(
url_for("edit_page", uuid="first"),
follow_redirects=True
)
# Should show the default minutes
assert b"change to another value if you want to be specific" in res.data
assert b"1566" in res.data
res = client.post(
url_for("settings_page"),
data={
"requests-time_between_check-minutes": 222,
'application-fetch_backend': "html_requests"
},
follow_redirects=True
)
assert b"Settings updated." in res.data
res = client.get(
url_for("edit_page", uuid="first"),
follow_redirects=True
)
# Should show the default minutes
assert b"change to another value if you want to be specific" in res.data
assert b"222" in res.data
# Now change it specifically, it should show the new minutes
res = client.post(
url_for("edit_page", uuid="first"),
data={"url": test_url,
"time_between_check-minutes": 55,
'fetch_backend': "html_requests"
},
follow_redirects=True
)
res = client.get(
url_for("edit_page", uuid="first"),
follow_redirects=True
)
assert b"55" in res.data
# Now submit an empty field, it should give back the default global minutes
res = client.post(
url_for("settings_page"),
data={
"requests-time_between_check-minutes": 666,
"application-fetch_backend": "html_requests"
},
follow_redirects=True
)
assert b"Settings updated." in res.data
res = client.post(
url_for("edit_page", uuid="first"),
data={"url": test_url,
"time_between_check-minutes": "",
'fetch_backend': "html_requests"
},
follow_redirects=True
)
assert b"Updated watch." in res.data
res = client.get(
url_for("edit_page", uuid="first"),
follow_redirects=True
)
assert b"666" in res.data

View File

@@ -1,4 +1,4 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import time
from flask import url_for
@@ -255,6 +255,69 @@ def test_xpath23_prefix_validation(client, live_server):
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
assert b'Deleted' in res.data
def test_xpath1_lxml(client, live_server):
#live_server_setup(live_server)
d = '''<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:taxo="http://purl.org/rss/1.0/modules/taxonomy/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
<channel>
<title>rpilocator.com</title>
<link>https://rpilocator.com</link>
<description>Find Raspberry Pi Computers in Stock</description>
<lastBuildDate>Thu, 19 May 2022 23:27:30 GMT</lastBuildDate>
<image>
<url>https://rpilocator.com/favicon.png</url>
<title>rpilocator.com</title>
<link>https://rpilocator.com/</link>
<width>32</width>
<height>32</height>
</image>
<item>
<title>Stock Alert (UK): RPi CM4</title>
<foo>something else unrelated</foo>
</item>
<item>
<title>Stock Alert (UK): Big monitorěěěě</title>
<foo>something else unrelated</foo>
</item>
</channel>
</rss>'''.encode('utf-8')
with open("test-datastore/endpoint-content.txt", "wb") as f:
f.write(d)
test_url = url_for('test_endpoint', _external=True)
res = client.post(
url_for("import_page"),
data={"urls": test_url},
follow_redirects=True
)
assert b"1 Imported" in res.data
wait_for_all_checks(client)
res = client.post(
url_for("edit_page", uuid="first"),
data={"include_filters": "xpath1://title/text()", "url": test_url, "tags": "", "headers": "",
'fetch_backend': "html_requests"},
follow_redirects=True
)
##### #2312
wait_for_all_checks(client)
res = client.get(url_for("index"))
assert b'_ElementStringResult' not in res.data # tested with 5.1.1 when it was removed and 5.1.0
assert b'Exception' not in res.data
res = client.get(
url_for("preview_page", uuid="first"),
follow_redirects=True
)
assert b"rpilocator.com" in res.data # in selector
assert "Stock Alert (UK): Big monitorěěěě".encode('utf-8') in res.data # not in selector
#####
def test_xpath1_validation(client, live_server):
# Add our URL to the import page

View File

@@ -0,0 +1,57 @@
#!/usr/bin/python3
# run from dir above changedetectionio/ dir
# python3 -m unittest changedetectionio.tests.unit.test_jinja2_security
import unittest
from changedetectionio import safe_jinja
# mostly
class TestJinja2SSTI(unittest.TestCase):
def test_exception(self):
import jinja2
# Where sandbox should kick in
attempt_list = [
"My name is {{ self.__init__.__globals__.__builtins__.__import__('os').system('id') }}",
"{{ self._TemplateReference__context.cycler.__init__.__globals__.os }}",
"{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}",
"{{cycler.__init__.__globals__.os.popen('id').read()}}",
"{{joiner.__init__.__globals__.os.popen('id').read()}}",
"{{namespace.__init__.__globals__.os.popen('id').read()}}",
"{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/hello.txt', 'w').write('Hello here !') }}",
"My name is {{ self.__init__.__globals__ }}",
"{{ dict.__base__.__subclasses__() }}"
]
for attempt in attempt_list:
with self.assertRaises(jinja2.exceptions.SecurityError):
safe_jinja.render(attempt)
def test_exception_debug_calls(self):
import jinja2
# Where sandbox should kick in - configs and debug calls
attempt_list = [
"{% debug %}",
]
for attempt in attempt_list:
# Usually should be something like 'Encountered unknown tag 'debug'.'
with self.assertRaises(jinja2.exceptions.TemplateSyntaxError):
safe_jinja.render(attempt)
# https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection/jinja2-ssti#accessing-global-objects
def test_exception_empty_calls(self):
import jinja2
attempt_list = [
"{{config}}",
"{{ debug }}"
"{{[].__class__}}",
]
for attempt in attempt_list:
self.assertEqual(len(safe_jinja.render(attempt)), 0, f"string test '{attempt}' is correctly empty")
if __name__ == '__main__':
unittest.main()

View File

@@ -116,7 +116,7 @@ def extract_UUID_from_client(client):
)
# <span id="api-key">{{api_key}}</span>
m = re.search('edit/(.+?)"', str(res.data))
m = re.search('edit/(.+?)[#"]', str(res.data))
uuid = m.group(1)
return uuid.strip()

View File

@@ -462,7 +462,7 @@ class update_worker(threading.Thread):
except Exception as e:
logger.error(f"Exception reached processing watch UUID: {uuid}")
logger.error(str(e))
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': str(e)})
self.datastore.update_watch(uuid=uuid, update_obj={'last_error': "Exception: " + str(e)})
# Other serious error
process_changedetection_results = False
# import traceback

View File

@@ -54,7 +54,9 @@ services:
#
# Default number of parallel/concurrent fetchers
# - FETCH_WORKERS=10
#
# Absolute minimum seconds to recheck, overrides any watch minimum, change to 0 to disable
# - MINIMUM_SECONDS_RECHECK_TIME=3
# Comment out ports: when using behind a reverse proxy , enable networks: etc.
ports:
- 5000:5000

View File

@@ -41,7 +41,7 @@ apprise~=1.7.4
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
# and 2.0.0 https://github.com/dgtlmoon/changedetection.io/issues/2241 not yet compatible
# use v1.x due to https://github.com/eclipse/paho.mqtt.python/issues/814
paho-mqtt < 2.0.0
paho-mqtt>=1.6.1,<2.0.0
# This mainly affects some ARM builds, which unlike the other builds ignores "ARG CRYPTOGRAPHY_DONT_BUILD_RUST=1"
# so without this pinning, the newer versions on ARM will forcefully try to build rust, which results in "rust compiler not found"
@@ -52,7 +52,7 @@ cryptography~=3.4
beautifulsoup4
# XPath filtering, lxml is required by bs4 anyway, but put it here to be safe.
lxml
lxml >=4.8.0,<6
# XPath 2.0-3.1 support - 4.2.0 broke something?
elementpath==4.1.5