This is an automated email from the ASF dual-hosted git repository.
sciabarracom pushed a commit to branch 0.9.0
in repository https://gitbox.apache.org/repos/asf/openserverless-operator.git
The following commit(s) were added to refs/heads/0.9.0 by this push:
new 14e7a30 fix: isolate malformed cron annotations between tenants (#144)
14e7a30 is described below
commit 14e7a309c770b1f6e3bc79b60d2fdd65e711faf2
Author: miki3421 <[email protected]>
AuthorDate: Thu Oct 22 10:42:06 2026 +0200
fix: isolate malformed cron annotations between tenants (#144)
---
openserverless/actionexecutor.py | 17 +++--
tests/test_actionexecutor_cron_security.py | 112 +++++++++++++++++++++++++++++
2 files changed, 125 insertions(+), 4 deletions(-)
diff --git a/openserverless/actionexecutor.py b/openserverless/actionexecutor.py
index 1a69a94..e4fdcca 100644
--- a/openserverless/actionexecutor.py
+++ b/openserverless/actionexecutor.py
@@ -22,6 +22,9 @@ import croniter as cn
import requests as req
from datetime import datetime
+# Bound tenant-controlled input before passing it to the cron parser.
+MAX_CRON_EXPRESSION_LENGTH = 4096
+
def check(f, what, res):
if f:
logging.info(f"OK: {what}")
@@ -244,8 +247,11 @@ def get_autoexec(actionAnnotations):
# no_execution if the action should not be triggered or the cron expression is
not a valid one
#
def should_trigger(actionNamespace, actionName, actionCronExpression,
currentDate, executionInterval):
- if not cn.croniter.is_valid(actionCronExpression):
- logging.warn(f"action {actionNamespace}/{actionName} cron expression
{actionCronExpression} is not valid. Skipping execution")
+ if (not isinstance(actionCronExpression, str)
+ or not 0 < len(actionCronExpression) <= MAX_CRON_EXPRESSION_LENGTH
+ or not cn.croniter.is_valid(actionCronExpression)):
+ logging.warning("action %s/%s has an invalid cron expression. Skipping
execution",
+ actionNamespace, actionName)
return "no_execution"
if not action_should_trigger(currentDate, executionInterval,
actionCronExpression):
@@ -389,8 +395,11 @@ def start():
if(len(actions) > 0):
subjects = get_subjects(db,
config['couchdb.controller.user'],config['couchdb.controller.password'])
for action in actions:
- handle_action(baseurl, currentDate, interval, action, subjects)
+ try:
+ handle_action(baseurl, currentDate, interval, action,
subjects)
+ except Exception:
+ logging.exception("Failed to process scheduled action;
continuing with remaining actions")
else:
logging.info('No cron aware action extracted. Exiting....')
else:
- logging.warn("CouchDB it is not available. Exiting....")
\ No newline at end of file
+ logging.warn("CouchDB it is not available. Exiting....")
diff --git a/tests/test_actionexecutor_cron_security.py
b/tests/test_actionexecutor_cron_security.py
new file mode 100644
index 0000000..94721a1
--- /dev/null
+++ b/tests/test_actionexecutor_cron_security.py
@@ -0,0 +1,112 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+import unittest
+from unittest.mock import Mock, patch
+
+import openserverless.actionexecutor as executor
+
+
+def action(namespace, name, annotations):
+ return {
+ "name": name,
+ "entityType": "action",
+ "namespace": namespace,
+ "parameters": [],
+ "annotations": annotations,
+ }
+
+
+class CronTenantIsolationTest(unittest.TestCase):
+ def test_invalid_cron_values_are_skipped_before_parsing(self):
+ for value in (None, False, 42, [], {}, "", "x" * 4097):
+ with self.subTest(value=value):
+ with patch.object(executor.cn.croniter, "is_valid") as
is_valid, self.assertLogs(level="WARNING"):
+ result = executor.should_trigger(
+ "tenant-a", "broken", value, executor.datetime.now(),
60
+ )
+ self.assertEqual(result, "no_execution")
+ is_valid.assert_not_called()
+
+ def test_malformed_actions_do_not_stop_other_tenants(self):
+ actions = [
+ action("tenant-a", "null-cron", [{"key": "cron", "value": None}]),
+ action("tenant-a", "missing-value", [{"key": "cron"}]),
+ action("tenant-b", "healthy", [{"key": "cron", "value": "* * * *
*"}]),
+ action("tenant-b", "one-shot", [{"key": "autoexec", "value":
True}]),
+ ]
+ subjects = [
+ {"name": "tenant-a", "uuid": "a", "key": "a-secret"},
+ {"name": "tenant-b", "uuid": "b", "key": "b-secret"},
+ ]
+ config = {
+ "scheduler.schedule": "* * * * *",
+ "controller.protocol": "http",
+ "controller.host": "controller",
+ "controller.port": "3233",
+ "couchdb.controller.user": "controller",
+ "couchdb.controller.password": "secret",
+ }
+ db = Mock()
+ db.wait_db_ready.return_value = True
+ batches = {
+ "whisks": [
+ {"docs": actions, "bookmark": "next-actions"},
+ {"docs": [], "bookmark": ""},
+ ],
+ "subjects": [
+ {"docs": [{"namespaces": subjects}], "bookmark":
"next-subjects"},
+ {"docs": [], "bookmark": ""},
+ ],
+ }
+
+ def find_doc(database, selector, username, password):
+ self.assertEqual((username, password), ("controller", "secret"))
+ self.assertIn("selector", json.loads(selector))
+ return batches[database].pop(0)
+
+ db.find_doc.side_effect = find_doc
+
+ with (
+ patch.dict(executor.os.environ, {"SCHEDULER_CONFIG":
json.dumps(config)}),
+ patch.object(executor.cu, "CouchDB", return_value=db),
+ patch.object(executor.req, "post",
return_value=Mock(status_code=202, text="")) as invoke,
+ patch.object(executor.req, "put",
return_value=Mock(status_code=200, text="")) as unschedule,
+ self.assertLogs(level="WARNING") as logs,
+ ):
+ executor.start()
+
+ self.assertTrue(any("Failed to process scheduled action" in line for
line in logs.output))
+ self.assertEqual(invoke.call_count, 2)
+ self.assertEqual(
+ [call.args[0] for call in invoke.call_args_list],
+ [
+
"http://controller:3233/api/v1/namespaces/tenant-b/actions/healthy?blocking=false&result=false",
+
"http://controller:3233/api/v1/namespaces/tenant-b/actions/one-shot?blocking=false&result=false",
+ ],
+ )
+ self.assertEqual(
+ [call.kwargs["auth"] for call in invoke.call_args_list],
+ [("b", "b-secret"), ("b", "b-secret")],
+ )
+ unschedule.assert_called_once()
+ self.assertEqual(unschedule.call_args.kwargs["auth"], ("b",
"b-secret"))
+
+
+if __name__ == "__main__":
+ unittest.main()