Files
LEDMatrix/test/test_path_traversal_guards.py
T
ChuckandClaude Opus 5.5 a8b3e86775 refactor(web): delete dead routes, JS files and duplicate definitions (#609)
* refactor(web): drop validators nothing calls

escape_html, validate_image_url, validate_font_awesome_class,
validate_mime_type, validate_numeric_range, validate_string_length and
sanitize_plugin_config had no callers outside their own tests. Only
validate_file_upload (fonts upload) is imported by the web interface.

dedup_unique_arrays is kept: its one caller in save_plugin_config was
removed by the unrelated sync PR (#330), which looks accidental.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(api): remove the music-auth and of-the-day JSON routes

POST /plugins/authenticate/spotify and /plugins/authenticate/ytm had no
caller but their tests: the music plugin authenticates through its
web_ui_actions (authenticate_spotify.py / authenticate_ytm.py) via
/plugins/action.

POST /plugins/of-the-day/json/upload and /json/delete looked the plugin
up by the id ledmatrix-of-the-day (its manifest id is of-the-day), were
reachable only from a file_type "json" upload field that no schema
declares, and put the plugin directory on sys.path per request to
import scripts.update_config. of-the-day manages its files through
plugin-file-manager and its own web_ui_actions.

The of-the-day branch of GET /plugins/config stays: it matches the real
manifest id and still merges the on-disk category files into the form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(api): read managers only from the blueprints

api_v3/__init__.py and pages_v3.py declared module globals
(plugin_store_manager, saved_repositories_manager, schema_manager,
operation_queue, plugin_state_manager, operation_history, sync_manager,
config_manager, plugin_manager) that nothing assigns: app.py sets the
managers as attributes on the Blueprint objects, and every route reads
them there. The one reader, backup restore's fallback to the module
plugin_store_manager, could only ever fall back to None.

_ensure_cache_manager() built a second CacheManager in the web process
instead of using the one app.py puts on api_v3. The display routes now
read api_v3.cache_manager, creating it on the blueprint only when
nothing set it (the same None handling as the /cache routes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(web): drop run.sh and the unused log_config_change

web_interface/run.sh was referenced only by web_interface/README.md;
the service starts the UI through scripts/utils/start_web_conditionally.py
and the README already documents `python3 web_interface/start.py`.
log_config_change() in web_interface/logging_config.py was never called.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): delete unreferenced store_manager.js, diff_viewer.js, htmx-sse.js

- js/plugins/store_manager.js (window.PluginStoreManager) and
  js/config/diff_viewer.js (window.ConfigDiffViewer) were loaded on every
  page but nothing reads either global.
- htmx-sse.js (plus its CDN fallback) was loaded after HTMX, but no
  template or plugin page uses sse-connect / hx-ext="sse": the live
  streams run through LEDStreams in app-shell.js.

js/plugins/state_manager.js stays: install_manager.js's updateAll()
reads and refreshes window.PluginStateManager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): remove app.js helpers nothing calls

- hexToRgb, rgbToHex, validateForm, uploadFont and switchTab (whose
  'switch-tab' event had no listener) have no caller in the templates,
  static JS or the plugin monorepo.
- installPlugin: plugins_manager.js (loaded last) assigns
  window.installPlugin, and its own store cards are the only callers.
- The showNotification fallback could never install: app-shell.js is
  deferred ahead of app.js and defines the same fallback at top level.
- performanceMonitor only logged with ?debug=perf and read an unset
  this.measures; the marks it took on every load had no reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): drop app-shell.js refreshPlugin

A top-level function in app-shell.js, so a window global, but nothing
calls it (no inline handler, no window lookup, no string-built name).

The other plugin actions in that block stay. updatePlugin is the live
window.updatePlugin: plugins_manager.js only installs its own copy when
none exists. uninstallPlugin/pollUninstallOperation, updateAllPlugins,
executePluginAction and toggleNestedSection are replaced by later
deferred scripts, but a click that lands while those scripts are still
downloading reaches the app-shell copies, so removing them is not a
pure no-op.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): remove definitions plugins_manager.js always overrides

All of these are replaced before anything can call them, checked
against the load order in base.html and the live window.* values:

- openOnDemandModal/requestOnDemandStop stubs: the IIFE later in the
  same script assigns the real functions synchronously.
- updatePlugin and uninstallPlugin stubs (`window.X || stub`): app-shell.js
  already defined both, so the fallback never installed. Same for the
  later updatePlugin override, gated on the live function containing
  '[UPDATE]', which app-shell.js's never does.
- The first addArrayObjectItem/removeArrayObjectItem: reassigned by the
  top-level copies after the IIFE.
- The first `function formatDate` in the IIFE: a later declaration of
  the same name in the same scope wins.
- deleteUploadedImage, getCurrentImages, showUploadProgress,
  formatFileSize and getScheduleSummary: character-for-character
  copies of js/widgets/file-upload.js, which stays the owner.
- `typeof X === 'undefined'` fallbacks and `typeof X !== 'undefined'`
  re-exports after the IIFE: always false, or a self-assignment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): render the shell directly and delete index.html

index.html extended base.html with {% block content %}, but base.html
defines no blocks, so none of index.html ever rendered: rendering both
with jinja2 gives byte-identical output. index() still loaded the config,
read config.json and config_secrets.json raw and json.dumps'd them on
every page load for variables base.html never reads, and flashed errors
that base.html never shows. It now renders base.html with no context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): stop htmx-config.js replacing console.error and console.warn

It swapped both globals for filters that dropped any error mentioning
insertBefore / "Cannot read properties of null" when "htmx" appeared in
the message or stack, and a list of Permissions-Policy warnings. That
hid real errors from every script on the page, and made every logged
error and warning report htmx-config.js as its source. The beforeSwap
target validation above it, which prevents the insertBefore errors in
the first place, stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(web): quiet the widget load announcements and debug logs

About 30 lines hit the console on every page load: one "... widget
registered" per widget file, one "[WidgetRegistry] Registered widget: X"
per registration, plus the registry, base widget and plugin loader
announcing themselves. The load-time announcements are removed; the
per-call ones (registry register, plugin widget loads, "Render called")
now go through the page's debugLog switch (localStorage.pluginDebug),
guarded because the widgets also load in node tests without it.
fonts.html and wifi.html debug logging goes through debugLog as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(api): drop the removed music-auth and of-the-day JSON routes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 12:36:53 -04:00

466 lines
19 KiB
Python

"""Request-supplied names must not be able to name a file outside their base.
Three of these were live. Each test that stands in for one says so, and
asserts on the *filesystem* -- that the file outside the base is still there,
or was never read -- rather than only on the status code, because a handler
that returns 403 and deletes the file anyway would pass the weaker check.
The rest pin the shared helper in src/common/path_safety.py that the handlers
now go through, so a future handler gets the same behaviour by using it.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.common.path_safety import ( # noqa: E402
resolve_under,
safe_path_component,
safe_relative_parts,
)
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
BACKSLASH = chr(92)
class TestSafePathComponent:
"""One segment, or nothing."""
@pytest.mark.parametrize("value", [
"plugin", "ledmatrix-of-the-day", "weather_data", "manifest.json",
"a.b.c", "UPPER", "with-dash", "with_underscore", "9lives",
])
def test_ordinary_names_pass_through_unchanged(self, value):
assert safe_path_component(value) == value
@pytest.mark.parametrize("value", [
"..", ".", "", "../etc", "a/b", "/abs", "etc/passwd",
"a" + BACKSLASH + "b", "C:" + BACKSLASH + "x", "C:",
"a\x00b", None, 123, b"bytes", ["list"],
])
def test_anything_that_could_name_another_directory_is_refused(self, value):
assert safe_path_component(value) is None
def test_it_returns_the_value_rather_than_a_verdict(self):
# A boolean lets a caller validate one string and then open a
# different one. Returning the checked value is what stops that.
assert safe_path_component("ok") == "ok"
assert safe_path_component("../ok") is None
class TestSafeRelativeParts:
def test_a_multi_segment_path_splits(self):
assert safe_relative_parts("web_ui/index.html") == ["web_ui", "index.html"]
def test_empty_segments_are_dropped_not_rejected(self):
assert safe_relative_parts("a//b") == ["a", "b"]
assert safe_relative_parts("x/") == ["x"]
@pytest.mark.parametrize("value", [
"../x", "a/../../etc", "/etc/passwd", BACKSLASH + "etc",
"a" + BACKSLASH + ".." + BACKSLASH + "b", "", None,
])
def test_traversal_and_absolute_paths_are_refused(self, value):
assert safe_relative_parts(value) is None
class TestResolveUnder:
def test_a_path_inside_the_base_resolves(self, tmp_path):
(tmp_path / "sub").mkdir()
(tmp_path / "sub" / "f.txt").write_text("x", encoding="utf-8")
resolved = resolve_under(tmp_path, "sub", "f.txt")
assert resolved == (tmp_path / "sub" / "f.txt").resolve()
def test_a_path_that_would_escape_is_refused(self, tmp_path):
assert resolve_under(tmp_path, "..") is None
assert resolve_under(tmp_path, "..", "etc") is None
assert resolve_under(tmp_path, "/etc/passwd") is None
def test_a_sibling_sharing_a_prefix_is_refused(self, tmp_path):
# The bug the old `str(x).startswith(str(base))` checks had:
# "<root>/foo-evil" starts with "<root>/foo" as a string, but is not
# inside it as a directory.
(tmp_path / "foo").mkdir()
(tmp_path / "foo-evil").mkdir()
(tmp_path / "foo-evil" / "secret").write_text("s", encoding="utf-8")
assert resolve_under(tmp_path / "foo", "..", "foo-evil", "secret") is None
# And the string check it replaces would have said yes:
naive = (tmp_path / "foo" / ".." / "foo-evil" / "secret").resolve()
assert str(naive).startswith(str((tmp_path / "foo").resolve()))
def test_it_returns_none_rather_than_raising_on_junk(self, tmp_path):
assert resolve_under(tmp_path, None) is None
assert resolve_under(tmp_path, 42) is None
@pytest.fixture
def plugins_tree(tmp_path):
"""A plugins directory with one plugin, plus a secret outside it."""
plugins = tmp_path / "plugin-repos"
(plugins / "demo" / "web_ui").mkdir(parents=True)
(plugins / "demo" / "web_ui" / "index.html").write_text("<p>hi</p>", encoding="utf-8")
(plugins / "demo-evil").mkdir()
(plugins / "demo-evil" / "stolen.json").write_text('{"a":1}', encoding="utf-8")
secrets = tmp_path / "config"
secrets.mkdir()
(secrets / "config_secrets.json").write_text('{"api_key":"hunter2"}', encoding="utf-8")
return tmp_path
class TestServePluginStatic:
"""GET /api/v3/plugins/<plugin_id>/static/<path:file_path>
This handler read any file whose resolved path *string-prefixed* the
plugin directory. Two ways through:
* plugin_id of "..", because Flask's default converter forbids a slash
but not dots, and get_plugin_directory returned the parent of the
plugins directory since it exists. Every file under the project then
prefixed that directory, config/config_secrets.json included.
* a sibling directory sharing a prefix, per the helper test above.
"""
def _wire(self, api_v3_module, plugins_tree):
plugins = plugins_tree / "plugin-repos"
def get_plugin_directory(plugin_id):
candidate = plugins / plugin_id
return str(candidate) if candidate.exists() else None
api_v3_module.api_v3.plugin_manager = MagicMock()
api_v3_module.api_v3.plugin_manager.get_plugin_directory = get_plugin_directory
return plugins
def test_a_real_plugin_file_is_still_served(
self, api_v3_client, api_v3_module, plugins_tree
):
self._wire(api_v3_module, plugins_tree)
response = api_v3_client.get("/api/v3/plugins/demo/static/web_ui/index.html")
assert response.status_code == 200
assert b"<p>hi</p>" in response.data
def test_a_dotdot_plugin_id_cannot_reach_the_secrets_file(
self, api_v3_client, api_v3_module, plugins_tree
):
self._wire(api_v3_module, plugins_tree)
response = api_v3_client.get(
"/api/v3/plugins/../static/config/config_secrets.json"
)
assert response.status_code in (400, 403, 404)
assert b"hunter2" not in response.data
def test_a_percent_encoded_dotdot_plugin_id_is_refused_too(
self, api_v3_client, api_v3_module, plugins_tree
):
self._wire(api_v3_module, plugins_tree)
response = api_v3_client.get(
"/api/v3/plugins/%2e%2e/static/config/config_secrets.json"
)
assert response.status_code in (400, 403, 404)
assert b"hunter2" not in response.data
def test_a_sibling_directory_sharing_a_prefix_is_refused(
self, api_v3_client, api_v3_module, plugins_tree
):
self._wire(api_v3_module, plugins_tree)
response = api_v3_client.get(
"/api/v3/plugins/demo/static/../demo-evil/stolen.json"
)
assert response.status_code in (400, 403, 404)
assert b'"a"' not in response.data
def test_an_unknown_plugin_is_still_a_404(
self, api_v3_client, api_v3_module, plugins_tree
):
self._wire(api_v3_module, plugins_tree)
response = api_v3_client.get("/api/v3/plugins/nope/static/x.json")
assert response.status_code == 404
class TestDiskCacheKeys:
"""The cache key becomes a filename, and POST /api/v3/cache/delete passes
the request body's key straight through CacheManager.clear_cache to
os.remove. A key of "../../../../etc/whatever" named a file well outside
the cache directory.
"""
@pytest.fixture
def cache(self, tmp_path):
from src.cache.disk_cache import DiskCache
cache_dir = tmp_path / "cache"
cache_dir.mkdir()
return DiskCache(str(cache_dir)), cache_dir
def test_an_ordinary_key_still_round_trips(self, cache):
disk_cache, cache_dir = cache
disk_cache.set("espn_nfl_2024", {"ok": True})
assert (cache_dir / "espn_nfl_2024.json").exists()
assert disk_cache.get("espn_nfl_2024", max_age=None)["ok"] is True
def test_a_traversing_key_has_no_path(self, cache):
disk_cache, _ = cache
assert disk_cache.get_cache_path("../../victim") is None
assert disk_cache.get_cache_path("..") is None
assert disk_cache.get_cache_path("a/b") is None
def test_clearing_a_traversing_key_deletes_nothing(self, cache, tmp_path):
disk_cache, _ = cache
victim = tmp_path / "victim.json"
victim.write_text("important", encoding="utf-8")
disk_cache.clear("../victim")
assert victim.exists(), "file outside the cache directory was deleted"
def test_writing_a_traversing_key_creates_nothing(self, cache, tmp_path):
disk_cache, _ = cache
disk_cache.set("../escaped", {"x": 1})
assert not (tmp_path / "escaped.json").exists()
def test_the_delete_endpoint_says_no_rather_than_claiming_success(
self, api_v3_client, api_v3_module
):
api_v3_module.api_v3.cache_manager = MagicMock()
response = api_v3_client.post(
"/api/v3/cache/delete", json={"key": "../../etc/passwd"}
)
assert response.status_code == 400
api_v3_module.api_v3.cache_manager.clear_cache.assert_not_called()
def test_the_delete_endpoint_still_deletes_an_ordinary_key(
self, api_v3_client, api_v3_module
):
api_v3_module.api_v3.cache_manager = MagicMock()
response = api_v3_client.post(
"/api/v3/cache/delete", json={"key": "espn_nfl_2024"}
)
assert response.status_code == 200
api_v3_module.api_v3.cache_manager.clear_cache.assert_called_once_with(
"espn_nfl_2024"
)
class TestPluginVersionLookup:
"""_get_plugin_version joins a request-supplied id onto the plugins
directory and opens manifest.json under it."""
def test_a_real_manifest_is_read(self, tmp_path):
from web_interface.blueprints import api_v3 as module
plugins = tmp_path / "plugin-repos"
(plugins / "demo").mkdir(parents=True)
(plugins / "demo" / "manifest.json").write_text(
json.dumps({"version": "1.2.3"}), encoding="utf-8"
)
original = getattr(module.api_v3, "plugin_store_manager", None)
module.api_v3.plugin_store_manager = MagicMock(plugins_dir=str(plugins))
try:
assert module._get_plugin_version("demo") == "1.2.3"
assert module._get_plugin_version("../demo") == ""
assert module._get_plugin_version("..") == ""
finally:
module.api_v3.plugin_store_manager = original
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 20
class TestPluginAssetRoutes:
"""/api/v3/plugins/assets/upload, /list and /delete
All three joined a request-supplied plugin_id straight onto
assets/plugins, so '../../config' created an uploads directory under
config/, wrote images and .metadata.json there, listed it, and deleted
whatever file a metadata entry's path named. #561 fixed only the route
that serves the uploaded files.
"""
@pytest.fixture
def project(self, tmp_path, api_v3_module, monkeypatch):
import web_interface.blueprints.api_v3.plugins as plugins_module
monkeypatch.setattr(plugins_module, "PROJECT_ROOT", tmp_path)
(tmp_path / "config").mkdir()
secrets = tmp_path / "config" / "config_secrets.json"
secrets.write_text('{"api_key":"hunter2"}', encoding="utf-8")
return tmp_path, secrets
def _upload(self, client, plugin_id):
import io
return client.post(
"/api/v3/plugins/assets/upload",
data={"plugin_id": plugin_id, "files": (io.BytesIO(PNG), "x.png")},
content_type="multipart/form-data",
)
def _tree(self, root):
return sorted(str(p.relative_to(root)) for p in root.rglob("*"))
def test_an_ordinary_upload_list_and_delete_still_work(self, api_v3_client, project):
root, _ = project
response = self._upload(api_v3_client, "static-image")
assert response.status_code == 200, response.get_json()
uploaded = response.get_json()["uploaded_files"][0]
assert uploaded["path"].startswith("assets/plugins/static-image/uploads/")
stored = root / uploaded["path"]
assert stored.exists()
listed = api_v3_client.get(
"/api/v3/plugins/assets/list", query_string={"plugin_id": "static-image"}
)
assert listed.status_code == 200
assert [a["id"] for a in listed.get_json()["data"]["assets"]] == [uploaded["id"]]
deleted = api_v3_client.post(
"/api/v3/plugins/assets/delete",
json={"plugin_id": "static-image", "image_id": uploaded["id"]},
)
assert deleted.status_code == 200
assert not stored.exists()
@pytest.mark.parametrize("plugin_id", [
"../../config", "..", "a/b", "/abs", "x" + BACKSLASH + "y", "C:",
])
def test_a_traversing_upload_writes_nothing(self, api_v3_client, project, plugin_id):
root, _ = project
before = self._tree(root)
response = self._upload(api_v3_client, plugin_id)
assert response.status_code == 400
assert self._tree(root) == before
def test_a_traversing_list_reads_nothing(self, api_v3_client, project):
root, _ = project
outside = root / "config" / "uploads"
outside.mkdir()
(outside / ".metadata.json").write_text(
json.dumps({"i": {"id": "i", "path": "leaked"}}), encoding="utf-8"
)
response = api_v3_client.get(
"/api/v3/plugins/assets/list", query_string={"plugin_id": "../../config"}
)
assert response.status_code == 400
assert b"leaked" not in response.data
def test_a_traversing_delete_deletes_nothing(self, api_v3_client, project):
root, secrets = project
outside = root / "config" / "uploads"
outside.mkdir()
(outside / ".metadata.json").write_text(
json.dumps({"i": {"id": "i", "path": "config/config_secrets.json"}}),
encoding="utf-8",
)
response = api_v3_client.post(
"/api/v3/plugins/assets/delete",
json={"plugin_id": "../../config", "image_id": "i"},
)
assert response.status_code == 400
assert secrets.exists(), "file outside assets/plugins was deleted"
@pytest.mark.parametrize("stored_path", [
"config/config_secrets.json",
"assets/plugins/static-image/uploads/../../../../config/config_secrets.json",
"assets/plugins/other/uploads/x.png",
None,
])
def test_a_metadata_path_outside_the_uploads_is_never_unlinked(
self, api_v3_client, project, stored_path
):
root, secrets = project
uploads = root / "assets" / "plugins" / "static-image" / "uploads"
uploads.mkdir(parents=True)
other = root / "assets" / "plugins" / "other" / "uploads"
other.mkdir(parents=True)
(other / "x.png").write_bytes(PNG)
(uploads / ".metadata.json").write_text(
json.dumps({"i": {"id": "i", "path": stored_path}}), encoding="utf-8"
)
response = api_v3_client.post(
"/api/v3/plugins/assets/delete",
json={"plugin_id": "static-image", "image_id": "i"},
)
assert response.status_code == 200
assert secrets.exists(), "file named by tampered metadata was deleted"
assert (other / "x.png").exists(), "another plugin's upload was deleted"
# The bad entry is still dropped, so the UI can get rid of it.
assert json.loads((uploads / ".metadata.json").read_text(encoding="utf-8")) == {}
class TestPluginActionDirectory:
"""POST /api/v3/plugins/action runs a script named by the manifest in
get_plugin_directory(plugin_id), and plugin_id is the request body's.
get_plugin_directory joined it onto plugins_dir unchecked, so
'../elsewhere' ran a script from any directory holding a manifest.
"""
@pytest.fixture
def tree(self, tmp_path):
import threading
from src.plugin_system.plugin_manager import PluginManager
plugins = tmp_path / "plugin-repos"
(plugins / "demo").mkdir(parents=True)
(plugins / "ledmatrix-legacy").mkdir()
outside = tmp_path / "elsewhere"
outside.mkdir()
(outside / "manifest.json").write_text(json.dumps({
"web_ui_actions": [{"id": "go", "type": "script", "script": "s.py"}],
}), encoding="utf-8")
marker = tmp_path / "PWNED"
(outside / "s.py").write_text(
"open(%r, 'w').write('ran')\n" % str(marker), encoding="utf-8"
)
manager = MagicMock()
manager.plugins_dir = plugins
manager._discovery_lock = threading.Lock()
manager.plugin_directories = {}
manager.get_plugin_directory = (
lambda pid: PluginManager.get_plugin_directory(manager, pid)
)
return manager, plugins, marker
def test_real_plugin_directories_are_still_found(self, tree):
manager, plugins, _ = tree
assert manager.get_plugin_directory("demo") == str(plugins / "demo")
assert manager.get_plugin_directory("legacy") == str(plugins / "ledmatrix-legacy")
assert manager.get_plugin_directory("nope") is None
def test_a_discovered_plugin_is_returned_from_the_registry(self, tree, tmp_path):
manager, _, _ = tree
manager.plugin_directories = {"linked": tmp_path / "somewhere"}
assert manager.get_plugin_directory("linked") == str(tmp_path / "somewhere")
@pytest.mark.parametrize("plugin_id", [
"..", "../elsewhere", "a/b", "/abs", "x" + BACKSLASH + "..", "",
])
def test_get_plugin_directory_refuses_anything_but_a_plain_name(self, tree, plugin_id):
manager, _, _ = tree
assert manager.get_plugin_directory(plugin_id) is None
def test_the_action_endpoint_runs_nothing_outside_the_plugins_dir(
self, api_v3_client, api_v3_module, tree
):
manager, _, marker = tree
api_v3_module.api_v3.plugin_manager = manager
response = api_v3_client.post(
"/api/v3/plugins/action",
json={"plugin_id": "../elsewhere", "action_id": "go", "params": {}},
)
assert response.status_code == 400
assert not marker.exists(), "script outside the plugins directory ran"
def test_the_fallback_without_a_plugin_manager_is_guarded_too(
self, api_v3_client, api_v3_module
):
api_v3_module.api_v3.plugin_manager = None
response = api_v3_client.post(
"/api/v3/plugins/action",
json={"plugin_id": "../elsewhere", "action_id": "go", "params": {}},
)
assert response.status_code == 400