Restrict third-party shell plugin capabilities
Reported-by: Roger Piñol <rogerpicar@gmail.com>
This commit is contained in:
1 parent
4d017913d0
commit
1702cf0bee
25 files changed
+1403
-49
No files matched your search
@@ -0,0 +1,12 @@
|
||||
import QtQuick
|
||||
import "services/AuthServiceStore.js" as AuthServiceStore
|
||||
|
||||
QtObject {
|
||||
function retain(id, service) {
|
||||
AuthServiceStore.put(id, service)
|
||||
}
|
||||
|
||||
function has(id) {
|
||||
return AuthServiceStore.has(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import QtQuick
|
||||
import "services/AuthServiceStore.js" as AuthServiceStore
|
||||
|
||||
QtObject {
|
||||
function has(id) {
|
||||
return AuthServiceStore.has(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "services"
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
property var calls: []
|
||||
property QtObject ownService: QtObject { property string marker: "own" }
|
||||
|
||||
AuthStoreOwner { id: authStoreOwner }
|
||||
AuthStoreReader { id: authStoreReader }
|
||||
|
||||
Component {
|
||||
id: apiComponent
|
||||
PluginShellApi { }
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: resultFile
|
||||
path: Quickshell.env("OMARCHY_QML_TEST_RESULT")
|
||||
atomicWrites: true
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
var caller = "example.safe"
|
||||
authStoreOwner.retain("omarchy.lock", root.ownService)
|
||||
var api = apiComponent.createObject(null, {
|
||||
pluginId: caller,
|
||||
_serviceLookup: function(requestedId) {
|
||||
return requestedId === caller ? root.ownService : null
|
||||
},
|
||||
_summon: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["summon"])
|
||||
return true
|
||||
},
|
||||
_hide: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["hide"])
|
||||
return true
|
||||
},
|
||||
_toggle: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["toggle"])
|
||||
return true
|
||||
},
|
||||
_isOpen: function(requestedId) { return requestedId === caller },
|
||||
_updateSettings: function(requestedId) {
|
||||
if (requestedId !== caller) return false
|
||||
root.calls = root.calls.concat(["settings"])
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
var own = api.serviceFor(caller)
|
||||
var result = {
|
||||
detached: api.parent === undefined || api.parent === null,
|
||||
ownService: own && own.marker === "own",
|
||||
foreignService: api.serviceFor("omarchy.lock") === null,
|
||||
firstPartyService: api.firstPartyServiceFor("omarchy.polkit") === null,
|
||||
ownSummon: api.summon(caller, "{}") === true,
|
||||
foreignSummon: api.summon("omarchy.lock", "{}") === false,
|
||||
ownHide: api.hide(caller) === true,
|
||||
foreignHide: api.hide("omarchy.lock") === false,
|
||||
ownToggle: api.toggle(caller, "{}") === true,
|
||||
foreignToggle: api.toggle("omarchy.lock", "{}") === false,
|
||||
ownOpen: api.isPluginOpen(caller) === true,
|
||||
foreignOpen: api.isPluginOpen("omarchy.lock") === false,
|
||||
ownSettings: api.updateEntryInline(caller, {}) === true,
|
||||
foreignSettings: api.updateEntryInline("omarchy.lock", {}) === false,
|
||||
authStoreOwnerRetains: authStoreOwner.has("omarchy.lock") === true,
|
||||
authStoreImportIsolated: authStoreReader.has("omarchy.lock") === false,
|
||||
calls: root.calls
|
||||
}
|
||||
result.ok = Object.keys(result).every(function(key) {
|
||||
return key === "ok" || key === "calls" || result[key] === true
|
||||
}) && JSON.stringify(result.calls) === JSON.stringify(["summon", "hide", "toggle", "settings"])
|
||||
resultFile.setText(JSON.stringify(result))
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,9 @@ ShellRoot {
|
||||
scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" }))
|
||||
scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("firstparty", "/first/hybrid", manifest("omarchy.hybrid", ["menu", "bar-widget"], { menu: "Menu.qml", barWidget: "Widget.qml" }))
|
||||
var futureAuth = manifest("omarchy.future-auth", ["service"], { service: "Service.qml" })
|
||||
futureAuth.omarchy = { capabilities: ["authentication"] }
|
||||
scan += block("firstparty", "/first/future-auth", futureAuth)
|
||||
scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" }, { defaultSection: "left" }))
|
||||
scan += block("thirdparty", "/third/center-widget", manifest("third.center-widget", ["bar-widget"], { barWidget: "Widget.qml" }))
|
||||
@@ -103,6 +106,12 @@ ShellRoot {
|
||||
localBar.omarchy = { clonedFrom: "omarchy.bar" }
|
||||
scan += block("thirdparty", "/third/local-bar", localBar)
|
||||
scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" }))
|
||||
var localFutureAuth = manifest("local.future-auth", ["service"], { service: "Service.qml" })
|
||||
localFutureAuth.omarchy = { clonedFrom: "omarchy.future-auth" }
|
||||
scan += block("thirdparty", "/third/local-future-auth", localFutureAuth)
|
||||
var spoofedAuth = manifest("third.spoofed-auth", ["service"], { service: "Service.qml" })
|
||||
spoofedAuth.omarchy = { capabilities: ["authentication"] }
|
||||
scan += block("thirdparty", "/third/spoofed-auth", spoofedAuth)
|
||||
scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" }))
|
||||
scan += block("thirdparty", "/third/unsafe", manifest("third.unsafe", ["panel"], { panel: "../Panel.qml" }))
|
||||
@@ -116,22 +125,28 @@ ShellRoot {
|
||||
root.assertDeepEqual(pluginIds(), [
|
||||
"local.bar",
|
||||
"local.first-widget",
|
||||
"local.future-auth",
|
||||
"local.grouped-panel",
|
||||
"local.hybrid",
|
||||
"local.weather",
|
||||
"omarchy.bar",
|
||||
"omarchy.first-widget",
|
||||
"omarchy.future-auth",
|
||||
"omarchy.grouped-panel",
|
||||
"omarchy.hybrid",
|
||||
"third.bar",
|
||||
"third.center-widget",
|
||||
"third.panel",
|
||||
"third.right-widget",
|
||||
"third.spoofed-auth",
|
||||
"third.widget"
|
||||
], "registry merges valid first-party and third-party manifests")
|
||||
|
||||
root.assertTrue(registry.installedPlugins["omarchy.first-widget"].__isFirstParty === true, "first-party manifests are stamped")
|
||||
root.assertTrue(registry.installedPlugins["third.panel"].__isFirstParty === false, "third-party manifests are stamped")
|
||||
root.assertDeepEqual(registry.installedPlugins["omarchy.future-auth"].__hostCapabilities, ["authentication"], "trusted manifests stamp authentication capability")
|
||||
root.assertDeepEqual(registry.installedPlugins["local.future-auth"].__hostCapabilities, ["authentication"], "clones inherit trusted host capabilities")
|
||||
root.assertDeepEqual(registry.installedPlugins["third.spoofed-auth"].__hostCapabilities, [], "third-party manifests cannot self-grant host capabilities")
|
||||
root.assertEqual(registry.installedPlugins["omarchy.grouped-panel"].__sourceDir, "/first/panels/grouped", "grouped plugin source paths are preserved")
|
||||
root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.panel"], "panel"), "file:///third/panel/Panel.qml", "entryPointUrl resolves plugin-relative paths")
|
||||
root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.widget"], "barWidget"), "file:///third/widget/Widget.qml", "entryPointUrl resolves bar widget paths")
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
TMPDIR=""
|
||||
QS_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then
|
||||
kill "$QS_PID" 2>/dev/null || true
|
||||
wait "$QS_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n $TMPDIR && -d $TMPDIR ]]; then
|
||||
rm -rf "$TMPDIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
shell_qml="$ROOT/shell/shell.qml"
|
||||
bar_qml="$ROOT/shell/plugins/bar/Bar.qml"
|
||||
|
||||
# Normalize horizontal and vertical whitespace so the wiring assertions survive
|
||||
# harmless QML reflow. The runtime fixture below behaviorally covers
|
||||
# PluginShellApi and AuthServiceStore; these checks remain the guard for their
|
||||
# integration through shell.qml and Bar.qml, including without a compositor.
|
||||
qml_matches() {
|
||||
local file=$1
|
||||
local pattern=$2
|
||||
|
||||
tr '\n\r\t' ' ' < "$file" | grep -Eq "$pattern"
|
||||
}
|
||||
|
||||
qml_matches "$shell_qml" 'comp\.createObject\( *manifest\.__isFirstParty *&& *!authenticationService *\? *serviceHost *: *null *\)' ||
|
||||
fail "third-party and authentication services are detached from the host object tree"
|
||||
qml_matches "$shell_qml" 'AuthServiceStore\.put\( *key, *inst *\)' ||
|
||||
fail "authentication services are retained outside the host service map"
|
||||
pass "third-party and authentication services are detached from the host object tree"
|
||||
|
||||
qml_matches "$shell_qml" 'inst\.shell *= *shell\.pluginShellFor\( *manifest *\)' ||
|
||||
fail "service plugins receive a scoped shell facade"
|
||||
qml_matches "$shell_qml" 'item\.shell *= *shell\.pluginShellFor\( *panelEntry\.manifest *\)' ||
|
||||
fail "panel plugins receive a scoped shell facade"
|
||||
qml_matches "$shell_qml" 'target\.shell *= *shell\.pluginShellFor\( *manifest *\)' ||
|
||||
fail "full-bar plugins receive a scoped shell facade"
|
||||
pass "third-party entry points receive scoped shell facades"
|
||||
|
||||
qml_matches "$bar_qml" 'target\.bar *= *firstParty *\? *root *: *root\.pluginBarApiFor\( *pluginApiId, *moduleName, *registered *\)' ||
|
||||
fail "third-party widgets receive a bar facade instead of the host bar"
|
||||
qml_matches "$bar_qml" 'api\.clickTargets *= *root\.pluginClickTargets\( *api\.pluginId *\)' ||
|
||||
fail "third-party bar facades exclude other widgets from their object graph"
|
||||
pass "third-party widgets receive a bar facade instead of the host bar"
|
||||
|
||||
qml_matches "$shell_qml" 'widgets: *shell\.publicBarWidgetSnapshot\( *\)' ||
|
||||
fail "third-party widget registries receive detached snapshots"
|
||||
qml_matches "$bar_qml" 'root\.markPluginObject\( *pluginId, *target, *"clickTarget" *\)' ||
|
||||
fail "third-party bar-object ownership is stamped by the host callback"
|
||||
qml_matches "$bar_qml" 'root\.markPluginObject\( *pluginId, *owner, *"popout" *\)' ||
|
||||
fail "owner-less popouts receive trusted ownership before activation"
|
||||
qml_matches "$shell_qml" 'manifest\.__hostCapabilities\.indexOf\( *"authentication" *\)' ||
|
||||
fail "authentication isolation follows host-stamped capabilities"
|
||||
pass "registry mutation and ownership boundaries are host-controlled"
|
||||
|
||||
qml_matches "$bar_qml" 'root\.moduleWidgets\( *moduleName *\)' ||
|
||||
fail "custom bar module widget lookups use their real module name"
|
||||
qml_matches "$shell_qml" 'shell\.pluginShellForBarEntry\( *cacheKey *\+ *":" *\+ *ownerId, *moduleName *\)' ||
|
||||
fail "full-bar plugins receive a scoped settings facade for custom modules"
|
||||
pass "custom bar modules retain settings and popout identity"
|
||||
|
||||
if qml_matches "$bar_qml" 'on(Foreground|BarForeground|Background|Urgent|FontFamily|Vertical|BarSize|Transparent)Changed: *sync'; then
|
||||
fail "animated scalar properties still trigger full facade resyncs"
|
||||
fi
|
||||
qml_matches "$bar_qml" 'api\.foreground *= *Qt\.binding\( *function\( *\) *\{ *return root\.foreground *\} *\)' ||
|
||||
fail "third-party bar scalar mirrors use bindings"
|
||||
qml_matches "$shell_qml" 'shell\.prunePluginApis\( *\)' ||
|
||||
fail "disabled plugin facade caches are pruned"
|
||||
pass "plugin facade synchronization is bounded"
|
||||
|
||||
require_compositor "plugin authentication boundary runtime test"
|
||||
|
||||
if ! command -v quickshell >/dev/null 2>&1; then
|
||||
pass "quickshell not installed; skipping plugin authentication boundary runtime test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
require_command jq
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
result="$TMPDIR/result.json"
|
||||
log="$TMPDIR/quickshell.log"
|
||||
config_dir="$TMPDIR/plugin-auth-boundary"
|
||||
mkdir -p "$config_dir" "$TMPDIR/home"
|
||||
cp "$SHELL_TEST_DIR/fixtures/plugin-auth-boundary/"*.qml "$config_dir/"
|
||||
ln -s "$ROOT/shell/services" "$config_dir/services"
|
||||
|
||||
OMARCHY_QML_TEST_RESULT="$result" \
|
||||
HOME="$TMPDIR/home" \
|
||||
XDG_CONFIG_HOME="$TMPDIR/home/.config" \
|
||||
XDG_CACHE_HOME="$TMPDIR/home/.cache" \
|
||||
XDG_STATE_HOME="$TMPDIR/home/.local/state" \
|
||||
quickshell -p "$config_dir" --no-color >"$log" 2>&1 &
|
||||
QS_PID=$!
|
||||
|
||||
for _ in {1..80}; do
|
||||
[[ -s $result ]] && break
|
||||
if ! kill -0 "$QS_PID" 2>/dev/null; then
|
||||
sed -n '1,220p' "$log" >&2
|
||||
fail "plugin authentication boundary fixture exited before writing result"
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
[[ -s $result ]] || {
|
||||
sed -n '1,220p' "$log" >&2
|
||||
fail "plugin authentication boundary runtime test timed out"
|
||||
}
|
||||
|
||||
if ! jq -e '.ok == true' "$result" >/dev/null; then
|
||||
jq . "$result" >&2
|
||||
sed -n '1,220p' "$log" >&2
|
||||
fail "plugin authentication boundary runtime behavior"
|
||||
fi
|
||||
|
||||
pass "plugin authentication boundary runtime behavior"
|
||||
Reference in new issue
Block a user