Skip to content
This repository was archived by the owner on Jul 14, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/bugfix-validate-psec-url.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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"
></iframe>
Expand All @@ -24,6 +25,7 @@ import { useGettext } from 'vue3-gettext'
const props = defineProps<{
modal: Modal
publicLink: string
serverUrl: string
}>()

const iframeRef = ref<HTMLIFrameElement>()
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FileAction>(() => ({
name: 'open-password-protected-folder',
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
})
Expand All @@ -16,17 +18,34 @@ 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,<script>alert(1)</script>', '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 {
mocks,
wrapper: shallowMount(FolderViewModal, {
props: {
modal: mock<Modal>(),
publicLink: 'https://example.org/public-link'
publicLink,
serverUrl
},
global: {
plugins: defaultPlugins(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,35 @@ describe('createFileHandler', () => {
})
})

it.each(['javascript:alert(1)', 'data:text/html,<script>alert(1)</script>', '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<typeof addLink>).mockResolvedValue(
mock<LinkShare>({ 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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<Resource>()],
space: mock<SpaceResource>()
})
).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,<script>alert(1)</script>', '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<Resource>()],
space: mock<SpaceResource>()
})
).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<typeof useOpenFolderAction>,
mocks: ReturnType<typeof defaultComponentMocks>
) => 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)
},
Expand Down
Loading