Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(filename): Improve filename validation to support Nextcloud 30 capabilities #1013

Merged
merged 1 commit into from
Jul 12, 2024
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
197 changes: 197 additions & 0 deletions __tests__/utils/filename-validation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later or LGPL-3.0-or-later
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { InvalidFilenameError, InvalidFilenameErrorReason, isFilenameValid, validateFilename } from '../../lib/index'

const nextcloudCapabilities = vi.hoisted(() => ({ getCapabilities: vi.fn(() => ({ files: {} })) }))
vi.mock('@nextcloud/capabilities', () => nextcloudCapabilities)

describe('isFilenameValid', () => {
beforeEach(() => {
vi.restoreAllMocks()
delete window._oc_config
})

it('works for valid filenames', async () => {
expect(isFilenameValid('foo.bar')).toBe(true)
})

it('works for invalid filenames', async () => {
expect(isFilenameValid('foo\\bar')).toBe(false)
})

it('does not catch any interal exceptions', async () => {
// invalid capability just to get an exception here
nextcloudCapabilities.getCapabilities.mockImplementationOnce(() => ({ files: { forbidden_filename_extensions: 3 } }))
expect(() => isFilenameValid('hello')).toThrowError(TypeError)
})
})

describe('validateFilename', () => {

beforeEach(() => {
vi.restoreAllMocks()
delete window._oc_config
})

it('works for valid filenames', async () => {
expect(() => validateFilename('foo.bar')).not.toThrow()
})

it('has fallback invalid characters', async () => {
expect(() => validateFilename('foo\\bar')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('foo/bar')).toThrowError(InvalidFilenameError)
})

it('has fallback invalid names', async () => {
expect(() => validateFilename('.htaccess')).toThrowError(InvalidFilenameError)
})

it('has fallback invalid extension', async () => {
expect(() => validateFilename('file.txt.part')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('file.txt.filepart')).toThrowError(InvalidFilenameError)
})

// Nextcloud 29
it('fallback fetching forbidden characters from oc config', async () => {
window._oc_config = { forbidden_filenames_characters: ['=', '?'] }
expect(() => validateFilename('foo.bar')).not.toThrow()
expect(() => validateFilename('foo=bar')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('foo?bar')).toThrowError(InvalidFilenameError)
})

// Nextcloud 30+
it('fetches forbidden characters from capabilities', async () => {
nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_characters: ['=', '?'] } }))
expect(() => validateFilename('foo')).not.toThrow()
expect(() => validateFilename('foo?')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('foo=bar')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('?foo')).toThrowError(InvalidFilenameError)
})

it('fetches forbidden extensions from capabilities', async () => {
nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_extensions: ['.txt', '.tar.gz'] } }))
expect(() => validateFilename('foo.md')).not.toThrow()
expect(() => validateFilename('foo.txt')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('foo.tar.gz')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('foo.tar.zstd')).not.toThrow()
})

it('fetches forbidden filenames from capabilities', async () => {
nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filenames: ['thumbs.db'] } }))
expect(() => validateFilename('thumbs.png')).not.toThrow()
expect(() => validateFilename('thumbs.db')).toThrowError(InvalidFilenameError)
})

it('fetches forbidden filename basenames from capabilities', async () => {
nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_basenames: ['com0'] } }))
expect(() => validateFilename('com1.txt')).not.toThrow()
expect(() => validateFilename('com0.txt')).toThrowError(InvalidFilenameError)
})

it('handles forbidden filenames case-insensitive', () => {
nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filenames: ['thumbs.db'] } }))
expect(() => validateFilename('thumbS.db')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('thumbs.DB')).toThrowError(InvalidFilenameError)
})

it('handles forbidden filename basenames case-insensitive', () => {
nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_basenames: ['com0'] } }))
expect(() => validateFilename('COM0')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('com0')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('com0.namespace')).toThrowError(InvalidFilenameError)
})

it('handles forbidden filename extensions case-insensitive', () => {
nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_extensions: ['.txt'] } }))
expect(() => validateFilename('file.TXT')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('FILE.txt')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('FiLe.TxT')).toThrowError(InvalidFilenameError)
})

it('handles hidden files correctly', () => {
nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_basenames: ['.hidden'], forbidden_filename_extensions: ['.txt'] } }))
// forbidden basename '.hidden'
expect(() => validateFilename('.hidden')).toThrowError(InvalidFilenameError)
expect(() => validateFilename('.hidden.png')).toThrowError(InvalidFilenameError)
// basename is .txt so not forbidden
expect(() => validateFilename('.txt')).not.toThrowError(InvalidFilenameError)
expect(() => validateFilename('.txt.png')).not.toThrowError(InvalidFilenameError)
// forbidden extension
expect(() => validateFilename('.other-hidden.txt')).toThrowError(InvalidFilenameError)
})

it('sets error properties correctly on invalid filename', async () => {
try {
validateFilename('.htaccess')
expect(true, 'should not be reached').toBeFalsy()
} catch (error) {
expect(error).toBeInstanceOf(InvalidFilenameError)
expect((error as InvalidFilenameError).reason).toBe(InvalidFilenameErrorReason.ReservedName)
expect((error as InvalidFilenameError).segment).toBe('.htaccess')
expect((error as InvalidFilenameError).filename).toBe('.htaccess')
}
})

it('sets error properties correctly on invalid extension', async () => {
try {
validateFilename('file.part')
expect(true, 'should not be reached').toBeFalsy()
} catch (error) {
expect(error).toBeInstanceOf(InvalidFilenameError)
expect((error as InvalidFilenameError).reason).toBe(InvalidFilenameErrorReason.Extension)
expect((error as InvalidFilenameError).segment).toBe('.part')
expect((error as InvalidFilenameError).filename).toBe('file.part')
}
})

it('sets error properties correctly on invalid character', async () => {
try {
validateFilename('file\\name')
expect(true, 'should not be reached').toBeFalsy()
} catch (error) {
expect(error).toBeInstanceOf(InvalidFilenameError)
expect((error as InvalidFilenameError).reason).toBe(InvalidFilenameErrorReason.Character)
expect((error as InvalidFilenameError).segment).toBe('\\')
expect((error as InvalidFilenameError).filename).toBe('file\\name')
}
})

it('sets error properties correctly on invalid basename', async () => {
nextcloudCapabilities.getCapabilities.mockImplementation(() => ({ files: { forbidden_filename_basenames: ['com0'] } }))
try {
validateFilename('com0.namespace')
expect(true, 'should not be reached').toBeFalsy()
} catch (error) {
expect(error).toBeInstanceOf(InvalidFilenameError)
expect((error as InvalidFilenameError).reason).toBe(InvalidFilenameErrorReason.ReservedName)
expect((error as InvalidFilenameError).segment).toBe('com0')
expect((error as InvalidFilenameError).filename).toBe('com0.namespace')
}
})
})

describe('InvalidFilenameError', () => {

it('sets the filename', () => {
const error = new InvalidFilenameError({ filename: 'file', segment: 'fi', reason: InvalidFilenameErrorReason.Extension })
expect(error.filename).toBe('file')
})

it('sets the segment', () => {
const error = new InvalidFilenameError({ filename: 'file', segment: 'fi', reason: InvalidFilenameErrorReason.Extension })
expect(error.segment).toBe('fi')
})

it('sets the reason', () => {
const error = new InvalidFilenameError({ filename: 'file', segment: 'fi', reason: InvalidFilenameErrorReason.Extension })
expect(error.reason).toBe(InvalidFilenameErrorReason.Extension)
})

it('sets the message', () => {
const error = new InvalidFilenameError({ filename: 'file', segment: 'fi', reason: InvalidFilenameErrorReason.Extension })
expect(error.message).toMatchInlineSnapshot('"Invalid extension \'fi\' in filename \'file\'"')
})
})
41 changes: 1 addition & 40 deletions __tests__/utils/filename.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,9 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later or LGPL-3.0-or-later
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { getUniqueName } from '../../lib/index'

describe('isFilenameValid', () => {
beforeEach(() => {
delete window._oc_config
vi.resetModules()
})

it('works for valid filenames', async () => {
const { isFilenameValid } = await import('../../lib/index')

expect(isFilenameValid('foo.bar')).toBe(true)
})

it('has fallback invalid characters', async () => {
const { isFilenameValid } = await import('../../lib/index')

expect(isFilenameValid('foo\\bar')).toBe(false)
expect(isFilenameValid('foo/bar')).toBe(false)
})

it('reads invalid characters from oc config', async () => {
window._oc_config = { forbidden_filenames_characters: ['=', '?'] }
const { isFilenameValid } = await import('../../lib/index')

expect(isFilenameValid('foo.bar')).toBe(true)
expect(isFilenameValid('foo=bar')).toBe(false)
expect(isFilenameValid('foo?bar')).toBe(false)
})

it('supports invalid filename regex', async () => {
window._oc_config = { forbidden_filenames_characters: ['/'], blacklist_files_regex: '\\.(part|filepart)$' }
const { isFilenameValid } = await import('../../lib/index')

expect(isFilenameValid('foo.bar')).toBe(true)
expect(isFilenameValid('foo.part')).toBe(false)
expect(isFilenameValid('foo.filepart')).toBe(false)
expect(isFilenameValid('.filepart')).toBe(false)
})
})

describe('getUniqueName', () => {
it('returns the same name if unique', () => {
const name = 'file.txt'
Expand Down
3 changes: 2 additions & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export { File, type IFile } from './files/file'
export { Folder, type IFolder } from './files/folder'
export { Node, NodeStatus, type INode } from './files/node'

export { isFilenameValid, getUniqueName } from './utils/filename'
export * from './utils/filename-validation'
export { getUniqueName } from './utils/filename'
export { formatFileSize, parseFileSize } from './utils/fileSize'
export { orderBy } from './utils/sorting'
export { sortNodes, FilesSortingMode, type FilesSortingOptions } from './utils/fileSorting'
Expand Down
130 changes: 130 additions & 0 deletions lib/utils/filename-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* SPDX-FileCopyrightText: Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later or LGPL-3.0-or-later
*/

import { getCapabilities } from '@nextcloud/capabilities'

interface NextcloudCapabilities extends Record<string, unknown> {
files: {
'bigfilechunking': boolean
// those are new in Nextcloud 30
'forbidden_filenames'?: string[]
'forbidden_filename_basenames'?: string[]
'forbidden_filename_characters'?: string[]
'forbidden_filename_extensions'?: string[]
}
}

export enum InvalidFilenameErrorReason {
ReservedName = 'reserved name',
Character = 'character',
Extension = 'extension',
}

interface InvalidFilenameErrorOptions {
/**
* The filename that was validated
*/
filename: string

/**
* Reason why the validation failed
*/
reason: InvalidFilenameErrorReason

/**
* Part of the filename that caused this error
*/
segment: string
}

export class InvalidFilenameError extends Error {

public constructor(options: InvalidFilenameErrorOptions) {
super(`Invalid ${options.reason} '${options.segment}' in filename '${options.filename}'`, { cause: options })
}

/**
* The filename that was validated
*/
public get filename() {
return (this.cause as InvalidFilenameErrorOptions).filename
}

/**
* Reason why the validation failed
*/
public get reason() {
return (this.cause as InvalidFilenameErrorOptions).reason
}

/**
* Part of the filename that caused this error
*/
public get segment() {
return (this.cause as InvalidFilenameErrorOptions).segment
}

}

/**
* Validate a given filename
* @param filename The filename to check
* @throws {InvalidFilenameError}
*/
export function validateFilename(filename: string): void {
const capabilities = (getCapabilities() as NextcloudCapabilities).files

// Handle forbidden characters
// This needs to be done first as the other checks are case insensitive!
const forbiddenCharacters = capabilities.forbidden_filename_characters ?? window._oc_config?.forbidden_filenames_characters ?? ['/', '\\']
for (const character of forbiddenCharacters) {
if (filename.includes(character)) {
throw new InvalidFilenameError({ segment: character, reason: InvalidFilenameErrorReason.Character, filename })
}
}

// everything else is case insensitive (the capabilities are returned lowercase)
filename = filename.toLocaleLowerCase()

// Handle forbidden filenames, on older Nextcloud versions without this capability it was hardcoded in the backend to '.htaccess'
const forbiddenFilenames = capabilities.forbidden_filenames ?? ['.htaccess']
if (forbiddenFilenames.includes(filename)) {
throw new InvalidFilenameError({ filename, segment: filename, reason: InvalidFilenameErrorReason.ReservedName })
}

// Handle forbidden basenames
const endOfBasename = filename.indexOf('.', 1)
const basename = filename.substring(0, endOfBasename === -1 ? undefined : endOfBasename)
const forbiddenFilenameBasenames = capabilities.forbidden_filename_basenames ?? []
if (forbiddenFilenameBasenames.includes(basename)) {
throw new InvalidFilenameError({ filename, segment: basename, reason: InvalidFilenameErrorReason.ReservedName })
}

// The legacy 'blacklist_files_regex' was hardcoded to the extension '.part' and '.filepart'
// So if the new (Nextcloud 30) capability is not awailable then we fallback to that
const forbiddenFilenameExtensions = capabilities.forbidden_filename_extensions ?? ['.part', '.filepart']
for (const extension of forbiddenFilenameExtensions) {
if (filename.length > extension.length && filename.endsWith(extension)) {
throw new InvalidFilenameError({ segment: extension, reason: InvalidFilenameErrorReason.Extension, filename })
}
}
}

/**
* Check the validity of a filename
* This is a convinient wrapper for `checkFilenameValidity` to only return a boolean for the valid
* @param filename Filename to check validity
*/
export function isFilenameValid(filename: string): boolean {
try {
validateFilename(filename)
return true
} catch (error) {
if (error instanceof InvalidFilenameError) {
return false
}
throw error
}
}
Loading
Loading