From 955fae118bc05624b3cd2974618c1b285e6a197b Mon Sep 17 00:00:00 2001 From: Matteo Date: Tue, 30 Jun 2026 14:31:07 +0200 Subject: [PATCH] fix(password-protected-folders): [OCISDEV-1016] validate .psec URL before opening --- .../unreleased/bugfix-validate-psec-url.md | 7 +++ .../src/components/FolderViewModal.vue | 8 +++ .../src/composables/useCreateFileHandler.ts | 12 ++++ .../src/composables/useOpenFolderAction.ts | 25 +++++++- .../unit/components/FolderViewModal.spec.ts | 23 +++++++- .../composables/useCreateFileHandler.spec.ts | 29 ++++++++++ .../composables/useOpenFolderAction.spec.ts | 58 ++++++++++++++++--- 7 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 changelog/unreleased/bugfix-validate-psec-url.md diff --git a/changelog/unreleased/bugfix-validate-psec-url.md b/changelog/unreleased/bugfix-validate-psec-url.md new file mode 100644 index 00000000000..f734e13529a --- /dev/null +++ b/changelog/unreleased/bugfix-validate-psec-url.md @@ -0,0 +1,7 @@ +Bugfix: Validate URL before opening password-protected folder + +We've fixed an issue where the URL stored inside a `.psec` file was not validated before +being used as the source of the folder view iframe. The URL is now checked to ensure it +uses a safe scheme and points to the configured ownCloud server. + +https://github.com/owncloud/web/pull/13924 diff --git a/packages/web-app-password-protected-folders/src/components/FolderViewModal.vue b/packages/web-app-password-protected-folders/src/components/FolderViewModal.vue index 7a9e977181a..428646f23c6 100644 --- a/packages/web-app-password-protected-folders/src/components/FolderViewModal.vue +++ b/packages/web-app-password-protected-folders/src/components/FolderViewModal.vue @@ -8,6 +8,7 @@ class="oc-width-1-1 oc-height-1-1" :title="iframeTitle" :src="iframeUrl.href" + sandbox="allow-scripts allow-forms allow-same-origin allow-popups" tabindex="0" @load="onLoad" > @@ -24,6 +25,7 @@ import { useGettext } from 'vue3-gettext' const props = defineProps<{ modal: Modal publicLink: string + serverUrl: string }>() const iframeRef = ref() @@ -33,6 +35,12 @@ const { current } = useGettext() const iframeTitle = themeStore.currentTheme.common?.name const iframeUrl = new URL(props.publicLink) +if (!['https:', 'http:'].includes(iframeUrl.protocol)) { + throw new Error('Invalid URL scheme for iframe') +} +if (iframeUrl.origin !== new URL(props.serverUrl).origin) { + throw new Error('URL does not belong to this server') +} iframeUrl.searchParams.append('hide-logo', 'true') iframeUrl.searchParams.append('hide-app-switcher', 'true') iframeUrl.searchParams.append('hide-account-menu', 'true') diff --git a/packages/web-app-password-protected-folders/src/composables/useCreateFileHandler.ts b/packages/web-app-password-protected-folders/src/composables/useCreateFileHandler.ts index 1cee24c72d3..c799a3c48c0 100644 --- a/packages/web-app-password-protected-folders/src/composables/useCreateFileHandler.ts +++ b/packages/web-app-password-protected-folders/src/composables/useCreateFileHandler.ts @@ -1,11 +1,13 @@ import { Resource, SpaceResource, urlJoin } from '@ownclouders/web-client' import { SharingLinkType } from '@ownclouders/web-client/graph/generated' import { useClientService, useResourcesStore, useSharesStore } from '@ownclouders/web-pkg' +import { useGettext } from 'vue3-gettext' export const useCreateFileHandler = () => { const clientService = useClientService() const { upsertResource } = useResourcesStore() const { addLink } = useSharesStore() + const { $pgettext } = useGettext() const createFileHandler = async ({ fileName, @@ -52,6 +54,16 @@ export const useCreateFileHandler = () => { options: { password, type } }) + const shareUrl = new URL(share.webUrl) + if (!['https:', 'http:'].includes(shareUrl.protocol)) { + throw new Error( + $pgettext( + 'Error shown when creating a password-protected folder fails because the generated share link has an unexpected format.', + 'The folder could not be created because the share link is invalid.' + ) + ) + } + const path = urlJoin(currentFolder.path, fileName + '.psec') const file = await clientService.webdav.putFileContents(currentSpace, { diff --git a/packages/web-app-password-protected-folders/src/composables/useOpenFolderAction.ts b/packages/web-app-password-protected-folders/src/composables/useOpenFolderAction.ts index 0c8b3c79ca4..282d0fb9139 100644 --- a/packages/web-app-password-protected-folders/src/composables/useOpenFolderAction.ts +++ b/packages/web-app-password-protected-folders/src/composables/useOpenFolderAction.ts @@ -1,12 +1,13 @@ -import { FileAction, useClientService, useModals } from '@ownclouders/web-pkg' +import { FileAction, useClientService, useConfigStore, useModals } from '@ownclouders/web-pkg' import { computed } from 'vue' import { useGettext } from 'vue3-gettext' import FolderViewModal from '../components/FolderViewModal.vue' export const useOpenFolderAction = () => { - const { $gettext } = useGettext() + const { $gettext, $pgettext } = useGettext() const { dispatchModal } = useModals() const clientService = useClientService() + const configStore = useConfigStore() const action = computed(() => ({ name: 'open-password-protected-folder', @@ -16,13 +17,31 @@ export const useOpenFolderAction = () => { const [file] = resources const { body } = await clientService.webdav.getFileContents(space, file) const publicLink = atob(body) + const publicLinkUrl = new URL(publicLink) + if (!['https:', 'http:'].includes(publicLinkUrl.protocol)) { + throw new Error( + $pgettext( + 'Error shown when opening a password-protected folder fails because the stored link has an unexpected format.', + 'This folder cannot be opened because the link it contains is invalid.' + ) + ) + } + if (publicLinkUrl.origin !== new URL(configStore.serverUrl).origin) { + throw new Error( + $pgettext( + 'Error shown when opening a password-protected folder fails because the stored link points to a different server.', + 'This folder cannot be opened because the link it contains does not point to this server.' + ) + ) + } dispatchModal({ title: resources.at(0).name, elementClass: 'folder-view-modal', customComponent: FolderViewModal, customComponentAttrs: () => ({ - publicLink + publicLink, + serverUrl: configStore.serverUrl }), hideConfirmButton: true, cancelText: $gettext('Close folder') diff --git a/packages/web-app-password-protected-folders/tests/unit/components/FolderViewModal.spec.ts b/packages/web-app-password-protected-folders/tests/unit/components/FolderViewModal.spec.ts index 0e254570eb6..0d16d4df35a 100644 --- a/packages/web-app-password-protected-folders/tests/unit/components/FolderViewModal.spec.ts +++ b/packages/web-app-password-protected-folders/tests/unit/components/FolderViewModal.spec.ts @@ -3,6 +3,8 @@ import FolderViewModal from '../../../src/components/FolderViewModal.vue' import { Modal } from '@ownclouders/web-pkg' import { mock } from 'vitest-mock-extended' +const SERVER_URL = 'https://example.org/' + const SELECTORS = Object.freeze({ iframe: '#iframe-folder-view' }) @@ -16,9 +18,25 @@ describe('FolderViewModal', () => { 'https://example.org/public-link?hide-logo=true&hide-app-switcher=true&hide-account-menu=true&hide-navigation=true&lang=en' ) }) + + it.each(['javascript:alert(1)', 'data:text/html,', 'blob:fake'])( + 'should throw when publicLink has a non-http(s) scheme: %s', + (invalidUrl) => { + expect(() => getWrapper({ publicLink: invalidUrl })).toThrow('Invalid URL scheme for iframe') + } + ) + + it('should throw when publicLink points to a different server', () => { + expect(() => getWrapper({ publicLink: 'https://other.example.com/public-link' })).toThrow( + 'URL does not belong to this server' + ) + }) }) -function getWrapper() { +function getWrapper({ + publicLink = 'https://example.org/public-link', + serverUrl = SERVER_URL +} = {}) { const mocks = defaultComponentMocks() return { @@ -26,7 +44,8 @@ function getWrapper() { wrapper: shallowMount(FolderViewModal, { props: { modal: mock(), - publicLink: 'https://example.org/public-link' + publicLink, + serverUrl }, global: { plugins: defaultPlugins(), diff --git a/packages/web-app-password-protected-folders/tests/unit/composables/useCreateFileHandler.spec.ts b/packages/web-app-password-protected-folders/tests/unit/composables/useCreateFileHandler.spec.ts index e26be1a34b9..d5ef52b17a5 100644 --- a/packages/web-app-password-protected-folders/tests/unit/composables/useCreateFileHandler.spec.ts +++ b/packages/web-app-password-protected-folders/tests/unit/composables/useCreateFileHandler.spec.ts @@ -76,6 +76,35 @@ describe('createFileHandler', () => { }) }) + it.each(['javascript:alert(1)', 'data:text/html,', 'blob:fake'])( + 'should throw and delete the folder when share URL has a non-http(s) scheme: %s', + (invalidUrl) => { + getWrapper({ + async setup(instance, mocks) { + const { addLink } = useSharesStore() + + ;(addLink as MockedFunction).mockResolvedValue( + mock({ webUrl: invalidUrl }) + ) + + await expect( + instance.createFileHandler({ + fileName: 'protected', + personalSpace, + currentFolder, + currentSpace: space, + password: 'Pass$123', + type: SharingLinkType.Edit + }) + ).rejects.toThrow('The folder could not be created because the share link is invalid.') + expect(mocks.$clientService.webdav.deleteFile).toHaveBeenCalledWith(personalSpace, { + path: '/.PasswordProtectedFolders/projects/With psec file/current/folder/protected' + }) + } + }) + } + ) + it('should delete the folder if the file creation fails', () => { getWrapper({ async setup(instance, mocks) { diff --git a/packages/web-app-password-protected-folders/tests/unit/composables/useOpenFolderAction.spec.ts b/packages/web-app-password-protected-folders/tests/unit/composables/useOpenFolderAction.spec.ts index 77f4d4d1474..5d788cc0de2 100644 --- a/packages/web-app-password-protected-folders/tests/unit/composables/useOpenFolderAction.spec.ts +++ b/packages/web-app-password-protected-folders/tests/unit/composables/useOpenFolderAction.spec.ts @@ -1,12 +1,18 @@ -import { defaultComponentMocks, getComposableWrapper } from '@ownclouders/web-test-helpers' +import { + defaultComponentMocks, + getComposableWrapper, + writable +} from '@ownclouders/web-test-helpers' import { useOpenFolderAction } from '../../../src/composables/useOpenFolderAction' import { unref } from 'vue' import { mock } from 'vitest-mock-extended' import { Resource, SpaceResource } from '@ownclouders/web-client' -import { useModals } from '@ownclouders/web-pkg' +import { useConfigStore, useModals } from '@ownclouders/web-pkg' import { MockedFunction } from 'vitest' import FolderViewModal from '../../../src/components/FolderViewModal.vue' +const SERVER_URL = 'https://example.org/' + describe('openFolderAction', () => { it('should open a modal with the public link', () => { getWrapper({ @@ -26,28 +32,66 @@ describe('openFolderAction', () => { expect(dispatchModal).toHaveBeenCalledWith( expect.objectContaining({ customComponent: FolderViewModal }) ) - expect(attrs).toStrictEqual({ publicLink: 'https://example.org/public-link' }) + expect(attrs).toStrictEqual({ + publicLink: 'https://example.org/public-link', + serverUrl: SERVER_URL + }) } }) }) + + it('should throw when .psec file URL points to a different server', () => { + getWrapper({ + body: btoa('https://other.example.com/public-link'), + async setup(instance) { + await expect( + unref(instance).handler({ + resources: [mock()], + space: mock() + }) + ).rejects.toThrow( + 'This folder cannot be opened because the link it contains does not point to this server.' + ) + } + }) + }) + + it.each(['javascript:alert(1)', 'data:text/html,', 'blob:fake'])( + 'should throw when .psec file contains a non-http(s) URL: %s', + (invalidUrl) => { + getWrapper({ + body: btoa(invalidUrl), + async setup(instance) { + await expect( + unref(instance).handler({ + resources: [mock()], + space: mock() + }) + ).rejects.toThrow('This folder cannot be opened because the link it contains is invalid.') + } + }) + } + ) }) function getWrapper({ - setup + setup, + body = btoa('https://example.org/public-link') }: { setup: ( instance: ReturnType, mocks: ReturnType ) => void + body?: string }) { const mocks = defaultComponentMocks() - mocks.$clientService.webdav.getFileContents.mockResolvedValue({ - body: btoa('https://example.org/public-link') - }) + mocks.$clientService.webdav.getFileContents.mockResolvedValue({ body }) return { wrapper: getComposableWrapper( () => { + const configStore = useConfigStore() + writable(configStore).serverUrl = SERVER_URL const instance = useOpenFolderAction() setup(instance, mocks) },