-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1013 from nextcloud-libraries/fix/adjust-filename…
…-validation
- Loading branch information
Showing
7 changed files
with
344 additions
and
62 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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\'"') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
Oops, something went wrong.