Skip to content

Commit

Permalink
Merge pull request #1013 from nextcloud-libraries/fix/adjust-filename…
Browse files Browse the repository at this point in the history
…-validation
  • Loading branch information
skjnldsv committed Jul 12, 2024
2 parents 4f1bb00 + cd80e84 commit 0f6b74f
Show file tree
Hide file tree
Showing 7 changed files with 344 additions and 62 deletions.
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

0 comments on commit 0f6b74f

Please sign in to comment.