Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
fix(OC): validate request token and move logic to one place
Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Jun 16, 2025
commit d332a22e7815a0eb25cf5093539d6c97c3e6f7fe
7 changes: 4 additions & 3 deletions __tests__/FixJSDOMEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import JSDOMEnvironment from 'jest-environment-jsdom'
import FixedJSDOMEnvironment from 'jest-fixed-jsdom'

// https://github.com/facebook/jest/blob/v29.4.3/website/versioned_docs/version-29.4/Configuration.md#testenvironment-string
export default class FixJSDOMEnvironment extends JSDOMEnvironment {
export default class FixJSDOMEnvironment extends FixedJSDOMEnvironment {

constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
constructor(...args: ConstructorParameters<typeof FixedJSDOMEnvironment>) {
super(...args)

// https://github.com/jsdom/jsdom/issues/3363
// 31 ad above switched to vitest and don't have that issue
// @ts-expect-error see JSDOMEnvironment
this.global.structuredClone = structuredClone
}

Expand Down
2 changes: 2 additions & 0 deletions __tests__/mock-window.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@
window.OC = { ...window.OC }
window.OCA = { ...window.OCA }
window.OCP = { ...window.OCP }

window._oc_webroot = ''
4 changes: 1 addition & 3 deletions core/src/OC/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,7 @@ import {
getPort,
getProtocol,
} from './host.js'
import {
getToken as getRequestToken,
} from './requesttoken.ts'
import { getRequestToken } from './requesttoken.ts'
import {
hideMenus,
registerMenu,
Expand Down
56 changes: 33 additions & 23 deletions core/src/OC/requesttoken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,46 @@
*/

import { emit } from '@nextcloud/event-bus'
import { generateUrl } from '@nextcloud/router'

/**
* @private
* @param {Document} global the document to read the initial value from
* @param {Function} emit the function to invoke for every new token
* @return {object}
* Get the current CSRF token.
*/
export const manageToken = (global, emit) => {
let token = global.getElementsByTagName('head')[0].getAttribute('data-requesttoken')

return {
getToken: () => token,
setToken: newToken => {
token = newToken

emit('csrf-token-update', {
token,
})
},
}
export function getRequestToken(): string {
return document.head.dataset.requesttoken!
}

const manageFromDocument = manageToken(document, emit)

/**
* @return {string}
* Set a new CSRF token (e.g. because of session refresh).
* This also emits an event bus event for the updated token.
*
* @param token - The new token
* @fires Error - If the passed token is not a potential valid token
*/
export const getToken = manageFromDocument.getToken
export function setRequestToken(token: string): void {
if (!token || typeof token !== 'string') {
throw new Error('Invalid CSRF token given', { cause: { token } })
}

document.head.dataset.requesttoken = token
emit('csrf-token-update', { token })
}

/**
* @param {string} newToken new token
* Fetch the request token from the API.
* This does also set it on the current context, see `setRequestToken`.
*
* @fires Error - If the request failed
*/
export const setToken = manageFromDocument.setToken
export async function fetchRequestToken(): Promise<string> {
const url = generateUrl('/csrftoken')

const response = await fetch(url)
if (!response.ok) {
throw new Error('Could not fetch CSRF token from API', { cause: response })
}

const { token } = await response.json()
setRequestToken(token)
return token
}
2 changes: 1 addition & 1 deletion core/src/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import 'strengthify/strengthify.css'
import OC from './OC/index.js'
import OCP from './OCP/index.js'
import OCA from './OCA/index.js'
import { getToken as getRequestToken } from './OC/requesttoken.ts'
import { getRequestToken } from './OC/requesttoken.ts'

const warnIfNotTesting = function() {
if (window.TESTING === undefined) {
Expand Down
4 changes: 2 additions & 2 deletions core/src/jquery/requesttoken.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@

import $ from 'jquery'

import { getToken } from '../OC/requesttoken.ts'
import { getRequestToken } from '../OC/requesttoken.ts'

$(document).on('ajaxSend', function(elm, xhr, settings) {
if (settings.crossDomain === false) {
xhr.setRequestHeader('requesttoken', getToken())
xhr.setRequestHeader('requesttoken', getRequestToken())
xhr.setRequestHeader('OCS-APIREQUEST', 'true')
}
})
53 changes: 0 additions & 53 deletions core/src/tests/OC/requesttoken.spec.js

This file was deleted.

147 changes: 147 additions & 0 deletions core/src/tests/OC/requesttoken.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'
import { fetchRequestToken, getRequestToken, setRequestToken } from '../../OC/requesttoken.ts'
import * as eventbus from '@nextcloud/event-bus'

jest.mock('@nextcloud/event-bus', () => ({ emit: jest.fn() }))

const server = setupServer()

describe('getRequestToken', () => {
it('can read the token from DOM', () => {
mockToken('tokenmock-123')
expect(getRequestToken()).toBe('tokenmock-123')
})

it('can handle missing token', () => {
mockToken(undefined)
expect(getRequestToken()).toBeUndefined()
})
})

describe('setRequestToken', () => {
beforeEach(() => {
jest.resetAllMocks()
})

it('does emit an event on change', () => {
setRequestToken('new-token')
expect(eventbus.emit).toBeCalledTimes(1)
expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
})

it('does set the new token to the DOM', () => {
setRequestToken('new-token')
expect(document.head.dataset.requesttoken).toBe('new-token')
})

it('does remember the new token', () => {
mockToken('old-token')
setRequestToken('new-token')
expect(getRequestToken()).toBe('new-token')
})

it('throws if the token is not a string', () => {
// @ts-expect-error mocking
expect(() => setRequestToken(123)).toThrowError('Invalid CSRF token given')
})

it('throws if the token is not valid', () => {
expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given')
})

it('does not emit an event if the token is not valid', () => {
expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given')
expect(eventbus.emit).not.toBeCalled()
})
})

describe('fetchRequestToken', () => {
const successfullCsrf = http.get('/index.php/csrftoken', () => {
return HttpResponse.json({ token: 'new-token' })
})
const forbiddenCsrf = http.get('/index.php/csrftoken', () => {
return HttpResponse.json([], { status: 403 })
})
const serverErrorCsrf = http.get('/index.php/csrftoken', () => {
return HttpResponse.json([], { status: 500 })
})
const networkErrorCsrf = http.get('/index.php/csrftoken', () => {
return new HttpResponse(null, { type: 'error' })
})

beforeAll(() => {
server.listen()
})

beforeEach(() => {
jest.resetAllMocks()
})

it('correctly parses response', async () => {
server.use(successfullCsrf)

mockToken('oldToken')
const token = await fetchRequestToken()
expect(token).toBe('new-token')
})

it('sets the token', async () => {
server.use(successfullCsrf)

mockToken('oldToken')
await fetchRequestToken()
expect(getRequestToken()).toBe('new-token')
})

it('does emit an event', async () => {
server.use(successfullCsrf)

await fetchRequestToken()
expect(eventbus.emit).toBeCalledTimes(1)
expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
})

it('handles 403 error due to invalid cookies', async () => {
server.use(forbiddenCsrf)

mockToken('oldToken')
await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API')
expect(getRequestToken()).toBe('oldToken')
})

it('handles server error', async () => {
server.use(serverErrorCsrf)

mockToken('oldToken')
await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API')
expect(getRequestToken()).toBe('oldToken')
})

it('handles network error', async () => {
server.use(networkErrorCsrf)

mockToken('oldToken')
await expect(() => fetchRequestToken()).rejects.toThrow()
expect(getRequestToken()).toBe('oldToken')
})
})

/**
* Mock the request token directly so we can test reading it.
*
* @param token - The CSRF token to mock
*/
function mockToken(token?: string) {
if (token === undefined) {
delete document.head.dataset.requesttoken
} else {
document.head.dataset.requesttoken = token
}
}
5 changes: 5 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ const config: Config = {
],

testEnvironment: './__tests__/FixJSDOMEnvironment.ts',
testEnvironmentOptions: {
// https://mswjs.io/docs/migrations/1.x-to-2.x#cannot-find-module-mswnode-jsdom
customExportConditions: [''],
},

preset: 'ts-jest/presets/js-with-ts',

roots: [
Expand Down
Loading