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

[stable29] fix(theming): Harden admin theming settings #50487

Merged
merged 3 commits into from
Jan 27, 2025
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
8 changes: 5 additions & 3 deletions apps/theming/lib/Controller/ThemingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,13 @@ public function updateAppMenu($setting, $value) {
}

/**
* Check that a string is a valid http/https url
* Check that a string is a valid http/https url.
* Also validates that there is no way for XSS through HTML
*/
private function isValidUrl(string $url): bool {
return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) &&
filter_var($url, FILTER_VALIDATE_URL) !== false);
return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://'))
&& filter_var($url, FILTER_VALIDATE_URL) !== false)
&& !str_contains($url, '"');
}

/**
Expand Down
39 changes: 35 additions & 4 deletions apps/theming/src/mixins/admin/TextValueMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,56 @@ export default {

data() {
return {
/** @type {string|boolean} */
localValue: this.value,
}
},

computed: {
valueToPost() {
if (this.type === 'url') {
// if this is already encoded just make sure there is no doublequote (HTML XSS)
// otherwise simply URL encode
return this.isUrlEncoded(this.localValue)
? this.localValue.replaceAll('"', '%22')
: encodeURI(this.localValue)
}
// Convert boolean to string as server expects string value
if (typeof this.localValue === 'boolean') {
return this.localValue ? 'yes' : 'no'
}
return this.localValue
},
},

methods: {
/**
* Check if URL is percent-encoded
* @param {string} url The URL to check
* @return {boolean}
*/
isUrlEncoded(url) {
try {
return decodeURI(url) !== url
} catch {
return false
}
},

async save() {
this.reset()
const url = generateUrl('/apps/theming/ajax/updateStylesheet')
// Convert boolean to string as server expects string value
const valueToPost = this.localValue === true ? 'yes' : this.localValue === false ? 'no' : this.localValue

try {
await axios.post(url, {
setting: this.name,
value: valueToPost,
value: this.valueToPost,
})
this.$emit('update:value', this.localValue)
this.handleSuccess()
} catch (e) {
this.errorMessage = e.response.data.data?.message
console.error('Failed to save changes', e)
this.errorMessage = e.response?.data.data?.message
}
},

Expand Down
19 changes: 16 additions & 3 deletions apps/theming/tests/Controller/ThemingControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,24 @@ public function testUpdateStylesheetSuccess($setting, $value, $message) {
}

public function dataUpdateStylesheetError() {
$urls = [
'url' => 'web address',
'imprintUrl' => 'legal notice address',
'privacyUrl' => 'privacy policy address',
];

$urlTests = [];
foreach ($urls as $urlKey => $urlName) {
// Check length limit
$urlTests[] = [$urlKey, 'http://example.com/' . str_repeat('a', 501), "The given {$urlName} is too long"];
// Check potential evil javascript
$urlTests[] = [$urlKey, 'javascript:alert(1)', "The given {$urlName} is not a valid URL"];
// Check XSS
$urlTests[] = [$urlKey, 'https://example.com/"><script/src="alert(\'1\')"><a/href/="', "The given {$urlName} is not a valid URL"];
}

return [
['name', str_repeat('a', 251), 'The given name is too long'],
['url', 'http://example.com/' . str_repeat('a', 501), 'The given web address is too long'],
['url', str_repeat('a', 501), 'The given web address is not a valid URL'],
['url', 'javascript:alert(1)', 'The given web address is not a valid URL'],
['slogan', str_repeat('a', 501), 'The given slogan is too long'],
['color', '0082C9', 'The given color is invalid'],
['color', '#0082Z9', 'The given color is invalid'],
Expand Down
143 changes: 143 additions & 0 deletions cypress/e2e/theming/admin-settings_urls.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { User } from '@nextcloud/cypress'

const admin = new User('admin', 'admin')

describe('Admin theming: Setting custom project URLs', function() {
this.beforeEach(() => {
// Just in case previous test failed
cy.resetAdminTheming()
cy.login(admin)
cy.visit('/settings/admin/theming')
cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming')
})

it('Setting the web link', () => {
cy.findByRole('textbox', { name: /web link/i })
.and('have.attr', 'type', 'url')
.as('input')
.scrollIntoView()
cy.get('@input')
.should('be.visible')
.type('{selectAll}http://example.com/path?query#fragment{enter}')

cy.wait('@updateTheming')

cy.logout()

cy.visit('/')
cy.contains('a', 'Nextcloud')
.should('be.visible')
.and('have.attr', 'href', 'http://example.com/path?query#fragment')
})

it('Setting the legal notice link', () => {
cy.findByRole('textbox', { name: /legal notice link/i })
.should('exist')
.and('have.attr', 'type', 'url')
.as('input')
.scrollIntoView()
cy.get('@input')
.type('http://example.com/path?query#fragment{enter}')

cy.wait('@updateTheming')

cy.logout()

cy.visit('/')
cy.contains('a', /legal notice/i)
.should('be.visible')
.and('have.attr', 'href', 'http://example.com/path?query#fragment')
})

it('Setting the privacy policy link', () => {
cy.findByRole('textbox', { name: /privacy policy link/i })
.should('exist')
.as('input')
.scrollIntoView()
cy.get('@input')
.should('have.attr', 'type', 'url')
.type('http://privacy.local/path?query#fragment{enter}')

cy.wait('@updateTheming')

cy.logout()

cy.visit('/')
cy.contains('a', /privacy policy/i)
.should('be.visible')
.and('have.attr', 'href', 'http://privacy.local/path?query#fragment')
})

})

describe('Admin theming: Web link corner cases', function() {
this.beforeEach(() => {
// Just in case previous test failed
cy.resetAdminTheming()
cy.login(admin)
cy.visit('/settings/admin/theming')
cy.intercept('POST', '**/apps/theming/ajax/updateStylesheet').as('updateTheming')
})

it('Already URL encoded', () => {
cy.findByRole('textbox', { name: /web link/i })
.and('have.attr', 'type', 'url')
.as('input')
.scrollIntoView()
cy.get('@input')
.should('be.visible')
.type('{selectAll}http://example.com/%22path%20with%20space%22{enter}')

cy.wait('@updateTheming')

cy.logout()

cy.visit('/')
cy.contains('a', 'Nextcloud')
.should('be.visible')
.and('have.attr', 'href', 'http://example.com/%22path%20with%20space%22')
})

it('URL with double quotes', () => {
cy.findByRole('textbox', { name: /web link/i })
.and('have.attr', 'type', 'url')
.as('input')
.scrollIntoView()
cy.get('@input')
.should('be.visible')
.type('{selectAll}http://example.com/"path"{enter}')

cy.wait('@updateTheming')

cy.logout()

cy.visit('/')
cy.contains('a', 'Nextcloud')
.should('be.visible')
.and('have.attr', 'href', 'http://example.com/%22path%22')
})

it('URL with double quotes and already encoded', () => {
cy.findByRole('textbox', { name: /web link/i })
.and('have.attr', 'type', 'url')
.as('input')
.scrollIntoView()
cy.get('@input')
.should('be.visible')
.type('{selectAll}http://example.com/"the%20path"{enter}')

cy.wait('@updateTheming')

cy.logout()

cy.visit('/')
cy.contains('a', 'Nextcloud')
.should('be.visible')
.and('have.attr', 'href', 'http://example.com/%22the%20path%22')
})

})
4 changes: 2 additions & 2 deletions dist/theming-admin-theming.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/theming-admin-theming.js.map

Large diffs are not rendered by default.

Loading