From b33aded230a751f3bd964a3bd234d2cc6da698a9 Mon Sep 17 00:00:00 2001 From: jamesread Date: Mon, 9 Mar 2026 10:00:47 +0000 Subject: [PATCH 1/8] chore: Use a constant for the default CSP --- service/internal/config/config.go | 2 +- service/internal/config/constants.go | 5 +++++ service/internal/config/sanitize.go | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 service/internal/config/constants.go diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 742f6bf..03edd4a 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -281,7 +281,7 @@ func DefaultConfigWithBasePort(basePort int) *Config { config.Prometheus.Enabled = false config.Prometheus.DefaultGoMetrics = false config.Security.HeaderContentSecurityPolicy = true - config.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'" + config.Security.ContentSecurityPolicy = ContentSecurityPolicyDefault config.Security.HeaderXContentTypeOptions = true config.Security.HeaderXFrameOptions = true config.Security.XFrameOptions = "DENY" diff --git a/service/internal/config/constants.go b/service/internal/config/constants.go new file mode 100644 index 0000000..fe8cc1f --- /dev/null +++ b/service/internal/config/constants.go @@ -0,0 +1,5 @@ +package config + +// ContentSecurityPolicyDefault is the default Content-Security-Policy header value +// when security headers are enabled and no custom policy is set. +const ContentSecurityPolicyDefault = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'" diff --git a/service/internal/config/sanitize.go b/service/internal/config/sanitize.go index f612edb..0c97aa5 100644 --- a/service/internal/config/sanitize.go +++ b/service/internal/config/sanitize.go @@ -194,7 +194,7 @@ func (cfg *Config) sanitizeSecurityHeadersCSP() { if !cfg.Security.HeaderContentSecurityPolicy || cfg.Security.ContentSecurityPolicy != "" { return } - cfg.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'" + cfg.Security.ContentSecurityPolicy = ContentSecurityPolicyDefault } func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() { From 9d916b717568c45b00de9e033209df615218aa12 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 10 Mar 2026 11:40:06 +0000 Subject: [PATCH 2/8] fix: Add link to websocket disconnection --- .../vue/components/ConnectionBanner.vue | 18 ++++++++++--- lang/combined_output.json | 25 +++++++++++-------- lang/de-DE.yaml | 5 ++-- lang/en.yaml | 5 ++-- lang/es-ES.yaml | 5 ++-- lang/it-IT.yaml | 5 ++-- lang/zh-Hans-CN.yaml | 5 ++-- 7 files changed, 44 insertions(+), 24 deletions(-) diff --git a/frontend/resources/vue/components/ConnectionBanner.vue b/frontend/resources/vue/components/ConnectionBanner.vue index 48e8462..ebf2928 100644 --- a/frontend/resources/vue/components/ConnectionBanner.vue +++ b/frontend/resources/vue/components/ConnectionBanner.vue @@ -1,7 +1,9 @@ @@ -12,6 +14,10 @@ import { connectionState } from '../stores/connectionState.js' const { t } = useI18n() +const websocketDocsUrl = 'https://docs.olivetin.app/troubleshooting/err-websocket-connection.html' + +const linkText = computed(() => t('disconnected-banner-link-text')) + function formatShortRelative(ms) { if (ms < 0) return '0s' const secs = Math.floor(ms / 1000) @@ -49,16 +55,16 @@ onUnmounted(() => { const staticAnnouncement = computed(() => t('disconnected-banner-announcement')) -const bannerText = computed(() => { +const bannerSuffix = computed(() => { const at = connectionState.disconnectedAt const next = connectionState.nextReconnectAt const n = now.value const disconnectedSince = formatShortTime(at) if (next != null && next > n) { const reconnectIn = formatShortRelative(next - n) - return t('disconnected-banner', { disconnectedSince, reconnectIn }) + return t('disconnected-banner-suffix', { disconnectedSince, reconnectIn }) } - return t('disconnected-banner-reconnecting', { disconnectedSince }) + return t('disconnected-banner-suffix-reconnecting', { disconnectedSince }) }) @@ -70,6 +76,10 @@ const bannerText = computed(() => { border: 0; margin: 0; } +.connection-banner-link { + color: inherit; + text-decoration: underline; +} .connection-banner-sr-only { position: absolute; width: 1px; diff --git a/lang/combined_output.json b/lang/combined_output.json index 979be80..a162003 100644 --- a/lang/combined_output.json +++ b/lang/combined_output.json @@ -21,9 +21,10 @@ "diagnostics.useragent-data-error": "Fehler beim Abrufen von userAgentData", "diagnostics.where-to-find-help": "Wo Sie Hilfe finden", "disconnected": "Getrennt", - "disconnected-banner": "Events-Websocket getrennt seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}.", "disconnected-banner-announcement": "Events-Websocket getrennt.", - "disconnected-banner-reconnecting": "Events-Websocket getrennt seit {disconnectedSince}. Verbindungsversuch…", + "disconnected-banner-link-text": "Events-Websocket getrennt", + "disconnected-banner-suffix": " seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}.", + "disconnected-banner-suffix-reconnecting": " seit {disconnectedSince}. Verbindungsversuch…", "docs": "Dokumentation", "language-dialog.browser-languages": "Browser-Sprachen", "language-dialog.close": "Schließen", @@ -79,9 +80,10 @@ "diagnostics.useragent-data-error": "Error retrieving userAgentData", "diagnostics.where-to-find-help": "Where to find help", "disconnected": "Disconnected", - "disconnected-banner": "Events websocket disconnected since {disconnectedSince}. Trying reconnect in {reconnectIn}.", "disconnected-banner-announcement": "Events websocket disconnected.", - "disconnected-banner-reconnecting": "Events websocket disconnected since {disconnectedSince}. Trying reconnect…", + "disconnected-banner-link-text": "Events websocket disconnected", + "disconnected-banner-suffix": " since {disconnectedSince}. Trying reconnect in {reconnectIn}.", + "disconnected-banner-suffix-reconnecting": " since {disconnectedSince}. Trying reconnect…", "docs": "Documentation", "language-dialog.browser-languages": "Browser languages", "language-dialog.close": "Close", @@ -137,9 +139,10 @@ "diagnostics.useragent-data-error": "Error al recuperar userAgentData", "diagnostics.where-to-find-help": "Dónde encontrar ayuda", "disconnected": "Desconectado", - "disconnected-banner": "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión en {reconnectIn}.", "disconnected-banner-announcement": "Websocket de eventos desconectado.", - "disconnected-banner-reconnecting": "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión…", + "disconnected-banner-link-text": "Websocket de eventos desconectado", + "disconnected-banner-suffix": " desde {disconnectedSince}. Reintentando conexión en {reconnectIn}.", + "disconnected-banner-suffix-reconnecting": " desde {disconnectedSince}. Reintentando conexión…", "docs": "Documentación", "language-dialog.browser-languages": "Idiomas del navegador", "language-dialog.close": "Cerrar", @@ -195,9 +198,10 @@ "diagnostics.useragent-data-error": "Errore nel recupero di userAgentData", "diagnostics.where-to-find-help": "Dove trovare aiuto", "disconnected": "Disconnesso", - "disconnected-banner": "Websocket eventi disconnesso dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}.", "disconnected-banner-announcement": "Websocket eventi disconnesso.", - "disconnected-banner-reconnecting": "Websocket eventi disconnesso dalle {disconnectedSince}. Tentativo di connessione…", + "disconnected-banner-link-text": "Websocket eventi disconnesso", + "disconnected-banner-suffix": " dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}.", + "disconnected-banner-suffix-reconnecting": " dalle {disconnectedSince}. Tentativo di connessione…", "docs": "Documentazione", "language-dialog.browser-languages": "Lingue del browser", "language-dialog.close": "Chiudi", @@ -253,9 +257,10 @@ "diagnostics.useragent-data-error": "检索 userAgentData 时出错", "diagnostics.where-to-find-help": "在哪里找到帮助", "disconnected": "已断开连接", - "disconnected-banner": "事件 WebSocket 自 {disconnectedSince} 已断开。{reconnectIn} 后尝试重连。", "disconnected-banner-announcement": "事件 WebSocket 已断开。", - "disconnected-banner-reconnecting": "事件 WebSocket 自 {disconnectedSince} 已断开。正在尝试重连…", + "disconnected-banner-link-text": "事件 WebSocket 已断开", + "disconnected-banner-suffix": "自 {disconnectedSince}。{reconnectIn} 后尝试重连。", + "disconnected-banner-suffix-reconnecting": "自 {disconnectedSince}。正在尝试重连…", "docs": "文档", "language-dialog.browser-languages": "浏览器语言", "language-dialog.close": "关闭", diff --git a/lang/de-DE.yaml b/lang/de-DE.yaml index 606620b..f96ee0b 100644 --- a/lang/de-DE.yaml +++ b/lang/de-DE.yaml @@ -8,9 +8,10 @@ translations: connected: Verbunden disconnected: Getrennt reconnecting: Verbinde erneut… - disconnected-banner: "Events-Websocket getrennt seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}." - disconnected-banner-reconnecting: "Events-Websocket getrennt seit {disconnectedSince}. Verbindungsversuch…" disconnected-banner-announcement: Events-Websocket getrennt. + disconnected-banner-link-text: "Events-Websocket getrennt" + disconnected-banner-suffix: " seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}." + disconnected-banner-suffix-reconnecting: " seit {disconnectedSince}. Verbindungsversuch…" login-button: Login raise-issue: Ein Problem melden auf GitHub docs: Dokumentation diff --git a/lang/en.yaml b/lang/en.yaml index 61d3e3d..77421f4 100644 --- a/lang/en.yaml +++ b/lang/en.yaml @@ -10,9 +10,10 @@ translations: connected: Connected disconnected: Disconnected reconnecting: Reconnecting… - disconnected-banner: "Events websocket disconnected since {disconnectedSince}. Trying reconnect in {reconnectIn}." - disconnected-banner-reconnecting: "Events websocket disconnected since {disconnectedSince}. Trying reconnect…" disconnected-banner-announcement: Events websocket disconnected. + disconnected-banner-link-text: "Events websocket disconnected" + disconnected-banner-suffix: " since {disconnectedSince}. Trying reconnect in {reconnectIn}." + disconnected-banner-suffix-reconnecting: " since {disconnectedSince}. Trying reconnect…" login-button: Login logs.title: Logs logs.page-description: This is a list of logs from actions that have been executed. You can filter the list by action title. diff --git a/lang/es-ES.yaml b/lang/es-ES.yaml index 6cc8c71..6a03237 100644 --- a/lang/es-ES.yaml +++ b/lang/es-ES.yaml @@ -8,9 +8,10 @@ translations: connected: Conectado disconnected: Desconectado reconnecting: Reconectando… - disconnected-banner: "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión en {reconnectIn}." - disconnected-banner-reconnecting: "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión…" disconnected-banner-announcement: Websocket de eventos desconectado. + disconnected-banner-link-text: "Websocket de eventos desconectado" + disconnected-banner-suffix: " desde {disconnectedSince}. Reintentando conexión en {reconnectIn}." + disconnected-banner-suffix-reconnecting: " desde {disconnectedSince}. Reintentando conexión…" login-button: Iniciar sesión raise-issue: Reportar un problema en GitHub docs: Documentación diff --git a/lang/it-IT.yaml b/lang/it-IT.yaml index 28c8549..351ed24 100644 --- a/lang/it-IT.yaml +++ b/lang/it-IT.yaml @@ -9,9 +9,10 @@ translations: connected: Connesso disconnected: Disconnesso reconnecting: Riconnessione… - disconnected-banner: "Websocket eventi disconnesso dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}." - disconnected-banner-reconnecting: "Websocket eventi disconnesso dalle {disconnectedSince}. Tentativo di connessione…" disconnected-banner-announcement: Websocket eventi disconnesso. + disconnected-banner-link-text: "Websocket eventi disconnesso" + disconnected-banner-suffix: " dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}." + disconnected-banner-suffix-reconnecting: " dalle {disconnectedSince}. Tentativo di connessione…" login-button: Login raise-issue: Segnala un problema su GitHub logs.title: Registri diff --git a/lang/zh-Hans-CN.yaml b/lang/zh-Hans-CN.yaml index 58f3cb7..2883a67 100644 --- a/lang/zh-Hans-CN.yaml +++ b/lang/zh-Hans-CN.yaml @@ -8,9 +8,10 @@ translations: connected: 已连接 disconnected: 已断开连接 reconnecting: 正在重新连接… - disconnected-banner: "事件 WebSocket 自 {disconnectedSince} 已断开。{reconnectIn} 后尝试重连。" - disconnected-banner-reconnecting: "事件 WebSocket 自 {disconnectedSince} 已断开。正在尝试重连…" disconnected-banner-announcement: 事件 WebSocket 已断开。 + disconnected-banner-link-text: "事件 WebSocket 已断开" + disconnected-banner-suffix: "自 {disconnectedSince}。{reconnectIn} 后尝试重连。" + disconnected-banner-suffix-reconnecting: "自 {disconnectedSince}。正在尝试重连…" login-button: 登录 raise-issue: 在 GitHub 上报告问题 docs: 文档 From ac0852aad42c9254809c4ead220df881d9960f6e Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 10 Mar 2026 22:36:28 +0000 Subject: [PATCH 3/8] chore: dep update March 26 --- frontend/package-lock.json | 202 ++++++++++++++++++++----------------- frontend/package.json | 10 +- 2 files changed, 115 insertions(+), 97 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1f2bcb..6d6cc10 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,19 +11,19 @@ "dependencies": { "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", - "@hugeicons/core-free-icons": "^3.3.0", - "@hugeicons/vue": "^1.0.4", + "@hugeicons/core-free-icons": "^4.0.0", + "@hugeicons/vue": "^1.0.5", "@vitejs/plugin-vue": "^6.0.4", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "iconify-icon": "^3.0.2", - "picocrank": "^1.14.0", + "picocrank": "^1.14.1", "standard": "^17.1.2", "unplugin-vue-components": "^31.0.0", "vite": "^7.3.1", - "vue": "^3.5.29", - "vue-i18n": "^11.2.8", + "vue": "^3.5.30", + "vue-i18n": "^11.3.0", "vue-router": "^5.0.3" }, "devDependencies": { @@ -906,15 +906,15 @@ } }, "node_modules/@hugeicons/core-free-icons": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.3.0.tgz", - "integrity": "sha512-qYyr4JQ2eQIHTSTbITvnJvs6ERNK64D9gpwZnf2IyuG0exzqfyABLO/oTB71FB3RZPfu1GbwycdiGSo46apjMQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.0.0.tgz", + "integrity": "sha512-bzfbKumv3ke3ajbe2MyXi9i0I/cdsZ6n/mO9EfIPNSL++pHLqs7nSGRIVUtjF4xrrEyVkfhxssv4Jek8DPA6gA==", "license": "MIT" }, "node_modules/@hugeicons/vue": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.4.tgz", - "integrity": "sha512-OtFEXbyW5jYUig98C/n/HygktLvfF5Ga6nN6gK8R0E0jCrVw3EfgoZZVXqo+xGxyIjH5R1wdbg6nJrtf6mzLKQ==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.5.tgz", + "integrity": "sha512-kaouUZceXtdDfupfiqqfn40tIyRBF/fcEvCfY96hZIXZ3JMsqpwhCDqiqoj+B5bMEUXOuvz3npNyKI5+7iPfYA==", "license": "MIT", "peerDependencies": { "vue": "^2.6.0 || ^3.0.0" @@ -962,13 +962,30 @@ "license": "MIT" }, "node_modules/@intlify/core-base": { - "version": "11.2.8", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz", - "integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.0.tgz", + "integrity": "sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==", "license": "MIT", "dependencies": { - "@intlify/message-compiler": "11.2.8", - "@intlify/shared": "11.2.8" + "@intlify/devtools-types": "11.3.0", + "@intlify/message-compiler": "11.3.0", + "@intlify/shared": "11.3.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/devtools-types": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.0.tgz", + "integrity": "sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.3.0", + "@intlify/shared": "11.3.0" }, "engines": { "node": ">= 16" @@ -978,12 +995,12 @@ } }, "node_modules/@intlify/message-compiler": { - "version": "11.2.8", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz", - "integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.0.tgz", + "integrity": "sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==", "license": "MIT", "dependencies": { - "@intlify/shared": "11.2.8", + "@intlify/shared": "11.3.0", "source-map-js": "^1.0.2" }, "engines": { @@ -994,9 +1011,9 @@ } }, "node_modules/@intlify/shared": { - "version": "11.2.8", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz", - "integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.0.tgz", + "integrity": "sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==", "license": "MIT", "engines": { "node": ">= 16" @@ -1436,53 +1453,53 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", - "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.29", + "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", - "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.29", - "@vue/shared": "3.5.29" + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", - "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.29", - "@vue/compiler-dom": "3.5.29", - "@vue/compiler-ssr": "3.5.29", - "@vue/shared": "3.5.29", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", - "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.29", - "@vue/shared": "3.5.29" + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" } }, "node_modules/@vue/devtools-api": { @@ -1516,53 +1533,53 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", - "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.29" + "@vue/shared": "3.5.30" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", - "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.29", - "@vue/shared": "3.5.29" + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", - "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.29", - "@vue/runtime-core": "3.5.29", - "@vue/shared": "3.5.29", + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", - "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.29", - "@vue/shared": "3.5.29" + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" }, "peerDependencies": { - "vue": "3.5.29" + "vue": "3.5.30" } }, "node_modules/@vue/shared": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", - "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", "license": "MIT" }, "node_modules/@xterm/addon-fit": { @@ -4875,19 +4892,19 @@ "license": "ISC" }, "node_modules/picocrank": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.14.0.tgz", - "integrity": "sha512-ksjqPHFMFE6ENaIXjhund50wocFmaLy22jYgWlWikugHBdd/0YlHfOOuoIMn0wKV8bSrJhcM3pQug/qz45Bc4g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.14.1.tgz", + "integrity": "sha512-N/aGK/deicXevv+n3zWaKrUe66d4QYQ/7SPinEr4fprljNpXQHSl2EBDlmTNlLoujR3QJHClfRO8Gusj2Hs5MQ==", "license": "ISC", "dependencies": { - "@hugeicons/core-free-icons": "^3.1.1", - "@hugeicons/vue": "^1.0.4", + "@hugeicons/core-free-icons": "^4.0.0", + "@hugeicons/vue": "^1.0.5", "@vitejs/plugin-vue": "^6.0.4", "femtocrank": "^2.5.0", "unplugin-vue-components": "^31.0.0", "vite": "^7.3.1", - "vue": "^3.5.28", - "vue-router": "^5.0.2" + "vue": "^3.5.30", + "vue-router": "^5.0.3" } }, "node_modules/picomatch": { @@ -5007,9 +5024,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -6603,16 +6620,16 @@ } }, "node_modules/vue": { - "version": "3.5.29", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", - "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.29", - "@vue/compiler-sfc": "3.5.29", - "@vue/runtime-dom": "3.5.29", - "@vue/server-renderer": "3.5.29", - "@vue/shared": "3.5.29" + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" @@ -6624,13 +6641,14 @@ } }, "node_modules/vue-i18n": { - "version": "11.2.8", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz", - "integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz", + "integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==", "license": "MIT", "dependencies": { - "@intlify/core-base": "11.2.8", - "@intlify/shared": "11.2.8", + "@intlify/core-base": "11.3.0", + "@intlify/devtools-types": "11.3.0", + "@intlify/shared": "11.3.0", "@vue/devtools-api": "^6.5.0" }, "engines": { diff --git a/frontend/package.json b/frontend/package.json index a08f799..f18851c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,19 +24,19 @@ "dependencies": { "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", - "@hugeicons/core-free-icons": "^3.3.0", - "@hugeicons/vue": "^1.0.4", + "@hugeicons/core-free-icons": "^4.0.0", + "@hugeicons/vue": "^1.0.5", "@vitejs/plugin-vue": "^6.0.4", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "iconify-icon": "^3.0.2", - "picocrank": "^1.14.0", + "picocrank": "^1.14.1", "standard": "^17.1.2", "unplugin-vue-components": "^31.0.0", "vite": "^7.3.1", - "vue": "^3.5.29", - "vue-i18n": "^11.2.8", + "vue": "^3.5.30", + "vue-i18n": "^11.3.0", "vue-router": "^5.0.3" } } From bc5e9fbe1e22ff87a4b277cb56605a46a10e561a Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 10 Mar 2026 23:27:09 +0000 Subject: [PATCH 4/8] security: GHSA-xx6g-43w2-9g6g (MODERATE) Email argument makes compliance harder, enables log injection --- service/internal/executor/arguments.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/service/internal/executor/arguments.go b/service/internal/executor/arguments.go index 655ecc4..a73a0d7 100644 --- a/service/internal/executor/arguments.go +++ b/service/internal/executor/arguments.go @@ -250,13 +250,10 @@ func typecheckChoiceEntity(value string, arg *config.ActionArgument) error { func typeSafetyCheckEmail(value string) error { _, err := mail.ParseAddress(value) - - log.Errorf("Email check: %v, %v", err, value) - if err != nil { + log.WithField("type", "email").Debugf("Email argument type check failed") return err } - return nil } From 86c35f40c324106d62d98be145aefe7220126473 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 10 Mar 2026 23:28:17 +0000 Subject: [PATCH 5/8] chore: remove unneeded comment --- service/internal/config/constants.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/service/internal/config/constants.go b/service/internal/config/constants.go index fe8cc1f..bff42c3 100644 --- a/service/internal/config/constants.go +++ b/service/internal/config/constants.go @@ -1,5 +1,3 @@ package config -// ContentSecurityPolicyDefault is the default Content-Security-Policy header value -// when security headers are enabled and no custom policy is set. const ContentSecurityPolicyDefault = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'" From acd6cb839e999bb2b0e9a91424f51e88be3f7ffc Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 10 Mar 2026 23:47:35 +0000 Subject: [PATCH 6/8] security: GHSA-228v-wc5r-j8m7 (HIGH) Unauthorized Action Output Disclosure via EventStream --- service/internal/api/api.go | 148 +++++++++++++++++++++---------- service/internal/api/api_test.go | 139 +++++++++++++++++++++++++++-- 2 files changed, 235 insertions(+), 52 deletions(-) diff --git a/service/internal/api/api.go b/service/internal/api/api.go index 83465aa..3497fb1 100644 --- a/service/internal/api/api.go +++ b/service/internal/api/api.go @@ -60,6 +60,20 @@ type streamingClient struct { AuthenticatedUser *authpublic.AuthenticatedUser } +// trySendEventToClient sends msg to the client's channel. Returns false if the channel is full (client should be removed). +func (api *oliveTinAPI) trySendEventToClient(client *streamingClient, msg *apiv1.EventStreamResponse) bool { + if client == nil || msg == nil { + return false + } + select { + case client.channel <- msg: + return true + default: + log.Warnf("EventStream: client channel is full, removing client") + return false + } +} + func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) { ret := &apiv1.KillActionResponse{ ExecutionTrackingId: req.Msg.ExecutionTrackingId, @@ -589,9 +603,20 @@ func isValidLogEntry(e *executor.InternalLogEntry) bool { // isLogEntryAllowed checks if a log entry is allowed to be viewed by the user. func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool { + if user == nil || !isValidLogEntry(e) { + return false + } return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) } +// mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL). +func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool { + if user == nil { + return false + } + return isValidLogEntry(entry) && api.isLogEntryAllowed(entry, user) +} + // buildEmptyPageResponse creates a response for an empty page. func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse { return &apiv1.GetActionLogsResponse{ @@ -886,6 +911,9 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1. } func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) { + if clientToRemove == nil { + return + } api.streamingClientsMutex.Lock() delete(api.streamingClients, clientToRemove) api.streamingClientsMutex.Unlock() @@ -915,50 +943,62 @@ func (api *oliveTinAPI) OnActionMapRebuilt() { func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) { toRemove := []*streamingClient{} - for _, client := range api.copyOfStreamingClients() { - select { - case client.channel <- &apiv1.EventStreamResponse{ - Event: &apiv1.EventStreamResponse_ExecutionStarted{ - ExecutionStarted: &apiv1.EventExecutionStarted{ - LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser), - }, - }, - }: - default: - log.Warnf("EventStream: client channel is full, removing client") - toRemove = append(toRemove, client) - } + api.maybeSendExecutionStarted(client, ex, &toRemove) } - for _, client := range toRemove { api.removeClient(client) } } +func (api *oliveTinAPI) maybeSendExecutionStarted(client *streamingClient, ex *executor.InternalLogEntry, toRemove *[]*streamingClient) { + if client == nil { + return + } + if !api.mayViewExecutionEvent(ex, client.AuthenticatedUser) { + return + } + msg := &apiv1.EventStreamResponse{ + Event: &apiv1.EventStreamResponse_ExecutionStarted{ + ExecutionStarted: &apiv1.EventExecutionStarted{ + LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser), + }, + }, + } + if !api.trySendEventToClient(client, msg) { + *toRemove = append(*toRemove, client) + } +} + func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) { toRemove := []*streamingClient{} - for _, client := range api.copyOfStreamingClients() { - select { - case client.channel <- &apiv1.EventStreamResponse{ - Event: &apiv1.EventStreamResponse_ExecutionFinished{ - ExecutionFinished: &apiv1.EventExecutionFinished{ - LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser), - }, - }, - }: - default: - log.Warnf("EventStream: client channel is full, removing client") - toRemove = append(toRemove, client) - } + api.maybeSendExecutionFinished(client, ile, &toRemove) } - for _, client := range toRemove { api.removeClient(client) } } +func (api *oliveTinAPI) maybeSendExecutionFinished(client *streamingClient, ile *executor.InternalLogEntry, toRemove *[]*streamingClient) { + if client == nil { + return + } + if !api.mayViewExecutionEvent(ile, client.AuthenticatedUser) { + return + } + msg := &apiv1.EventStreamResponse{ + Event: &apiv1.EventStreamResponse_ExecutionFinished{ + ExecutionFinished: &apiv1.EventExecutionFinished{ + LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser), + }, + }, + } + if !api.trySendEventToClient(client, msg) { + *toRemove = append(*toRemove, client) + } +} + func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) { user := auth.UserFromApiCall(ctx, req, api.cfg) if err := api.checkDashboardAccess(user); err != nil { @@ -1128,29 +1168,47 @@ func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLin } func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) { - toRemove := []*streamingClient{} - - for _, client := range api.copyOfStreamingClients() { - select { - case client.channel <- &apiv1.EventStreamResponse{ - Event: &apiv1.EventStreamResponse_OutputChunk{ - OutputChunk: &apiv1.EventOutputChunk{ - Output: string(content), - ExecutionTrackingId: executionTrackingId, - }, - }, - }: - default: - log.Warnf("EventStream: client channel is full, removing client") - toRemove = append(toRemove, client) - } + entry := api.getValidLogEntryForStreaming(executionTrackingId) + if entry == nil { + return + } + msg := &apiv1.EventStreamResponse{ + Event: &apiv1.EventStreamResponse_OutputChunk{ + OutputChunk: &apiv1.EventOutputChunk{ + Output: string(content), + ExecutionTrackingId: executionTrackingId, + }, + }, + } + toRemove := []*streamingClient{} + for _, client := range api.copyOfStreamingClients() { + api.maybeSendOutputChunk(client, entry, msg, &toRemove) } - for _, client := range toRemove { api.removeClient(client) } } +func (api *oliveTinAPI) getValidLogEntryForStreaming(executionTrackingId string) *executor.InternalLogEntry { + entry, ok := api.executor.GetLog(executionTrackingId) + if !ok || !isValidLogEntry(entry) { + return nil + } + return entry +} + +func (api *oliveTinAPI) maybeSendOutputChunk(client *streamingClient, entry *executor.InternalLogEntry, msg *apiv1.EventStreamResponse, toRemove *[]*streamingClient) { + if client == nil { + return + } + if !api.mayViewExecutionEvent(entry, client.AuthenticatedUser) { + return + } + if !api.trySendEventToClient(client, msg) { + *toRemove = append(*toRemove, client) + } +} + func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) { user := auth.UserFromApiCall(ctx, req, api.cfg) diff --git a/service/internal/api/api_test.go b/service/internal/api/api_test.go index c6ee915..ca8ecf5 100644 --- a/service/internal/api/api_test.go +++ b/service/internal/api/api_test.go @@ -2,24 +2,24 @@ package api import ( "context" + "net/http" + "net/http/httptest" + "path" "testing" + "time" "connectrpc.com/connect" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - log "github.com/sirupsen/logrus" - apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect" authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/entities" "github.com/OliveTin/OliveTin/internal/executor" - - "net/http" - "net/http/httptest" - "path" ) func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) { @@ -338,12 +338,13 @@ func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *Dashboard } // buildViewPermissionTestConfig returns config and users for GHSA view-permission tests: -// one action "secret_action", ACL "restricted" (view:false) for user "low", ACL "full" (view:true) for user "admin". +// one action "secret_action", ACL "restricted" (view:false, logs:false) for user "low", ACL "full" (view:true, logs:true) for user "admin". func buildViewPermissionTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) { t.Helper() cfg := config.DefaultConfig() cfg.DefaultPermissions.View = false cfg.DefaultPermissions.Exec = false + cfg.DefaultPermissions.Logs = false cfg.Actions = append(cfg.Actions, &config.Action{ ID: "secret_action", @@ -572,3 +573,127 @@ func TestOrderTopLevelDashboardComponents_SortablesSorted(t *testing.T) { assert.Equal(t, "Alpha", out[0].Title, "sortables ordered by title") assert.Equal(t, "Beta", out[1].Title) } + +// TestEventStreamACLNoLeakToUnauthorizedUser (GHSA-228v-wc5r-j8m7) asserts that EventStream +// does not send execution events or output chunks to users who are not allowed to view that action's logs. +func TestEventStreamACLNoLeakToUnauthorizedUser(t *testing.T) { + cfg, lowUser, adminUser := buildViewPermissionTestConfig(t) + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + api := newServer(ex) + + binding := ex.FindBindingByID("secret_action") + require.NotNil(t, binding, "secret_action binding must exist") + + clientLow, clientAdmin := addEventStreamTestClients(t, api, lowUser, adminUser) + defer removeEventStreamTestClients(api, clientLow, clientAdmin) + + runEventStreamTestExecution(t, ex, cfg, binding, adminUser) + adminEvents := drainEventStreamUntilFinished(clientAdmin.channel, 2*time.Second) + lowEvents := drainEventStreamWithTimeout(clientLow.channel, 50*time.Millisecond) + + assertEventStreamLowUserReceivesNothing(t, lowEvents) + assertEventStreamAdminReceivesSecretActionEvents(t, adminEvents) +} + +func addEventStreamTestClients(t *testing.T, api *oliveTinAPI, lowUser, adminUser *authpublic.AuthenticatedUser) (*streamingClient, *streamingClient) { + t.Helper() + clientLow := &streamingClient{ + channel: make(chan *apiv1.EventStreamResponse, 20), + AuthenticatedUser: lowUser, + } + clientAdmin := &streamingClient{ + channel: make(chan *apiv1.EventStreamResponse, 20), + AuthenticatedUser: adminUser, + } + api.streamingClientsMutex.Lock() + api.streamingClients[clientLow] = struct{}{} + api.streamingClients[clientAdmin] = struct{}{} + api.streamingClientsMutex.Unlock() + return clientLow, clientAdmin +} + +func removeEventStreamTestClients(api *oliveTinAPI, clientLow, clientAdmin *streamingClient) { + api.streamingClientsMutex.Lock() + delete(api.streamingClients, clientLow) + delete(api.streamingClients, clientAdmin) + api.streamingClientsMutex.Unlock() + close(clientLow.channel) + close(clientAdmin.channel) +} + +func runEventStreamTestExecution(t *testing.T, ex *executor.Executor, cfg *config.Config, binding *executor.ActionBinding, adminUser *authpublic.AuthenticatedUser) { + t.Helper() + execReq := &executor.ExecutionRequest{ + Binding: binding, + Arguments: map[string]string{}, + TrackingID: uuid.NewString(), + Cfg: cfg, + AuthenticatedUser: adminUser, + } + wg, _ := ex.ExecRequest(execReq) + wg.Wait() +} + +func drainEventStreamUntilFinished(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) []*apiv1.EventStreamResponse { + var out []*apiv1.EventStreamResponse + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + ev, finished := recvEventStreamOne(ch, 50*time.Millisecond) + if ev != nil { + out = append(out, ev) + if finished { + return out + } + } + } + return out +} + +func recvEventStreamOne(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) (*apiv1.EventStreamResponse, bool) { + select { + case ev := <-ch: + return ev, ev.GetExecutionFinished() != nil + case <-time.After(timeout): + return nil, true + } +} + +func drainEventStreamWithTimeout(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) []*apiv1.EventStreamResponse { + var out []*apiv1.EventStreamResponse + for { + select { + case ev := <-ch: + out = append(out, ev) + case <-time.After(timeout): + return out + } + } +} + +func assertEventStreamLowUserReceivesNothing(t *testing.T, lowEvents []*apiv1.EventStreamResponse) { + t.Helper() + for _, ev := range lowEvents { + assert.Nil(t, ev.GetExecutionStarted(), "low-privilege user must not receive ExecutionStarted") + assert.Nil(t, ev.GetExecutionFinished(), "low-privilege user must not receive ExecutionFinished") + assert.Nil(t, ev.GetOutputChunk(), "low-privilege user must not receive OutputChunk") + } + assert.Empty(t, lowEvents, "low-privilege user with Logs:false must not receive any execution events") +} + +func assertEventStreamAdminReceivesSecretActionEvents(t *testing.T, adminEvents []*apiv1.EventStreamResponse) { + t.Helper() + var gotStarted, gotFinished bool + for _, ev := range adminEvents { + if ev.GetExecutionStarted() != nil { + gotStarted = true + assert.Equal(t, "secret_action", ev.GetExecutionStarted().LogEntry.GetBindingId()) + } + if ev.GetExecutionFinished() != nil { + gotFinished = true + assert.Equal(t, "secret_action", ev.GetExecutionFinished().LogEntry.GetBindingId()) + } + } + assert.True(t, gotStarted, "admin must receive ExecutionStarted for secret_action") + assert.True(t, gotFinished, "admin must receive ExecutionFinished for secret_action") +} From 606b705bdd435480be2e7af1fa83724ce344c603 Mon Sep 17 00:00:00 2001 From: jamesread Date: Wed, 11 Mar 2026 00:27:42 +0000 Subject: [PATCH 7/8] chore: fix potential panic in tests --- service/internal/api/api_test.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/service/internal/api/api_test.go b/service/internal/api/api_test.go index ca8ecf5..eaca331 100644 --- a/service/internal/api/api_test.go +++ b/service/internal/api/api_test.go @@ -640,11 +640,11 @@ func drainEventStreamUntilFinished(ch <-chan *apiv1.EventStreamResponse, timeout deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { ev, finished := recvEventStreamOne(ch, 50*time.Millisecond) + if finished { + return out + } if ev != nil { out = append(out, ev) - if finished { - return out - } } } return out @@ -652,7 +652,10 @@ func drainEventStreamUntilFinished(ch <-chan *apiv1.EventStreamResponse, timeout func recvEventStreamOne(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) (*apiv1.EventStreamResponse, bool) { select { - case ev := <-ch: + case ev, ok := <-ch: + if !ok { + return nil, true + } return ev, ev.GetExecutionFinished() != nil case <-time.After(timeout): return nil, true @@ -663,7 +666,10 @@ func drainEventStreamWithTimeout(ch <-chan *apiv1.EventStreamResponse, timeout t var out []*apiv1.EventStreamResponse for { select { - case ev := <-ch: + case ev, ok := <-ch: + if !ok { + return out + } out = append(out, ev) case <-time.After(timeout): return out From cb3aa3362ecf7ebcdf34afc50ed8bf5ed95cd824 Mon Sep 17 00:00:00 2001 From: jamesread Date: Wed, 11 Mar 2026 00:29:56 +0000 Subject: [PATCH 8/8] chore: fix potential panic in tests --- service/internal/api/api_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/service/internal/api/api_test.go b/service/internal/api/api_test.go index eaca331..86bd1ec 100644 --- a/service/internal/api/api_test.go +++ b/service/internal/api/api_test.go @@ -640,12 +640,12 @@ func drainEventStreamUntilFinished(ch <-chan *apiv1.EventStreamResponse, timeout deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { ev, finished := recvEventStreamOne(ch, 50*time.Millisecond) - if finished { - return out - } if ev != nil { out = append(out, ev) } + if finished { + return out + } } return out }