393 files
This commit is contained in:
@@ -40,6 +40,7 @@ from ..const import (
|
||||
MAX_URL_LENGTH,
|
||||
)
|
||||
from .task_fields import EARLIEST_COMPLETION_RANGE
|
||||
from .url_safety import is_safe_url
|
||||
|
||||
# Per-field cap for task dicts. Values mirror the voluptuous schemas in
|
||||
# websocket/tasks.py so an admin who reaches the same field through the UI
|
||||
@@ -121,6 +122,11 @@ _FRESH_COPY_STRIP_KEYS = (
|
||||
"archived_reason",
|
||||
# #170: a copy is a new task — it gets its own reference number.
|
||||
"ref_no",
|
||||
# Dynamic state like the history (storage._DYNAMIC_TASK_FIELDS, pinned by
|
||||
# tests/test_dry_round4_runtime.py): a copy starts its phase cycle and its
|
||||
# checklist from the beginning (DRY audit 2026-09-26 B).
|
||||
"phase_cursor",
|
||||
"checklist_progress",
|
||||
)
|
||||
|
||||
|
||||
@@ -144,7 +150,7 @@ def strip_task_runtime_state(task: dict[str, Any]) -> dict[str, Any]:
|
||||
return task
|
||||
|
||||
|
||||
def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
|
||||
def cap_task_fields(task_data: dict[str, Any], *, keep_action_owner: bool = False) -> dict[str, Any]:
|
||||
"""Truncate user-controllable strings + numerics on a task dict in-place.
|
||||
|
||||
Returns the same dict for fluent use. Mirrors the WS schema caps:
|
||||
@@ -152,8 +158,19 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
|
||||
- `interval_days` → 1..MAX_INTERVAL_DAYS (negative/zero coerced to 1)
|
||||
- `warning_days` → 0..365
|
||||
- `checklist` → list of strings, each ≤ 500 chars, list ≤ 100 items
|
||||
|
||||
``keep_action_owner``: the completion action's ``configured_by`` (the
|
||||
user it runs as) survives. Only for RE-WRITES of a stored task whose
|
||||
action the caller cannot change (the ``update_task`` service, the
|
||||
options-flow task edit) and for the config flow's websocket step, whose
|
||||
callers are server-side copies (object duplicate / replace) or an import
|
||||
that stamped the owner itself. Stripping it there made an operator's
|
||||
action run with system rights again after any unrelated edit (bug audit
|
||||
2026-09-27, the SEC-2 regression). A client-supplied owner is never
|
||||
trusted: every client write path stamps the connection user instead.
|
||||
"""
|
||||
_cap_strings(task_data, _TASK_STR_LIMITS)
|
||||
_drop_unsafe_url(task_data)
|
||||
|
||||
iv = task_data.get("interval_days")
|
||||
if isinstance(iv, int):
|
||||
@@ -221,11 +238,13 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
|
||||
if task_data.get("assignee_pool") is not None:
|
||||
task_data["assignee_pool"] = sanitize_assignee_pool(task_data["assignee_pool"])
|
||||
|
||||
rs = task_data.get("rotation_strategy")
|
||||
if rs is not None:
|
||||
# Absence means "no rotation": a stored None / "" (the panel dialog sends
|
||||
# null for "none") is dropped too — as a select default it made the
|
||||
# options-flow task form unsaveable (bug audit 2026-09-26).
|
||||
if "rotation_strategy" in task_data:
|
||||
from ..const import ROTATION_STRATEGIES
|
||||
|
||||
if rs not in ROTATION_STRATEGIES:
|
||||
if task_data["rotation_strategy"] not in ROTATION_STRATEGIES:
|
||||
task_data.pop("rotation_strategy", None)
|
||||
|
||||
if task_data.get("required_completion_fields") is not None:
|
||||
@@ -241,7 +260,7 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
|
||||
# Strict shape: {service: "domain.name", target?: dict, data?: dict}.
|
||||
# Drops the field entirely on any structural problem; the action layer
|
||||
# treats absence as "no action configured" (not an error).
|
||||
cap_action_field(task_data)
|
||||
cap_action_field(task_data, keep_owner=keep_action_owner)
|
||||
|
||||
# v1.3.0: per-task quick_complete_defaults — pre-fill values used when
|
||||
# the user scans the "quick complete" QR code. Schema mirrors the
|
||||
@@ -364,11 +383,14 @@ _MAX_TARGET_FIELD_LENGTH = 200
|
||||
_FORBIDDEN_ACTION_DOMAINS = frozenset({"shell_command", "python_script", "hassio", "homeassistant", "recorder", "backup"})
|
||||
|
||||
|
||||
def cap_action_field(task_data: dict[str, Any]) -> None:
|
||||
def cap_action_field(task_data: dict[str, Any], *, keep_owner: bool = False) -> None:
|
||||
"""Validate + truncate task_data['on_complete_action'] in-place.
|
||||
|
||||
Drops the entire field on any structural problem. Passes silently when
|
||||
not present (it's optional).
|
||||
not present (it's optional). ``configured_by`` — the user the action runs
|
||||
as (``action_listener``) — is server-stamped: kept only for the WS update
|
||||
path's stored action (``keep_owner``), dropped from anything else a
|
||||
client or an import could have written.
|
||||
"""
|
||||
import re
|
||||
|
||||
@@ -421,9 +443,45 @@ def cap_action_field(task_data: dict[str, Any]) -> None:
|
||||
if serialised is not None and len(serialised) <= _MAX_ACTION_DATA_BYTES:
|
||||
cleaned["data"] = data
|
||||
|
||||
owner = action.get(ACTION_OWNER_KEY)
|
||||
if keep_owner and isinstance(owner, str) and 0 < len(owner) <= 64:
|
||||
cleaned[ACTION_OWNER_KEY] = owner
|
||||
|
||||
task_data["on_complete_action"] = cleaned
|
||||
|
||||
|
||||
# The user a completion action runs as (see helpers.action_listener).
|
||||
ACTION_OWNER_KEY = "configured_by"
|
||||
|
||||
|
||||
def stamp_action_owner(task_data: dict[str, Any], user_id: str | None) -> None:
|
||||
"""Record who configured the task's completion action — it then runs
|
||||
with that user's rights, so an operator cannot schedule an admin-only
|
||||
service (bug audit 2026-09-26). Call after :func:`cap_action_field`."""
|
||||
action = task_data.get("on_complete_action")
|
||||
if isinstance(action, dict) and user_id:
|
||||
action[ACTION_OWNER_KEY] = user_id
|
||||
|
||||
|
||||
def settle_action_owner(task_data: dict[str, Any], stored_action: Any, user_id: str | None) -> None:
|
||||
"""Owner of an EDITED task's completion action, after :func:`cap_action_field`.
|
||||
|
||||
An unchanged action (the panel dialog sends it back on every save) keeps
|
||||
the owner it was stored with — an operator editing the notes must not
|
||||
re-author an admin's action, nor may an edit drop the owner (the action
|
||||
would then run with system rights). A changed action belongs to
|
||||
``user_id``, the saving user (bug audit 2026-09-26 / 2026-09-27).
|
||||
"""
|
||||
stored_owner = stored_action.get(ACTION_OWNER_KEY) if isinstance(stored_action, dict) else None
|
||||
new_action = task_data.get("on_complete_action")
|
||||
unchanged = isinstance(stored_action, dict) and new_action == {
|
||||
k: v for k, v in stored_action.items() if k != ACTION_OWNER_KEY
|
||||
}
|
||||
if isinstance(new_action, dict):
|
||||
new_action.pop(ACTION_OWNER_KEY, None)
|
||||
stamp_action_owner(task_data, stored_owner if unchanged else user_id)
|
||||
|
||||
|
||||
def cap_quick_complete_defaults_field(task_data: dict[str, Any]) -> None:
|
||||
"""Validate + truncate task_data['quick_complete_defaults'] in-place.
|
||||
|
||||
@@ -467,9 +525,23 @@ def cap_quick_complete_defaults_field(task_data: dict[str, Any]) -> None:
|
||||
def cap_object_fields(obj_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Truncate user-controllable strings on an object dict in-place."""
|
||||
_cap_strings(obj_data, _OBJECT_STR_LIMITS)
|
||||
_drop_unsafe_url(obj_data)
|
||||
return obj_data
|
||||
|
||||
|
||||
def _drop_unsafe_url(data: dict[str, Any]) -> None:
|
||||
"""Drop a ``documentation_url`` that is not http(s)/path-relative —
|
||||
every create and edit path runs through the cap helpers, so this is the
|
||||
one place a ``javascript:`` link from a flow or an import stops (bug
|
||||
audit 2026-09-26; the WS layer refuses it with an error before)."""
|
||||
url = data.get("documentation_url")
|
||||
if isinstance(url, str) and url and not is_safe_url(url):
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning("Dropped an unsafe documentation link (%.40s)", url)
|
||||
data["documentation_url"] = None
|
||||
|
||||
|
||||
def cap_group_fields(group_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Truncate user-controllable strings on a group dict in-place."""
|
||||
_cap_strings(group_data, _GROUP_STR_LIMITS)
|
||||
|
||||
Reference in New Issue
Block a user