From 7051aad5995ec771314128437d4ce7d525de0d83 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 1 Mar 2026 23:17:44 +0000 Subject: [PATCH 1/2] feat: Clickable links in outout (#900) --- frontend/js/OutputTerminal.js | 14 +++- frontend/package-lock.json | 7 ++ frontend/package.json | 3 +- .../tests/xtermLinkHandling/config.yaml | 13 ++++ .../xtermLinkHandling/xtermLinkHandling.mjs | 69 +++++++++++++++++++ 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 integration-tests/tests/xtermLinkHandling/config.yaml create mode 100644 integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs diff --git a/frontend/js/OutputTerminal.js b/frontend/js/OutputTerminal.js index 1526318..ebda77b 100644 --- a/frontend/js/OutputTerminal.js +++ b/frontend/js/OutputTerminal.js @@ -1,5 +1,6 @@ import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' import { Mutex } from './Mutex.js' /** @@ -18,13 +19,24 @@ export class OutputTerminal { constructor (executionTrackingId) { this.executionTrackingId = executionTrackingId this.writeMutex = new Mutex() + const linkHandler = { + activate (event, text, _range) { + event.preventDefault() + window.open(text, '_blank') + } + } + this.terminal = new Terminal({ - convertEol: true + convertEol: true, + linkHandler }) const fitAddon = new FitAddon() this.terminal.loadAddon(fitAddon) this.terminal.fit = fitAddon + + this.terminal.loadAddon(new WebLinksAddon((event, uri) => linkHandler.activate(event, uri))) + this.linkHandlerConfigured = true } async write (out, then) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3bc98c8..47b2ac6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@hugeicons/vue": "^1.0.4", "@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", @@ -1570,6 +1571,12 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a64d473..a08f799 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,13 +28,14 @@ "@hugeicons/vue": "^1.0.4", "@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", "standard": "^17.1.2", "unplugin-vue-components": "^31.0.0", "vite": "^7.3.1", - "vue": "^3.5.29", + "vue": "^3.5.29", "vue-i18n": "^11.2.8", "vue-router": "^5.0.3" } diff --git a/integration-tests/tests/xtermLinkHandling/config.yaml b/integration-tests/tests/xtermLinkHandling/config.yaml new file mode 100644 index 0000000..f70848d --- /dev/null +++ b/integration-tests/tests/xtermLinkHandling/config.yaml @@ -0,0 +1,13 @@ +# +# Integration Test Config: xterm link handling +# + +listenAddressSingleHTTPFrontend: 0.0.0.0:1337 + +logLevel: "DEBUG" +checkForUpdates: false + +actions: + - title: Echo URL + shell: echo "See https://example.com for more info" + popupOnStart: execution-dialog-stdout-only diff --git a/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs b/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs new file mode 100644 index 0000000..849f5a0 --- /dev/null +++ b/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs @@ -0,0 +1,69 @@ +import { describe, it, before, after } from 'mocha' +import { expect } from 'chai' +import { By, Condition } from 'selenium-webdriver' +import { + getRootAndWait, + takeScreenshotOnFailure, + getTerminalBuffer, +} from '../../lib/elements.js' + +describe('config: xtermLinkHandling', function () { + before(async function () { + await runner.start('xtermLinkHandling') + }) + + after(async () => { + await runner.stop() + }) + + afterEach(function () { + takeScreenshotOnFailure(this.currentTest, webdriver) + }) + + it('xterm output shows URL and link handling is configured', async function () { + await getRootAndWait() + + await webdriver.wait(new Condition('wait for Echo URL button', async () => { + const btns = await webdriver.findElements(By.css('[title="Echo URL"]')) + return btns.length === 1 + }), 10000) + + const echoUrlButton = await webdriver.findElement(By.css('[title="Echo URL"]')) + await echoUrlButton.click() + + await webdriver.wait(new Condition('wait for execution view', async () => { + const url = await webdriver.getCurrentUrl() + return url.includes('/logs/') && !url.endsWith('/logs') + }), 10000) + + await webdriver.wait(new Condition('wait for execution status', async () => { + const statusElements = await webdriver.findElements(By.id('execution-dialog-status')) + return statusElements.length > 0 + }), 5000) + + await webdriver.wait(new Condition('wait for execution to finish', async () => { + try { + const statusElement = await webdriver.findElement(By.id('execution-dialog-status')) + const statusText = await statusElement.getText() + return !statusText.includes('Executing') + } catch (e) { + return false + } + }), 5000) + + await webdriver.sleep(500) + + const bufferText = await getTerminalBuffer() + expect(bufferText).to.not.be.null + expect(bufferText).to.include('https://example.com') + + const linkHandlerSet = await webdriver.executeScript(` + try { + return !!(window.terminal && window.terminal.linkHandlerConfigured === true) + } catch (e) { + return false + } + `) + expect(linkHandlerSet).to.equal(true) + }) +}) From f044d90d5525c4c8e3f421b32ed7eff771c22d36 Mon Sep 17 00:00:00 2001 From: jamesread Date: Sun, 1 Mar 2026 23:52:25 +0000 Subject: [PATCH 2/2] security: Remote crash in OAuth2 GHSA-45m3-398w-m2m9 Thanks @kule500 for the responsible disclosure. CVE to follow. --- .../auth/otoauth2/restapi_auth_oauth2.go | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/service/internal/auth/otoauth2/restapi_auth_oauth2.go b/service/internal/auth/otoauth2/restapi_auth_oauth2.go index 5266124..a84e1f6 100644 --- a/service/internal/auth/otoauth2/restapi_auth_oauth2.go +++ b/service/internal/auth/otoauth2/restapi_auth_oauth2.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "os" + "sync" "time" authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic" @@ -21,6 +22,7 @@ import ( type OAuth2Handler struct { cfg *config.Config + mu sync.RWMutex registeredStates map[string]*oauth2State registeredProviders map[string]*oauth2.Config } @@ -144,11 +146,13 @@ func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) return } + h.mu.Lock() h.registeredStates[state] = &oauth2State{ providerConfig: provider, providerName: providerName, Username: "", } + h.mu.Unlock() h.setOAuthCallbackCookie(w, r, "olivetin-sid-oauth", state) @@ -177,7 +181,9 @@ func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http. return nil, state, false } + h.mu.RLock() registeredState, ok := h.registeredStates[state] + h.mu.RUnlock() if !ok { log.Errorf("State not found in server: %v", state) http.Error(w, "State not found in server", http.StatusBadRequest) @@ -287,8 +293,10 @@ func (h *OAuth2Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Reque userInfoClient := h.createUserInfoClient(ctx, registeredState.providerConfig, tok, clientSettings) userinfo := getUserInfo(h.cfg, userInfoClient, providerConfig) + h.mu.Lock() h.registeredStates[state].Username = userinfo.Username h.registeredStates[state].Usergroup = h.computeUsergroup(userinfo, providerConfig) + h.mu.Unlock() http.Redirect(w, r, "/", http.StatusFound) } @@ -366,34 +374,36 @@ func getDataField(data map[string]any, field string) string { return stringVal } +func (h *OAuth2Handler) lookupOAuth2UserByState(state string) (*authTypes.AuthenticatedUser, bool) { + h.mu.RLock() + serverState, found := h.registeredStates[state] + if !found { + h.mu.RUnlock() + return nil, false + } + user := &authTypes.AuthenticatedUser{ + Username: serverState.Username, + UsergroupLine: serverState.Usergroup, + Provider: "oauth2", + SID: state, + } + h.mu.RUnlock() + return user, true +} + func (h *OAuth2Handler) CheckUserFromOAuth2Cookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser { cookie, err := context.Request.Cookie("olivetin-sid-oauth") - - user := &authTypes.AuthenticatedUser{} - - if err != nil { + if err != nil || cookie.Value == "" { return nil } - if cookie.Value == "" { - return nil - } - - serverState, found := h.registeredStates[cookie.Value] - + user, found := h.lookupOAuth2UserByState(cookie.Value) if !found { log.WithFields(log.Fields{ "sid": cookie.Value, "provider": "oauth2", }).Warnf("Stale session") - return nil } - - user.Username = serverState.Username - user.UsergroupLine = serverState.Usergroup - user.Provider = "oauth2" - user.SID = cookie.Value - return user }