diff --git a/cypress/e2e/direct.spec.js b/cypress/e2e/direct.spec.js index b182045951..61f9e48cdb 100644 --- a/cypress/e2e/direct.spec.js +++ b/cypress/e2e/direct.spec.js @@ -103,6 +103,48 @@ describe('Direct editing (legacy)', function() { }) }) + describe('PostMessage origin security', function() { + it('rejects messages from an unexpected origin', function() { + createDirectEditingLink(randUser, fileId) + .then((token) => { + cy.nextcloudTestingAppConfigSet('richdocuments', 'uiDefaults-UIMode', 'classic') + cy.logout() + cy.visit(token, { + onBeforeLoad(win) { + cy.spy(win, 'postMessage').as('postMessage') + }, + }) + cy.waitForCollabora(false) + cy.waitForPostMessage('App_LoadingStatus', { Status: 'Document_Loaded' }) + + cy.window().then(win => { + cy.spy(win.console, 'warn').as('consoleWarn') + }) + cy.dispatchMessageFromOrigin('https://evil.example.com', { MessageId: 'Action_Save', Values: {} }) + cy.get('@consoleWarn').should('have.been.calledWith', + 'PostMessageService: rejected message from unexpected origin', + 'https://evil.example.com' + ) + }) + }) + + it('sends messages with the Collabora targetOrigin', function() { + createDirectEditingLink(randUser, fileId) + .then((token) => { + cy.nextcloudTestingAppConfigSet('richdocuments', 'uiDefaults-UIMode', 'classic') + cy.logout() + cy.visit(token) + cy.waitForCollabora(false) + cy.get('[data-cy="coolframe"]').then($iframe => { + const collaboraOrigin = $iframe[0].contentWindow.location.origin + cy.spy($iframe[0].contentWindow, 'postMessage').as('postMessage') + cy.dispatchMessageFromOrigin(collaboraOrigin, { MessageId: 'App_LoadingStatus', Values: { Status: 'Document_Loaded' } }) + cy.waitForPostMessage('Host_PostmessageReady', undefined, { targetOrigin: collaboraOrigin }) + }) + }) + }) + }) + it('Open a remotely shared file', () => { cy.createRandomUser().then(shareRecipient => { cy.login(randUser) diff --git a/cypress/e2e/open.spec.js b/cypress/e2e/open.spec.js index 5226d2bb3d..18f8289222 100644 --- a/cypress/e2e/open.spec.js +++ b/cypress/e2e/open.spec.js @@ -152,3 +152,56 @@ describe('Open PDF with richdocuments', () => { cy.closeDocument() }) }) + +describe('PostMessage origin security', function() { + let randUser + + before(function() { + cy.createRandomUser().then(user => { + randUser = user + cy.login(user) + cy.uploadFile(user, 'document.odt', 'application/vnd.oasis.opendocument.text', '/document.odt') + }) + }) + + beforeEach(function() { + cy.login(randUser) + }) + + it('rejects messages from an unexpected origin', function() { + cy.visit('/apps/files', { + onBeforeLoad(win) { + cy.spy(win, 'postMessage').as('postMessage') + }, + }) + cy.openFile('document.odt') + cy.waitForViewer() + cy.waitForCollabora() + cy.waitForPostMessage('App_LoadingStatus', { Status: 'Document_Loaded' }) + + cy.window().then(win => { + cy.spy(win.console, 'warn').as('consoleWarn') + }) + cy.dispatchMessageFromOrigin('https://evil.example.com', { MessageId: 'Action_Save', Values: {} }) + cy.get('@consoleWarn').should('have.been.calledWith', + 'PostMessageService: rejected message from unexpected origin', + 'https://evil.example.com' + ) + cy.closeDocument() + }) + + it('sends messages with the Collabora targetOrigin', function() { + cy.visit('/apps/files') + cy.openFile('document.odt') + cy.waitForViewer() + cy.waitForCollabora() + cy.get('[data-cy="coolframe"]').then($iframe => { + const collaboraOrigin = $iframe[0].contentWindow.location.origin + cy.spy($iframe[0].contentWindow, 'postMessage').as('postMessage') + cy.dispatchMessageFromOrigin(collaboraOrigin, { MessageId: 'App_LoadingStatus', Values: { Status: 'Document_Loaded' } }) + cy.waitForPostMessage('Host_PostmessageReady', undefined, { targetOrigin: collaboraOrigin }) + }) + + cy.closeDocument() + }) +}) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 12a1030e9e..ebe4e178b1 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -276,18 +276,51 @@ Cypress.Commands.add('waitForCollabora', (wrapped = false, federated = false) => return cy.get('@loleafletframe') }) -Cypress.Commands.add('waitForPostMessage', (messageId, values = undefined) => { +Cypress.Commands.add('waitForPostMessage', (messageId, expectedValues = undefined, options = {}) => { + const { targetOrigin } = options + const checkExpectedValues = (message, values) => { + for (const [key, value] of Object.entries(values)) { + if (!message.Values[key] || message.Values[key] !== value) { + return false + } + } + + return true + } + cy.get('@postMessage', { timeout: 20000 }).should(spy => { const calls = spy.getCalls() - const findMatchingCall = calls.find(call => call.args[0].indexOf('"MessageId":"' + messageId + '"') !== -1) - if (!findMatchingCall) { - return expect(findMatchingCall).to.not.be.undefined + const messagesMatchingId = [] + + // Find all messages matching the given ID + // We do it this way to avoid the shallow copy of Array.filter() + for (const call of calls) { + if (call.args[0].includes(`"MessageId":"${messageId}"`)) { + messagesMatchingId.push({ message: JSON.parse(call.args[0]), call }) + } } - if (!values) { - const object = JSON.parse(findMatchingCall.args[0]) - values.forEach(value => { - expect(object.Values).to.have.property(value, values[value]) - }) + + expect(messagesMatchingId.length).to.be.greaterThan(0) + + if (expectedValues) { + const messagesMatchingValues = [] + + for (const { message } of messagesMatchingId) { + if (checkExpectedValues(message, expectedValues)) { + messagesMatchingValues.push(message) + } + } + + expect(messagesMatchingValues.length).to.be.greaterThan(0) + } + + if (targetOrigin) { + for (const { call } of messagesMatchingId) { + expect(call.args[1]).to.equal( + targetOrigin, + `Expected targetOrigin for ${messageId} to be ${targetOrigin}`, + ) + } } }) }) @@ -323,3 +356,12 @@ Cypress.Commands.add('uploadSystemTemplate', () => { }, { force: true }) cy.get('#richdocuments-templates li').contains('systemtemplate.otp') }) + +Cypress.Commands.add('dispatchMessageFromOrigin', (origin, message) => { + cy.window().then(win => { + win.dispatchEvent(new win.MessageEvent('message', { + origin, + data: JSON.stringify(message), + })) + }) +}) diff --git a/src/document.js b/src/document.js index 6aa7682bbc..fe4e1120c0 100644 --- a/src/document.js +++ b/src/document.js @@ -554,7 +554,20 @@ const documentsMain = { initSession() { PostMessages.sendPostMessage('parent', 'loading') - documentsMain.urlsrc = Config.get('urlsrc') + const urlsrc = Config.get('urlsrc') + if (urlsrc) { + try { + PostMessages.setAllowedOrigins([ + new URL(urlsrc).origin, + window.location.origin, + ]) + PostMessages.setTargetOrigins({ + loolframe: new URL(urlsrc).origin, + parent: window.location.origin, + }) + } catch (e) {} + } + documentsMain.urlsrc = urlsrc documentsMain.fullPath = Config.get('path') documentsMain.token = Config.get('token') documentsMain.tokenTtl = Config.get('token_ttl') * 1000 @@ -600,7 +613,7 @@ const documentsMain = { documentsMain.UI.hideEditor() documentsMain.openLocally() - PostMessages.sendPostMessage('parent', 'close', '*') + PostMessages.sendPostMessage('parent', 'close') }, onCloseViewer() { diff --git a/src/services/postMessage.tsx b/src/services/postMessage.tsx index 2d6b67a829..d8a67f0f76 100644 --- a/src/services/postMessage.tsx +++ b/src/services/postMessage.tsx @@ -38,22 +38,37 @@ interface WindowCallbackHandler { (): Window} export default class PostMessageService { private readonly targets: {[name: string]: (Window|WindowCallbackHandler)}; private postMessageHandlers: Function[] = []; + private allowedOrigins: string[] = []; + private targetOrigins: {[name: string]: string} = {}; constructor(targets: {[name: string]: (Window|WindowCallbackHandler)}) { this.targets = targets window.addEventListener('message', (event: {source: MessageEventSource, data: any, origin: string}) => { + if (this.allowedOrigins.length > 0 && !this.allowedOrigins.includes(event.origin)) { + console.warn('PostMessageService: rejected message from unexpected origin', event.origin) + return + } this.handlePostMessage(event.data) }, false) } - sendPostMessage(target: string, message: any, targetOrigin: string = '*') { + setAllowedOrigins(origins: string[]): void { + this.allowedOrigins = origins + } + + setTargetOrigins(origins: {[name: string]: string}): void { + this.targetOrigins = origins + } + + sendPostMessage(target: string, message: any, targetOrigin?: string) { let targetElement: Window; if (typeof this.targets[target] === 'function') { targetElement = (this.targets[target] as WindowCallbackHandler)() } else { targetElement = this.targets[target] as Window } - targetElement.postMessage(message, targetOrigin) + const origin = targetOrigin ?? this.targetOrigins[target] ?? '*' + targetElement.postMessage(message, origin) console.debug('PostMessageService.sendPostMessage', target, message) } diff --git a/src/view/Office.vue b/src/view/Office.vue index 48347311a3..418bf094a8 100644 --- a/src/view/Office.vue +++ b/src/view/Office.vue @@ -323,6 +323,12 @@ export default { }) if (data.federatedUrl) { + try { + this.postMessage.setAllowedOrigins([new URL(data.federatedUrl).origin, window.location.origin]) + this.postMessage.setTargetOrigins({ FRAME_DOCUMENT: new URL(data.federatedUrl).origin }) + } catch (e) { + console.warn('[richdocuments] Could not derive origin from federatedUrl', e) + } this.$set(this.formData, 'action', data.federatedUrl) this.$nextTick(() => this.$refs.form.submit()) this.loading = LOADING_STATE.DOCUMENT_READY @@ -330,6 +336,12 @@ export default { } Config.update('urlsrc', data.urlSrc) + try { + this.postMessage.setAllowedOrigins([new URL(data.urlSrc).origin, window.location.origin]) + this.postMessage.setTargetOrigins({ FRAME_DOCUMENT: new URL(data.urlSrc).origin }) + } catch (e) { + console.warn('[richdocuments] Could not derive Collabora origin from urlsrc', e) + } Config.update('wopi_callback_url', loadState('richdocuments', 'wopi_callback_url', '')) const forceReadOnly = this.isEmbedded && !this.hasWidgetEditingEnabled