diff --git a/com.woltlab.wcf/option.xml b/com.woltlab.wcf/option.xml index 015fb45b044..c17dc84e241 100644 --- a/com.woltlab.wcf/option.xml +++ b/com.woltlab.wcf/option.xml @@ -285,6 +285,11 @@ boolean 0 + + module.system + boolean + 0 + module.content boolean diff --git a/com.woltlab.wcf/templates/footer.tpl b/com.woltlab.wcf/templates/footer.tpl index b82160dd9d6..499d7b0fa5c 100644 --- a/com.woltlab.wcf/templates/footer.tpl +++ b/com.woltlab.wcf/templates/footer.tpl @@ -70,6 +70,13 @@ {event name='footer'} +{* Evaluated at the end of the page, because content and boxes may request a check after the head was rendered. *} +{if $__wcf->getBackgroundQueueHandler()->hasPendingCheck()} + +{/if} + {event name='pageFooterStickyNotice'} diff --git a/com.woltlab.wcf/templates/permissionDenied.tpl b/com.woltlab.wcf/templates/permissionDenied.tpl index 2cba203f7e1..f9e48170353 100644 --- a/com.woltlab.wcf/templates/permissionDenied.tpl +++ b/com.woltlab.wcf/templates/permissionDenied.tpl @@ -1,20 +1,19 @@ {capture assign='pageTitle'}{lang}wcf.page.error.permissionDenied.title{/lang}{/capture} {capture assign='contentTitle'}{lang}wcf.page.error.permissionDenied.title{/lang}{/capture} -{if !$isFirstVisit} - {capture assign='contentHeaderNavigation'} - {icon name='arrow-left'} {lang}wcf.page.error.backward{/lang} - {/capture} - - -{/if} +{capture assign='contentHeaderNavigation'} + {icon name='arrow-left'} {lang}wcf.page.error.backward{/lang} +{/capture} + + {include file='header' __disableAds=true} diff --git a/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl b/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl index bb3ef01b4c3..136b56ff224 100644 --- a/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl +++ b/com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl @@ -1,18 +1,21 @@ {unsafe:$fileProcessorHtmlElement} +{if $uploaderToken !== null} + +{/if} {assign var="files" value=$field->getFiles()} {if $field->isBigPreview()} {if $field->getValue()} {assign var="file" value=$files|reset} - {unsafe:$file->toHtmlElement()} + {unsafe:$file->toHtmlElement(null, $uploaderToken)} {/if} {else} {foreach from=$files item=file} - {unsafe:$file->toHtmlElement()} + {unsafe:$file->toHtmlElement(null, $uploaderToken)} {/foreach} diff --git a/phpstan-ambient.neon b/phpstan-ambient.neon index 2bafe42f632..f2bd985b693 100644 --- a/phpstan-ambient.neon +++ b/phpstan-ambient.neon @@ -24,6 +24,7 @@ parameters: - WCF_UUID - WOLTLAB_BRANDING - VISITOR_USE_TINY_BUILD + - VISITOR_ON_DEMAND_SESSION - ENABLE_DEBUG_MODE - ENABLE_BENCHMARK - LOG_IP_ADDRESS diff --git a/ts/WoltLabSuite/Core/Ajax/Request.ts b/ts/WoltLabSuite/Core/Ajax/Request.ts index 2d4725abcaf..24b80a97623 100644 --- a/ts/WoltLabSuite/Core/Ajax/Request.ts +++ b/ts/WoltLabSuite/Core/Ajax/Request.ts @@ -19,6 +19,18 @@ import { escapeHTML } from "../StringUtil"; let _didInit = false; let _ignoreAllErrors = false; +/** + * A guest's token changes once their session is started, which can happen after + * the URL of a reused request was built. + */ +function withCurrentXsrfToken(url: string): string { + if (!url.startsWith(window.WSC_API_URL)) { + return url; + } + + return url.replace(/([?&])t=[^]*/, `$1t=${encodeURIComponent(Core.getXsrfToken())}`); +} + /** * @constructor */ @@ -115,7 +127,7 @@ class AjaxRequest { } this._xhr = new XMLHttpRequest(); - this._xhr.open(this._options.type!, this._options.url!, true); + this._xhr.open(this._options.type!, withCurrentXsrfToken(this._options.url!), true); if (this._options.contentType) { this._xhr.setRequestHeader("Content-Type", this._options.contentType); } diff --git a/ts/WoltLabSuite/Core/Api/Files/DeleteFile.ts b/ts/WoltLabSuite/Core/Api/Files/DeleteFile.ts index e4a9beb259b..f2c45d6ef38 100644 --- a/ts/WoltLabSuite/Core/Api/Files/DeleteFile.ts +++ b/ts/WoltLabSuite/Core/Api/Files/DeleteFile.ts @@ -1,9 +1,14 @@ import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; -export async function deleteFile(fileId: number): Promise> { +export async function deleteFile(fileId: number, uploaderToken?: string): Promise> { + const url = new URL(`${window.WSC_RPC_API_URL}core/files/${fileId}`); + if (uploaderToken !== undefined) { + url.searchParams.set("uploaderToken", uploaderToken); + } + try { - await prepareRequest(`${window.WSC_RPC_API_URL}core/files/${fileId}`).delete().fetchAsJson(); + await prepareRequest(url).delete().fetchAsJson(); } catch (e) { return apiResultFromError(e); } diff --git a/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts b/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts index 14d82379204..b6521384009 100644 --- a/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts +++ b/ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts @@ -12,8 +12,11 @@ type Response = { thumbnails: Thumbnail[]; }; -export async function generateThumbnails(fileID: number): Promise> { +export async function generateThumbnails(fileID: number, uploaderToken?: string): Promise> { const url = new URL(`${window.WSC_RPC_API_URL}core/files/${fileID}/generate-thumbnails`); + if (uploaderToken !== undefined) { + url.searchParams.set("uploaderToken", uploaderToken); + } let response: Response; try { diff --git a/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts b/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts index 0618a39fdda..a33def4224e 100644 --- a/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts +++ b/ts/WoltLabSuite/Core/Component/Attachment/Entry.ts @@ -18,7 +18,12 @@ export type FileProcessorData = { messageObjectID: number | null; }; -function fileInitializationCompleted(element: HTMLElement, file: WoltlabCoreFileElement, editor: HTMLElement): void { +function fileInitializationCompleted( + element: HTMLElement, + file: WoltlabCoreFileElement, + editor: HTMLElement, + uploaderToken: string | undefined, +): void { const data = file.data; if (data === undefined) { throw new Error("No meta data was returned from the server.", { @@ -110,7 +115,14 @@ function fileInitializationCompleted(element: HTMLElement, file: WoltlabCoreFile const listItem = document.createElement("li"); listItem.append( - getDeleteAttachButton(fileId, (data as FileProcessorData).attachmentID, editor, element, file.filename), + getDeleteAttachButton( + fileId, + (data as FileProcessorData).attachmentID, + editor, + element, + file.filename, + uploaderToken, + ), ); dropdownMenu.append(listItem); @@ -141,6 +153,7 @@ function getDeleteAttachButton( editor: HTMLElement, element: HTMLElement, filename: string | undefined, + uploaderToken: string | undefined, ): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; @@ -152,7 +165,7 @@ function getDeleteAttachButton( return; } - (await deleteFile(fileId)).unwrap(); + (await deleteFile(fileId, uploaderToken)).unwrap(); dispatchToCkeditor(editor).removeAttachment({ attachmentId, @@ -179,7 +192,7 @@ function getInsertButton(attachmentId: number, url: string, editor: HTMLElement) return button; } -export function createAttachmentFromFile(file: WoltlabCoreFileElement, editor: HTMLElement) { +export function createAttachmentFromFile(file: WoltlabCoreFileElement, editor: HTMLElement, uploaderToken?: string) { const element = document.createElement("li"); element.classList.add("fileList__item", "attachment__item"); @@ -191,7 +204,7 @@ export function createAttachmentFromFile(file: WoltlabCoreFileElement, editor: H void file.ready .then(() => { - fileInitializationCompleted(element, file, editor); + fileInitializationCompleted(element, file, editor, uploaderToken); }) .catch((reason) => { fileInitializationFailed(element, file, reason); diff --git a/ts/WoltLabSuite/Core/Component/Attachment/List.ts b/ts/WoltLabSuite/Core/Component/Attachment/List.ts index 0f909ad0f7f..a0f4b89ee6b 100644 --- a/ts/WoltLabSuite/Core/Component/Attachment/List.ts +++ b/ts/WoltLabSuite/Core/Component/Attachment/List.ts @@ -8,9 +8,15 @@ import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex"; import { postObject } from "WoltLabSuite/Core/Api/PostObject"; import { debounce } from "WoltLabSuite/Core/Core"; import { getCkeditor } from "../Ckeditor"; - -function fileToAttachment(fileList: HTMLElement, file: WoltlabCoreFileElement, editor: HTMLElement): void { - fileList.append(createAttachmentFromFile(file, editor)); +import { getUploaderToken } from "../File/Helper"; + +function fileToAttachment( + fileList: HTMLElement, + file: WoltlabCoreFileElement, + editor: HTMLElement, + uploaderToken: string | undefined, +): void { + fileList.append(createAttachmentFromFile(file, editor, uploaderToken)); } type Context = { @@ -79,7 +85,7 @@ export function setup(editorId: string): void { let showOrder = -1; uploadButton.addEventListener("uploadStart", (event: CustomEvent) => { - fileToAttachment(fileList, event.detail, editor); + fileToAttachment(fileList, event.detail, editor, getUploaderToken(uploadButton)); const context = JSON.parse(uploadButton.dataset.context!) as Record; context.showOrder = ++showOrder; @@ -126,7 +132,7 @@ export function setup(editorId: string): void { const existingFiles = container.querySelector(".attachment__list__existingFiles"); if (existingFiles !== null) { existingFiles.querySelectorAll("woltlab-core-file").forEach((file) => { - fileToAttachment(fileList, file, editor); + fileToAttachment(fileList, file, editor, getUploaderToken(uploadButton)); const attachmentShowOrder = file.data?.showOrder; if (typeof attachmentShowOrder === "number") { diff --git a/ts/WoltLabSuite/Core/Component/File/Helper.ts b/ts/WoltLabSuite/Core/Component/File/Helper.ts index 5be918b3243..a3aedce7d1d 100644 --- a/ts/WoltLabSuite/Core/Component/File/Helper.ts +++ b/ts/WoltLabSuite/Core/Component/File/Helper.ts @@ -6,6 +6,21 @@ import { formatFilesize } from "WoltLabSuite/Core/FileUtil"; // element. Do not remove! import "WoltLabSuite/Core/Component/File/woltlab-core-file"; +/** + * Returns the secret token of the uploader that the server added to the context + * of the upload element, if the file processor uses one. + */ +export function getUploaderToken(element: HTMLElement): string | undefined { + const context = element.dataset.context; + if (!context) { + return undefined; + } + + const uploaderToken = (JSON.parse(context) as Record).uploaderToken; + + return typeof uploaderToken === "string" ? uploaderToken : undefined; +} + export function trackUploadProgress(element: HTMLElement, file: WoltlabCoreFileElement): void { const progress = document.createElement("progress"); progress.classList.add("fileList__item__progress__bar"); diff --git a/ts/WoltLabSuite/Core/Component/File/Upload.ts b/ts/WoltLabSuite/Core/Component/File/Upload.ts index db2f2207b2f..b75d55b4d93 100644 --- a/ts/WoltLabSuite/Core/Component/File/Upload.ts +++ b/ts/WoltLabSuite/Core/Component/File/Upload.ts @@ -7,6 +7,7 @@ import { uploadChunk, } from "WoltLabSuite/Core/Api/Files/Chunk/Chunk"; import { generateThumbnails } from "WoltLabSuite/Core/Api/Files/GenerateThumbnails"; +import { getUploaderToken } from "WoltLabSuite/Core/Component/File/Helper"; import ImageResizer from "WoltLabSuite/Core/Image/Resizer"; import { AttachmentData } from "../Ckeditor/Attachment"; import { innerError } from "WoltLabSuite/Core/Dom/Util"; @@ -100,7 +101,7 @@ async function upload( notifyChunkProgress(fileElement, i + 1, numberOfChunks); - await chunkUploadCompleted(fileElement, response.value); + await chunkUploadCompleted(fileElement, response.value, getUploaderToken(element)); if (response.value.completed) { return response.value; @@ -121,7 +122,11 @@ function notifyChunkProgress(element: WoltlabCoreFileElement, currentChunk: numb element.dispatchEvent(event); } -async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, result: UploadChunkResponse): Promise { +async function chunkUploadCompleted( + fileElement: WoltlabCoreFileElement, + result: UploadChunkResponse, + uploaderToken: string | undefined, +): Promise { if (!result.completed) { return; } @@ -129,7 +134,7 @@ async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, result: fileElement.uploadCompleted(result.fileID, result.mimeType, result.link, result.data, result.generateThumbnails); if (result.generateThumbnails) { - const { filename, fileSize, mimeType, thumbnails } = (await generateThumbnails(result.fileID)).unwrap(); + const { filename, fileSize, mimeType, thumbnails } = (await generateThumbnails(result.fileID, uploaderToken)).unwrap(); fileElement.setThumbnails(thumbnails); fileElement.updateFileData(filename, fileSize, mimeType); } diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts index 36c03cfd510..5ad807f3a8f 100644 --- a/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.ts @@ -11,6 +11,7 @@ import { deleteFile } from "WoltLabSuite/Core/Api/Files/DeleteFile"; import DomChangeListener from "WoltLabSuite/Core/Dom/Change/Listener"; import { getErrorMessageFromFile, + getUploaderToken, insertFileInformation, removeUploadProgress, trackUploadProgress, @@ -212,7 +213,7 @@ export class FileProcessor { return; } - const result = await deleteFile(element.fileId!); + const result = await deleteFile(element.fileId!, getUploaderToken(this.#uploadButton)); if (result.ok) { this.#unregisterFile(element); @@ -312,7 +313,7 @@ export class FileProcessor { element.ready .then(() => { if (this.#replaceElement !== undefined) { - void deleteFile(this.#replaceElement.fileId!); + void deleteFile(this.#replaceElement.fileId!, getUploaderToken(this.#uploadButton)); this.#replaceElement = undefined; } this.#fileInitializationCompleted(element, container!, notifyCallback); diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/FileProcessor.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/FileProcessor.ts index 3a14e664e69..771ac29e5dc 100644 --- a/ts/WoltLabSuite/Core/Form/Builder/Field/FileProcessor.ts +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/FileProcessor.ts @@ -18,9 +18,17 @@ export default class FileProcessor extends Field { return {}; } - return { + const data: FormBuilderData = { [this._fieldId]: value, }; + + // The files were uploaded with the token, the server must receive it again. + const uploaderToken = document.getElementById(`${this._fieldId}_uploaderToken`) as HTMLInputElement | null; + if (uploaderToken !== null) { + data[`${this._fieldId}_uploaderToken`] = uploaderToken.value; + } + + return data; } protected _readField(): void { diff --git a/ts/WoltLabSuite/Core/Form/XsrfToken.ts b/ts/WoltLabSuite/Core/Form/XsrfToken.ts index c4ef536eb8d..f55d902e62b 100644 --- a/ts/WoltLabSuite/Core/Form/XsrfToken.ts +++ b/ts/WoltLabSuite/Core/Form/XsrfToken.ts @@ -15,14 +15,26 @@ function isInput(node: Node): node is HTMLInputElement { } export function setup(): void { - const token = getXsrfToken(); - wheneverFirstSeen(".xsrfTokenInput", (node) => { if (!isInput(node)) { return; } - node.value = token; + node.value = getXsrfToken(); node.classList.add("xsrfTokenInputHandled"); }); + + // A guest's session, and with it the token, can be started by a request made + // after the page was loaded, leaving the inputs with an outdated value. + document.addEventListener( + "submit", + (event) => { + const form = event.target as HTMLFormElement; + const token = getXsrfToken(); + form.querySelectorAll("input.xsrfTokenInput").forEach((input) => { + input.value = token; + }); + }, + { capture: true }, + ); } diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index f5df28e5fbe..7a02d379d9b 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -23,6 +23,7 @@ use wcf\system\database\table\column\VarcharDatabaseTableColumn; use wcf\system\database\table\index\DatabaseTableForeignKey; use wcf\system\database\table\index\DatabaseTableIndex; +use wcf\system\database\table\index\DatabaseTablePrimaryIndex; use wcf\system\database\table\DatabaseTable; use wcf\system\database\table\PartialDatabaseTable; @@ -227,6 +228,39 @@ ->columns([ JsonDatabaseTableColumn::create('conditions'), ]), + DatabaseTable::create('wcf1_captcha_question_token') + ->columns([ + BinaryDatabaseTableColumn::create('nonce') + ->notNull() + ->length(16), + NotNullInt10DatabaseTableColumn::create('expires'), + ]) + ->indices([ + DatabaseTablePrimaryIndex::create() + ->columns(['nonce']), + DatabaseTableIndex::create('expires') + ->columns(['expires']), + ]), + DatabaseTable::create('wcf1_file_uploader_token') + ->columns([ + NotNullInt10DatabaseTableColumn::create('fileID'), + BinaryDatabaseTableColumn::create('tokenHash') + ->length(16), + ]) + ->indices([ + DatabaseTablePrimaryIndex::create() + ->columns(['fileID']), + DatabaseTableIndex::create('tokenHash') + ->columns(['tokenHash']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['fileID']) + ->referencedTable('wcf1_file') + ->referencedColumns(['fileID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + ]), DatabaseTable::create('wcf1_user_rank_l10n') ->columns([ NotNullInt10DatabaseTableColumn::create('rankID'), diff --git a/wcfsetup/install/files/js/WCF.js b/wcfsetup/install/files/js/WCF.js index af58d829197..4bdea3c7154 100755 --- a/wcfsetup/install/files/js/WCF.js +++ b/wcfsetup/install/files/js/WCF.js @@ -5140,10 +5140,17 @@ WCF.Upload = Class.extend({ $fd.append('parameters[' + $name + ']', $additionalParameters[$name]); } + // A guest's token changes once their session is started, which can happen + // after the URL was built. + var $url = this._options.url; + if ($url.indexOf(WSC_API_URL) === 0) { + $url = $url.replace(/([?&])t=[^]*/, '$1t=' + encodeURIComponent(SECURITY_TOKEN)); + } + var self = this; $.ajax({ type: 'POST', - url: this._options.url, + url: $url, enctype: 'multipart/form-data', data: $fd, contentType: false, diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Request.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Request.js index d94fc677793..acd06a929fd 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Request.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ajax/Request.js @@ -14,6 +14,16 @@ define(["require", "exports", "tslib", "./Status", "../Core", "../Dom/Change/Lis Listener_1 = tslib_1.__importDefault(Listener_1); let _didInit = false; let _ignoreAllErrors = false; + /** + * A guest's token changes once their session is started, which can happen after + * the URL of a reused request was built. + */ + function withCurrentXsrfToken(url) { + if (!url.startsWith(window.WSC_API_URL)) { + return url; + } + return url.replace(/([?&])t=[^]*/, `$1t=${encodeURIComponent(Core.getXsrfToken())}`); + } /** * @constructor */ @@ -91,7 +101,7 @@ define(["require", "exports", "tslib", "./Status", "../Core", "../Dom/Change/Lis AjaxStatus.show(); } this._xhr = new XMLHttpRequest(); - this._xhr.open(this._options.type, this._options.url, true); + this._xhr.open(this._options.type, withCurrentXsrfToken(this._options.url), true); if (this._options.contentType) { this._xhr.setRequestHeader("Content-Type", this._options.contentType); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/DeleteFile.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/DeleteFile.js index 2c25b2ae8dd..5bc24ad270e 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/DeleteFile.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/DeleteFile.js @@ -2,9 +2,13 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.deleteFile = deleteFile; - async function deleteFile(fileId) { + async function deleteFile(fileId, uploaderToken) { + const url = new URL(`${window.WSC_RPC_API_URL}core/files/${fileId}`); + if (uploaderToken !== undefined) { + url.searchParams.set("uploaderToken", uploaderToken); + } try { - await (0, Backend_1.prepareRequest)(`${window.WSC_RPC_API_URL}core/files/${fileId}`).delete().fetchAsJson(); + await (0, Backend_1.prepareRequest)(url).delete().fetchAsJson(); } catch (e) { return (0, Result_1.apiResultFromError)(e); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/GenerateThumbnails.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/GenerateThumbnails.js index d678a9b74a7..6a7b03893f1 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/GenerateThumbnails.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Files/GenerateThumbnails.js @@ -2,8 +2,11 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.generateThumbnails = generateThumbnails; - async function generateThumbnails(fileID) { + async function generateThumbnails(fileID, uploaderToken) { const url = new URL(`${window.WSC_RPC_API_URL}core/files/${fileID}/generate-thumbnails`); + if (uploaderToken !== undefined) { + url.searchParams.set("uploaderToken", uploaderToken); + } let response; try { response = (await (0, Backend_1.prepareRequest)(url).post().disableLoadingIndicator().fetchAsJson()); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js index b781c5e4522..61a429302ee 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/Entry.js @@ -3,7 +3,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ui/Dropdown/Simple", " Object.defineProperty(exports, "__esModule", { value: true }); exports.createAttachmentFromFile = createAttachmentFromFile; Listener_1 = tslib_1.__importDefault(Listener_1); - function fileInitializationCompleted(element, file, editor) { + function fileInitializationCompleted(element, file, editor, uploaderToken) { const data = file.data; if (data === undefined) { throw new Error("No meta data was returned from the server.", { @@ -80,7 +80,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ui/Dropdown/Simple", " dropdownMenu.append(listItem); } const listItem = document.createElement("li"); - listItem.append(getDeleteAttachButton(fileId, data.attachmentID, editor, element, file.filename)); + listItem.append(getDeleteAttachButton(fileId, data.attachmentID, editor, element, file.filename, uploaderToken)); dropdownMenu.append(listItem); const moreOptions = document.createElement("button"); moreOptions.classList.add("button", "small"); @@ -98,7 +98,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ui/Dropdown/Simple", " (0, Simple_1.toggleDropdown)(moreOptions.id); }); } - function getDeleteAttachButton(fileId, attachmentId, editor, element, filename) { + function getDeleteAttachButton(fileId, attachmentId, editor, element, filename, uploaderToken) { const button = document.createElement("button"); button.type = "button"; button.textContent = (0, Language_1.getPhrase)("wcf.global.button.delete"); @@ -107,7 +107,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ui/Dropdown/Simple", " if (!confirmationResult) { return; } - (await (0, DeleteFile_1.deleteFile)(fileId)).unwrap(); + (await (0, DeleteFile_1.deleteFile)(fileId, uploaderToken)).unwrap(); (0, Event_1.dispatchToCkeditor)(editor).removeAttachment({ attachmentId, }); @@ -127,7 +127,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ui/Dropdown/Simple", " }); return button; } - function createAttachmentFromFile(file, editor) { + function createAttachmentFromFile(file, editor, uploaderToken) { const element = document.createElement("li"); element.classList.add("fileList__item", "attachment__item"); (0, Helper_1.insertFileInformation)(element, file); @@ -136,7 +136,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Ui/Dropdown/Simple", " }); void file.ready .then(() => { - fileInitializationCompleted(element, file, editor); + fileInitializationCompleted(element, file, editor, uploaderToken); }) .catch((reason) => { (0, Helper_1.fileInitializationFailed)(element, file, reason); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js index fc8ad0e8a78..142d38ba0fd 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Attachment/List.js @@ -1,10 +1,10 @@ -define(["require", "exports", "tslib", "./Entry", "../Ckeditor/Event", "../Message/MessageTabMenu", "sortablejs", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Api/PostObject", "WoltLabSuite/Core/Core", "../Ckeditor"], function (require, exports, tslib_1, Entry_1, Event_1, MessageTabMenu_1, sortablejs_1, PromiseMutex_1, PostObject_1, Core_1, Ckeditor_1) { +define(["require", "exports", "tslib", "./Entry", "../Ckeditor/Event", "../Message/MessageTabMenu", "sortablejs", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Api/PostObject", "WoltLabSuite/Core/Core", "../Ckeditor", "../File/Helper"], function (require, exports, tslib_1, Entry_1, Event_1, MessageTabMenu_1, sortablejs_1, PromiseMutex_1, PostObject_1, Core_1, Ckeditor_1, Helper_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.setup = setup; sortablejs_1 = tslib_1.__importDefault(sortablejs_1); - function fileToAttachment(fileList, file, editor) { - fileList.append((0, Entry_1.createAttachmentFromFile)(file, editor)); + function fileToAttachment(fileList, file, editor, uploaderToken) { + fileList.append((0, Entry_1.createAttachmentFromFile)(file, editor, uploaderToken)); } function setup(editorId) { const container = document.getElementById(`attachments_${editorId}`); @@ -61,7 +61,7 @@ define(["require", "exports", "tslib", "./Entry", "../Ckeditor/Event", "../Messa }); let showOrder = -1; uploadButton.addEventListener("uploadStart", (event) => { - fileToAttachment(fileList, event.detail, editor); + fileToAttachment(fileList, event.detail, editor, (0, Helper_1.getUploaderToken)(uploadButton)); const context = JSON.parse(uploadButton.dataset.context); context.showOrder = ++showOrder; uploadButton.dataset.context = JSON.stringify(context); @@ -103,7 +103,7 @@ define(["require", "exports", "tslib", "./Entry", "../Ckeditor/Event", "../Messa const existingFiles = container.querySelector(".attachment__list__existingFiles"); if (existingFiles !== null) { existingFiles.querySelectorAll("woltlab-core-file").forEach((file) => { - fileToAttachment(fileList, file, editor); + fileToAttachment(fileList, file, editor, (0, Helper_1.getUploaderToken)(uploadButton)); const attachmentShowOrder = file.data?.showOrder; if (typeof attachmentShowOrder === "number") { showOrder = Math.max(showOrder, attachmentShowOrder); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Helper.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Helper.js index 838d1ea0f99..239f3663937 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Helper.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Helper.js @@ -1,12 +1,25 @@ define(["require", "exports", "WoltLabSuite/Core/Language", "WoltLabSuite/Core/FileUtil", "WoltLabSuite/Core/Component/File/woltlab-core-file"], function (require, exports, Language_1, FileUtil_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); + exports.getUploaderToken = getUploaderToken; exports.trackUploadProgress = trackUploadProgress; exports.removeUploadProgress = removeUploadProgress; exports.getErrorMessageFromFile = getErrorMessageFromFile; exports.fileInitializationFailed = fileInitializationFailed; exports.insertFileInformation = insertFileInformation; exports.updateFileInformation = updateFileInformation; + /** + * Returns the secret token of the uploader that the server added to the context + * of the upload element, if the file processor uses one. + */ + function getUploaderToken(element) { + const context = element.dataset.context; + if (!context) { + return undefined; + } + const uploaderToken = JSON.parse(context).uploaderToken; + return typeof uploaderToken === "string" ? uploaderToken : undefined; + } function trackUploadProgress(element, file) { const progress = document.createElement("progress"); progress.classList.add("fileList__item__progress__bar"); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js index ccdd87d2dec..ca13fae02b8 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/File/Upload.js @@ -1,4 +1,4 @@ -define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk", "WoltLabSuite/Core/Api/Files/GenerateThumbnails", "WoltLabSuite/Core/Image/Resizer", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Language", "hash-wasm", "WoltLabSuite/Core/Component/Image/Cropper", "WoltLabSuite/Core/Image/ExifUtil"], function (require, exports, tslib_1, Selector_1, Upload_1, Chunk_1, GenerateThumbnails_1, Resizer_1, Util_1, Language_1, hash_wasm_1, Cropper_1, ExifUtil_1) { +define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/Files/Upload", "WoltLabSuite/Core/Api/Files/Chunk/Chunk", "WoltLabSuite/Core/Api/Files/GenerateThumbnails", "WoltLabSuite/Core/Component/File/Helper", "WoltLabSuite/Core/Image/Resizer", "WoltLabSuite/Core/Dom/Util", "WoltLabSuite/Core/Language", "hash-wasm", "WoltLabSuite/Core/Component/Image/Cropper", "WoltLabSuite/Core/Image/ExifUtil"], function (require, exports, tslib_1, Selector_1, Upload_1, Chunk_1, GenerateThumbnails_1, Helper_1, Resizer_1, Util_1, Language_1, hash_wasm_1, Cropper_1, ExifUtil_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.clearPreviousErrors = clearPreviousErrors; @@ -36,7 +36,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol throw new Error("Unexpected validation error", { cause: response.error }); } notifyChunkProgress(fileElement, i + 1, numberOfChunks); - await chunkUploadCompleted(fileElement, response.value); + await chunkUploadCompleted(fileElement, response.value, (0, Helper_1.getUploaderToken)(element)); if (response.value.completed) { return response.value; } @@ -53,13 +53,13 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Helper/Selector", "Wol }); element.dispatchEvent(event); } - async function chunkUploadCompleted(fileElement, result) { + async function chunkUploadCompleted(fileElement, result, uploaderToken) { if (!result.completed) { return; } fileElement.uploadCompleted(result.fileID, result.mimeType, result.link, result.data, result.generateThumbnails); if (result.generateThumbnails) { - const { filename, fileSize, mimeType, thumbnails } = (await (0, GenerateThumbnails_1.generateThumbnails)(result.fileID)).unwrap(); + const { filename, fileSize, mimeType, thumbnails } = (await (0, GenerateThumbnails_1.generateThumbnails)(result.fileID, uploaderToken)).unwrap(); fileElement.setThumbnails(thumbnails); fileElement.updateFileData(filename, fileSize, mimeType); } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js index a663e9a37c0..ef42ba17a93 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Controller/FileProcessor.js @@ -158,7 +158,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui if (!(await (0, Confirmation_1.confirmationFactory)().delete())) { return; } - const result = await (0, DeleteFile_1.deleteFile)(element.fileId); + const result = await (0, DeleteFile_1.deleteFile)(element.fileId, (0, Helper_1.getUploaderToken)(this.#uploadButton)); if (result.ok) { this.#unregisterFile(element); notifyValueChange(this.#fieldId, this.values); @@ -242,7 +242,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Language", "WoltLabSui element.ready .then(() => { if (this.#replaceElement !== undefined) { - void (0, DeleteFile_1.deleteFile)(this.#replaceElement.fileId); + void (0, DeleteFile_1.deleteFile)(this.#replaceElement.fileId, (0, Helper_1.getUploaderToken)(this.#uploadButton)); this.#replaceElement = undefined; } this.#fileInitializationCompleted(element, container, notifyCallback); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/FileProcessor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/FileProcessor.js index 8549cc4859c..98f4d5383aa 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/FileProcessor.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/FileProcessor.js @@ -16,9 +16,15 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Form/Builder/Field/Fie if (value === undefined) { return {}; } - return { + const data = { [this._fieldId]: value, }; + // The files were uploaded with the token, the server must receive it again. + const uploaderToken = document.getElementById(`${this._fieldId}_uploaderToken`); + if (uploaderToken !== null) { + data[`${this._fieldId}_uploaderToken`] = uploaderToken.value; + } + return data; } _readField() { // does nothing diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/XsrfToken.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/XsrfToken.js index 2b22898c61a..252878cd505 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/XsrfToken.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/XsrfToken.js @@ -14,13 +14,21 @@ define(["require", "exports", "../Core", "../Helper/Selector"], function (requir return node.nodeName === "INPUT"; } function setup() { - const token = (0, Core_1.getXsrfToken)(); (0, Selector_1.wheneverFirstSeen)(".xsrfTokenInput", (node) => { if (!isInput(node)) { return; } - node.value = token; + node.value = (0, Core_1.getXsrfToken)(); node.classList.add("xsrfTokenInputHandled"); }); + // A guest's session, and with it the token, can be started by a request made + // after the page was loaded, leaving the inputs with an outdated value. + document.addEventListener("submit", (event) => { + const form = event.target; + const token = (0, Core_1.getXsrfToken)(); + form.querySelectorAll("input.xsrfTokenInput").forEach((input) => { + input.value = token; + }); + }, { capture: true }); } }); diff --git a/wcfsetup/install/files/lib/action/AJAXProxyAction.class.php b/wcfsetup/install/files/lib/action/AJAXProxyAction.class.php index e6b9677e2cc..cdcdca34751 100644 --- a/wcfsetup/install/files/lib/action/AJAXProxyAction.class.php +++ b/wcfsetup/install/files/lib/action/AJAXProxyAction.class.php @@ -108,7 +108,10 @@ protected function sendResponse() if ( !RequestHandler::getInstance()->isACPRequest() - && (bool)WCF::getSession()->getVar('forceBackgroundQueuePerform') + && ( + BackgroundQueueHandler::getInstance()->hasPendingCheck() + || (bool)WCF::getSession()->getVar('forceBackgroundQueuePerform') + ) ) { @\header( \sprintf( diff --git a/wcfsetup/install/files/lib/action/BackgroundQueuePerformAction.class.php b/wcfsetup/install/files/lib/action/BackgroundQueuePerformAction.class.php index fcf965c43eb..b824c4ca2a4 100644 --- a/wcfsetup/install/files/lib/action/BackgroundQueuePerformAction.class.php +++ b/wcfsetup/install/files/lib/action/BackgroundQueuePerformAction.class.php @@ -23,6 +23,8 @@ final class BackgroundQueuePerformAction extends AbstractAction #[\Override] public function execute(): JsonResponse { + WCF::getSession()->disablePersistence(); + parent::execute(); for ($i = 0; $i < self::$jobsPerRun; $i++) { diff --git a/wcfsetup/install/files/lib/action/CronjobPerformAction.class.php b/wcfsetup/install/files/lib/action/CronjobPerformAction.class.php index 37a5bbdce9a..b67fe8ded4f 100644 --- a/wcfsetup/install/files/lib/action/CronjobPerformAction.class.php +++ b/wcfsetup/install/files/lib/action/CronjobPerformAction.class.php @@ -21,6 +21,8 @@ final class CronjobPerformAction implements RequestHandlerInterface #[\Override] public function handle(ServerRequestInterface $request): EmptyResponse { + WCF::getSession()->disablePersistence(); + CronjobScheduler::getInstance()->executeCronjobs(); WCF::getSession()->disableUpdate(); diff --git a/wcfsetup/install/files/lib/action/FileDownloadAction.class.php b/wcfsetup/install/files/lib/action/FileDownloadAction.class.php index 847607d371a..0b84b56006c 100644 --- a/wcfsetup/install/files/lib/action/FileDownloadAction.class.php +++ b/wcfsetup/install/files/lib/action/FileDownloadAction.class.php @@ -37,7 +37,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface $request->getQueryParams(), <<<'EOT' array { - id: positive-int + id: positive-int, + uploaderToken?: non-empty-string } EOT, ); @@ -52,7 +53,14 @@ public function handle(ServerRequestInterface $request): ResponseInterface throw new IllegalLinkException(); } - if (!$processor->canDownload($file)) { + $uploaderToken = $parameters['uploaderToken'] ?? null; + if ($uploaderToken !== null) { + $canDownload = $processor->canDownloadWithUploaderToken($file, $uploaderToken); + } else { + $canDownload = $processor->canDownload($file); + } + + if (!$canDownload) { throw new PermissionDeniedException(); } diff --git a/wcfsetup/install/files/lib/data/file/File.class.php b/wcfsetup/install/files/lib/data/file/File.class.php index 62b8e35d4fd..fb3e68267a1 100644 --- a/wcfsetup/install/files/lib/data/file/File.class.php +++ b/wcfsetup/install/files/lib/data/file/File.class.php @@ -130,6 +130,23 @@ public function getLink(): string ); } + /** + * Returns the link for the uploader of the file, see `UploaderToken`. + * + * @since 6.3 + */ + public function getLinkWithUploaderToken(string $uploaderToken): string + { + return LinkHandler::getInstance()->getControllerLink( + FileDownloadAction::class, + [ + 'id' => $this->fileID, + 'uploaderToken' => $uploaderToken, + 'forceFrontend' => true, + ] + ); + } + public function getFullSizeImageSource(): ?string { $isImage = $this->isImage() || $this->mimeType === 'image/svg+xml'; @@ -173,6 +190,21 @@ public function canDelete(): bool return $processor->canDelete($this); } + /** + * Returns true if the uploader of the file can delete it, see `UploaderToken`. + * + * @since 6.3 + */ + public function canDeleteWithUploaderToken(string $uploaderToken): bool + { + $processor = $this->getProcessor(); + if ($processor === null) { + return true; + } + + return $processor->canDeleteWithUploaderToken($this, $uploaderToken); + } + public function addThumbnail(FileThumbnail $thumbnail): void { $this->thumbnails[$thumbnail->identifier] = $thumbnail; @@ -193,8 +225,9 @@ public function getThumbnails(): array /** * @param ?mixed[] $metaData + * @param ?string $uploaderToken links the file for its uploader, see `UploaderToken` */ - public function toHtmlElement(?array $metaData = null): string + public function toHtmlElement(?array $metaData = null, ?string $uploaderToken = null): string { $thumbnails = []; foreach ($this->thumbnails as $thumbnail) { @@ -222,7 +255,9 @@ public function toHtmlElement(?array $metaData = null): string StringUtil::encodeHTML($this->mimeType), StringUtil::encodeHTML(\json_encode($thumbnails, \JSON_THROW_ON_ERROR)), $metaData === null ? "" : 'data-meta-data="' . StringUtil::encodeHTML(\json_encode($metaData, \JSON_THROW_ON_ERROR)) . '"', - StringUtil::encodeHTML($this->getLink()), + StringUtil::encodeHTML( + $uploaderToken === null ? $this->getLink() : $this->getLinkWithUploaderToken($uploaderToken) + ), ); } diff --git a/wcfsetup/install/files/lib/form/LoginForm.class.php b/wcfsetup/install/files/lib/form/LoginForm.class.php index 080c2a960e5..5886c5c4bb4 100644 --- a/wcfsetup/install/files/lib/form/LoginForm.class.php +++ b/wcfsetup/install/files/lib/form/LoginForm.class.php @@ -18,6 +18,16 @@ class LoginForm extends \wcf\acp\form\LoginForm { const AVAILABLE_DURING_OFFLINE_MODE = true; + #[\Override] + public function readParameters() + { + parent::readParameters(); + + // Guests with on-demand sessions have none yet, but submitting the form + // requires the session cookie to tell whether cookies are accepted. + WCF::getSession()->persist(); + } + #[\Override] protected function createForm() { diff --git a/wcfsetup/install/files/lib/http/middleware/Xsrf.class.php b/wcfsetup/install/files/lib/http/middleware/Xsrf.class.php index e6d40e83939..fd1e0ce6036 100644 --- a/wcfsetup/install/files/lib/http/middleware/Xsrf.class.php +++ b/wcfsetup/install/files/lib/http/middleware/Xsrf.class.php @@ -43,7 +43,9 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $xsrfToken ); - $hasValidXsrfToken = \hash_equals($xsrfToken, $request->getHeaderLine('x-xsrf-token')); + // Also accepts requests of guests without a session, which have no token yet, + // if the browser reports that the request originates from this site. + $hasValidXsrfToken = WCF::getSession()->checkSecurityToken($request->getHeaderLine('x-xsrf-token')); $request = $request->withAttribute( self::HAS_VALID_HEADER_ATTRIBUTE, diff --git a/wcfsetup/install/files/lib/system/WCF.class.php b/wcfsetup/install/files/lib/system/WCF.class.php index d29a6710deb..8012037757b 100644 --- a/wcfsetup/install/files/lib/system/WCF.class.php +++ b/wcfsetup/install/files/lib/system/WCF.class.php @@ -12,6 +12,7 @@ use wcf\data\user\User; use wcf\system\application\ApplicationHandler; use wcf\system\application\IApplication; +use wcf\system\background\BackgroundQueueHandler; use wcf\system\benchmark\Benchmark; use wcf\system\box\BoxHandler; use wcf\system\cache\builder\PackageUpdateCacheBuilder; @@ -1088,6 +1089,16 @@ public function getBoxHandler(): BoxHandler return BoxHandler::getInstance(); } + /** + * Returns the background queue handler. + * + * @since 6.3 + */ + public function getBackgroundQueueHandler(): BackgroundQueueHandler + { + return BackgroundQueueHandler::getInstance(); + } + /** * Returns number of available updates. */ diff --git a/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php b/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php index 440e676361f..7c551998ff2 100644 --- a/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php +++ b/wcfsetup/install/files/lib/system/attachment/AttachmentHandler.class.php @@ -2,6 +2,7 @@ namespace wcf\system\attachment; +use ParagonIE\ConstantTime\Hex; use wcf\data\attachment\AttachmentBuilder; use wcf\data\attachment\AttachmentList; use wcf\data\object\type\ObjectType; @@ -10,6 +11,7 @@ use wcf\system\exception\SystemException; use wcf\system\file\processor\AttachmentFileProcessor; use wcf\system\file\processor\FileProcessor; +use wcf\system\session\SessionHandler; use wcf\system\WCF; /** @@ -163,6 +165,29 @@ private static function addUploaderCondition(PreparedStatementConditionBuilder $ } } + /** + * Returns the per-user component of a deterministic tmpHash. For logged-in users this + * is public data, so `addUploaderCondition()` is what blocks claiming by third parties. + * + * Guests with on-demand sessions receive a random value, because reading the session id + * would start a session merely by rendering the form. The form must carry that tmpHash, + * and uploads are lost when the guest reloads the page. + * + * @since 6.3 + */ + public static function getTmpHashIdentifier(): string + { + if (!WCF::getUser()->isGuest()) { + return (string)WCF::getUser()->userID; + } + + if (SessionHandler::hasOnDemandGuestSessions()) { + return Hex::encode(\random_bytes(20)); + } + + return WCF::getSession()->sessionID; + } + /** * Transfers attachments to a different object id of the same type (e.g. merging content) * diff --git a/wcfsetup/install/files/lib/system/background/BackgroundQueueHandler.class.php b/wcfsetup/install/files/lib/system/background/BackgroundQueueHandler.class.php index 03bc2c0eda7..b310555d0b9 100644 --- a/wcfsetup/install/files/lib/system/background/BackgroundQueueHandler.class.php +++ b/wcfsetup/install/files/lib/system/background/BackgroundQueueHandler.class.php @@ -33,7 +33,11 @@ final class BackgroundQueueHandler extends SingletonFactory */ public function forceCheck(): void { - WCF::getSession()->register('forceBackgroundQueuePerform', true); + // The session variable only carries the check across a redirect, the current page is + // covered by `hasPendingCheck()`. It must not start an on-demand session on its own. + if (WCF::getSession()->isPersisted()) { + WCF::getSession()->register('forceBackgroundQueuePerform', true); + } WCF::getTPL()->assign([ 'forceBackgroundQueuePerform' => true, diff --git a/wcfsetup/install/files/lib/system/captcha/CaptchaQuestionHandler.class.php b/wcfsetup/install/files/lib/system/captcha/CaptchaQuestionHandler.class.php index 103186f07dc..14033691c3c 100644 --- a/wcfsetup/install/files/lib/system/captcha/CaptchaQuestionHandler.class.php +++ b/wcfsetup/install/files/lib/system/captcha/CaptchaQuestionHandler.class.php @@ -7,7 +7,9 @@ use wcf\data\captcha\question\CaptchaQuestionEditor; use wcf\system\cache\builder\CaptchaQuestionCacheBuilder; use wcf\system\exception\UserInputException; +use wcf\system\session\SessionHandler; use wcf\system\WCF; +use wcf\util\CryptoUtil; use wcf\util\StringUtil; /** @@ -19,13 +21,20 @@ */ final class CaptchaQuestionHandler implements ICaptchaHandler { + /** + * Separates the signed token from other values signed with `CryptoUtil`. + */ + private const TOKEN_PREFIX = self::class . "\0"; + + private const TOKEN_LIFETIME = 24 * 3600; + /** * answer to the captcha question */ private string $captchaAnswer = ''; /** - * unique identifier of the captcha question + * unique identifier of the captcha question, a signed token with on-demand guest sessions */ private string $captchaQuestion = ''; @@ -103,20 +112,89 @@ private function readCaptchaQuestion(): void $questionID = \array_rand($this->questions); $this->question = new CaptchaQuestionEditor($this->questions[$questionID]); - // A random ID needs to be generated, otherwise an attacker will - // trivially be able to select a specific question. - $this->captchaQuestion = Hex::encode(\random_bytes(16)); + if (SessionHandler::hasOnDemandGuestSessions()) { + // The question is carried by a signed token, because rendering a form must not + // start a session. The random bytes make every token unique, they are recorded + // once the question was answered to prevent the token from being reused. + $this->captchaQuestion = CryptoUtil::createSignedString( + self::TOKEN_PREFIX + . \pack('NN', $questionID, \TIME_NOW + self::TOKEN_LIFETIME) + . \random_bytes(16) + ); + } else { + // A random ID needs to be generated, otherwise an attacker will + // trivially be able to select a specific question. + $this->captchaQuestion = Hex::encode(\random_bytes(16)); + + WCF::getSession()->register('captchaQuestion_' . $this->captchaQuestion, $questionID); + } + } - WCF::getSession()->register('captchaQuestion_' . $this->captchaQuestion, $questionID); + /** + * Returns the data of the signed token or `null` if the token is invalid or expired. + * + * @return ?array{questionID: int, expires: int, nonce: string} + */ + private function parseToken(): ?array + { + $value = CryptoUtil::getValueFromSignedString($this->captchaQuestion); + if ($value === null || !\str_starts_with($value, self::TOKEN_PREFIX)) { + return null; + } + + if (\strlen($value) !== \strlen(self::TOKEN_PREFIX) + 24) { + return null; + } + + [ + 'questionID' => $questionID, + 'expires' => $expires, + 'nonce' => $nonce, + ] = \unpack('NquestionID/Nexpires/a16nonce', $value, \strlen(self::TOKEN_PREFIX)); + + if ($expires < \TIME_NOW) { + return null; + } + + return [ + 'questionID' => $questionID, + 'expires' => $expires, + 'nonce' => $nonce, + ]; + } + + /** + * Records the token as used, returns false if it was used before. + * + * @param array{questionID: int, expires: int, nonce: string} $token + */ + private function markTokenAsUsed(array $token): bool + { + $sql = "INSERT IGNORE INTO wcf1_captcha_question_token + (nonce, expires) + VALUES (?, ?)"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $token['nonce'], + $token['expires'], + ]); + + return $statement->getAffectedRows() === 1; } #[\Override] public function validate() { - $questionID = WCF::getSession()->getVar('captchaQuestion_' . $this->captchaQuestion); + $token = null; + if (SessionHandler::hasOnDemandGuestSessions()) { + $token = $this->parseToken(); + $questionID = $token['questionID'] ?? null; + } else { + $questionID = WCF::getSession()->getVar('captchaQuestion_' . $this->captchaQuestion); + } if ($questionID === null || !isset($this->questions[$questionID])) { - throw new UserInputException('captchaAnswer'); + throw new UserInputException('captchaAnswer', 'invalid'); } $this->question = new CaptchaQuestionEditor($this->questions[$questionID]); @@ -136,10 +214,30 @@ public function validate() throw new UserInputException('captchaAnswer', 'false'); } + if ($token !== null && !$this->markTokenAsUsed($token)) { + // This token can no longer be answered, so the re-rendered form must ask a new question. + unset($this->question); + + throw new UserInputException('captchaAnswer', 'invalid'); + } + $this->question->updateCounters([ 'correctSubmissions' => 1, ]); WCF::getSession()->register('captchaQuestionSolved_' . $this->captchaQuestion, true); } + + /** + * Deletes the records of used tokens that have expired. + * + * @since 6.3 + */ + public static function pruneUsedTokens(): void + { + $sql = "DELETE FROM wcf1_captcha_question_token + WHERE expires < ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([\TIME_NOW]); + } } diff --git a/wcfsetup/install/files/lib/system/cronjob/DailyCleanUpCronjob.class.php b/wcfsetup/install/files/lib/system/cronjob/DailyCleanUpCronjob.class.php index 6cb656aed98..8846b0cd5ac 100644 --- a/wcfsetup/install/files/lib/system/cronjob/DailyCleanUpCronjob.class.php +++ b/wcfsetup/install/files/lib/system/cronjob/DailyCleanUpCronjob.class.php @@ -5,6 +5,7 @@ use wcf\command\email\log\entry\PruneEmailLogEntries; use wcf\data\cronjob\Cronjob; use wcf\data\edit\history\entry\EditHistoryEntryBuilder; +use wcf\system\captcha\CaptchaQuestionHandler; use wcf\system\flood\FloodControl; use wcf\system\user\multifactor\EmailMultifactorMethod; use wcf\system\visitTracker\VisitTracker; @@ -203,6 +204,7 @@ public function execute(Cronjob $cronjob) } FloodControl::getInstance()->prune(); + CaptchaQuestionHandler::pruneUsedTokens(); EmailMultifactorMethod::prune(); new PruneEmailLogEntries()(); } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/DeleteFile.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/DeleteFile.class.php index 7fc0c4770a9..0cbe6c798c7 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/DeleteFile.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/DeleteFile.class.php @@ -26,7 +26,24 @@ final class DeleteFile implements IController public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface { $file = Helper::fetchObjectFromRequestParameter($variables['id'], File::class); - if (!$file->canDelete()) { + + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + uploaderToken?: non-empty-string + } + EOT, + ); + + $uploaderToken = $parameters['uploaderToken'] ?? null; + if ($uploaderToken !== null) { + $canDelete = $file->canDeleteWithUploaderToken($uploaderToken); + } else { + $canDelete = $file->canDelete(); + } + + if (!$canDelete) { throw new PermissionDeniedException(); } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/GenerateThumbnails.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/GenerateThumbnails.class.php index 807f08042b0..8321a1efa3e 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/GenerateThumbnails.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/GenerateThumbnails.class.php @@ -30,7 +30,16 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res { $file = Helper::fetchObjectFromRequestParameter($variables['id'], File::class); - $this->assertCanGenerateThumbnails($file); + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + uploaderToken?: non-empty-string + } + EOT, + ); + + $this->assertCanGenerateThumbnails($file, $parameters['uploaderToken'] ?? null); $file = FileProcessor::getInstance()->stripExif($file); $file = FileProcessor::getInstance()->generateWebpVariant($file); @@ -53,14 +62,20 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res ]); } - private function assertCanGenerateThumbnails(File $file): void + private function assertCanGenerateThumbnails(File $file, ?string $uploaderToken): void { $processor = $file->getProcessor(); if ($processor === null) { throw new PermissionDeniedException(); } - if (!$processor->canDelete($file)) { + if ($uploaderToken !== null) { + $canDelete = $processor->canDeleteWithUploaderToken($file, $uploaderToken); + } else { + $canDelete = $processor->canDelete($file); + } + + if (!$canDelete) { throw new PermissionDeniedException(); } } diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/SaveChunk.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/SaveChunk.class.php index d85185c406c..133589b3509 100644 --- a/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/SaveChunk.class.php +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/files/upload/SaveChunk.class.php @@ -14,6 +14,7 @@ use wcf\system\endpoint\PostRequest; use wcf\system\exception\UserInputException; use wcf\system\file\processor\FileProcessor; +use wcf\system\file\processor\UploaderToken; use wcf\system\io\File; /** @@ -150,13 +151,18 @@ public function __invoke(ServerRequestInterface $request, array $variables): Res $data = $processor->getUploadResponse($file); } + $uploaderToken = null; + if ($processor !== null && $processor->usesUploaderToken()) { + $uploaderToken = UploaderToken::fromContext($context); + } + return new JsonResponse([ 'completed' => true, 'generateThumbnails' => $generateThumbnails, 'fileID' => $file->fileID, 'objectTypeID' => $file->objectTypeID, 'mimeType' => $file->mimeType, - 'link' => $file->getLink(), + 'link' => $uploaderToken === null ? $file->getLink() : $file->getLinkWithUploaderToken($uploaderToken), 'data' => $data, ]); } diff --git a/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php index e3984687e01..b10b50b2b6c 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AbstractFileProcessor.class.php @@ -136,4 +136,22 @@ public function serveSvgStatically(): bool // You MUST NOT enable this for unprivileged uploads. return false; } + + #[\Override] + public function usesUploaderToken(): bool + { + return false; + } + + #[\Override] + public function canDeleteWithUploaderToken(File $file, string $uploaderToken): bool + { + return $this->canDelete($file); + } + + #[\Override] + public function canDownloadWithUploaderToken(File $file, string $uploaderToken): bool + { + return $this->canDownload($file); + } } diff --git a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php index 869eb5315fc..303b5311e4e 100644 --- a/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/AttachmentFileProcessor.class.php @@ -107,9 +107,34 @@ public function canDelete(File $file): bool return false; } + // Only the uploader can delete the temporary attachments of guests, proven by the tmpHash. + if ($this->isTemporaryGuestAttachment($attachment)) { + return false; + } + return $attachment->canDelete(); } + #[\Override] + public function canDeleteWithUploaderToken(File $file, string $uploaderToken): bool + { + $attachment = Attachment::findByFileID($file->fileID); + if ($attachment === null) { + return false; + } + + if ($this->isTemporaryGuestAttachment($attachment)) { + return \hash_equals($attachment->tmpHash, $uploaderToken); + } + + return $attachment->canDelete(); + } + + private function isTemporaryGuestAttachment(Attachment $attachment): bool + { + return $attachment->tmpHash !== '' && $attachment->userID === null; + } + #[\Override] public function canDownload(File $file): bool { @@ -148,15 +173,19 @@ public function getUploadResponse(File $file): array public function toHtmlElement(string $objectType, int $objectID, string $tmpHash, int $parentObjectID): string { - return FileProcessor::getInstance()->getHtmlElement( - $this, - [ - 'objectType' => $objectType, - 'objectID' => $objectID, - 'parentObjectID' => $parentObjectID, - 'tmpHash' => $tmpHash, - ], - ); + $context = [ + 'objectType' => $objectType, + 'objectID' => $objectID, + 'parentObjectID' => $parentObjectID, + 'tmpHash' => $tmpHash, + ]; + + // Guests are identified by the tmpHash that only they know, see `canDeleteWithUploaderToken()`. + if (WCF::getUser()->isGuest() && $tmpHash !== '') { + $context[UploaderToken::CONTEXT_KEY] = \explode(',', $tmpHash)[0]; + } + + return FileProcessor::getInstance()->getHtmlElement($this, $context); } #[\Override] diff --git a/wcfsetup/install/files/lib/system/file/processor/ContactFormFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/ContactFormFileProcessor.class.php index 2eda2a36d78..669a5b589cf 100644 --- a/wcfsetup/install/files/lib/system/file/processor/ContactFormFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/ContactFormFileProcessor.class.php @@ -3,7 +3,6 @@ namespace wcf\system\file\processor; use wcf\data\file\File; -use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\WCF; use wcf\util\ArrayUtil; use wcf\util\FileUtil; @@ -16,9 +15,6 @@ */ final class ContactFormFileProcessor extends AbstractFileProcessor { - private const SESSION_VARIABLE_PREFIX = 'contact_form_file_processor_'; - private const SESSION_VARIABLE = self::SESSION_VARIABLE_PREFIX . '%d'; - #[\Override] public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult { @@ -26,6 +22,10 @@ public function acceptUpload(string $filename, int $fileSize, array $context): F return FileProcessorPreflightResult::InsufficientPermissions; } + if (UploaderToken::fromContext($context) === null) { + return FileProcessorPreflightResult::InvalidContext; + } + if ($fileSize > $this->getMaximumSize($context)) { return FileProcessorPreflightResult::FileSizeTooLarge; } @@ -40,15 +40,33 @@ public function acceptUpload(string $filename, int $fileSize, array $context): F #[\Override] public function canAdopt(File $file, array $context): bool { - return true; + $uploaderToken = UploaderToken::fromContext($context); + if ($uploaderToken === null) { + return false; + } + + // The file is claimed by `adopt()`, which is invoked right after this check + // once the upload has been completed. + if (UploaderToken::isUnclaimed($file)) { + return true; + } + + return UploaderToken::matches($file, $uploaderToken); } #[\Override] public function adopt(File $file, array $context): void { - // Save the `fileID` in the session variable so that the current user can download or delete it. - WCF::getSession()->register(\sprintf(self::SESSION_VARIABLE, $file->fileID), \TIME_NOW); - WCF::getSession()->update(); + $uploaderToken = UploaderToken::fromContext($context); + if ($uploaderToken !== null) { + UploaderToken::store($file, $uploaderToken); + } + } + + #[\Override] + public function usesUploaderToken(): bool + { + return true; } #[\Override] @@ -80,56 +98,47 @@ public function getMaximumSize(array $context): ?int #[\Override] public function canDelete(File $file): bool { - return WCF::getSession()->getVar( - \sprintf(self::SESSION_VARIABLE, $file->fileID) - ) !== null; + // Only the uploader can delete a file, identified by their token. + return false; + } + + #[\Override] + public function canDeleteWithUploaderToken(File $file, string $uploaderToken): bool + { + return UploaderToken::matches($file, $uploaderToken); } #[\Override] public function canDownload(File $file): bool { - if (WCF::getSession()->hasPermission('admin.contact.canManageContactForm')) { + return WCF::getSession()->hasPermission('admin.contact.canManageContactForm'); + } + + #[\Override] + public function canDownloadWithUploaderToken(File $file, string $uploaderToken): bool + { + if ($this->canDownload($file)) { return true; } - return WCF::getSession()->getVar( - \sprintf(self::SESSION_VARIABLE, $file->fileID) - ) !== null; + return UploaderToken::matches($file, $uploaderToken); } #[\Override] public function countExistingFiles(array $context): int { - $fileIDs = $this->getFileIDsFromSession(); - if ($fileIDs === []) { - return 0; - } - - $objectTypeID = FileProcessor::getInstance()->getObjectType($this->getObjectTypeName())?->objectTypeID; - if ($objectTypeID === null) { + $uploaderToken = UploaderToken::fromContext($context); + if ($uploaderToken === null) { return 0; } - // The session may reference files that have already been deleted or - // that were pruned by `FileCleanUpCronjob`, those must not count - // towards the limit. - $conditionBuilder = new PreparedStatementConditionBuilder(); - $conditionBuilder->add('fileID IN (?)', [$fileIDs]); - $conditionBuilder->add('objectTypeID = ?', [$objectTypeID]); - - $sql = "SELECT COUNT(*) - FROM wcf1_file - {$conditionBuilder}"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditionBuilder->getParameters()); - - return (int)$statement->fetchSingleColumn(); + return UploaderToken::countFiles($uploaderToken); } #[\Override] public function delete(array $fileIDs, array $thumbnailIDs): void { - $this->unregisterFiles($fileIDs); + // The records of the uploader tokens are deleted along with the files. } #[\Override] @@ -139,47 +148,14 @@ public function getObjectTypeName(): string } /** - * Releases the claim of the current user on the files of a message that - * has been submitted. The files are retained until they are pruned, but - * they must not count towards the upload limit of any future message. + * Releases the claim of the uploader on the files of a message that has been + * submitted. The files are retained until they are pruned, but they can no + * longer be deleted by the uploader or be adopted by another message. * * @param list $fileIDs */ public function releaseFiles(array $fileIDs): void { - $this->unregisterFiles($fileIDs); - - // `WCF::destruct()` writes the session only after the response has been - // flushed, therefore the redirect could otherwise race the removal. - WCF::getSession()->update(); - } - - /** - * @param list $fileIDs - */ - private function unregisterFiles(array $fileIDs): void - { - foreach ($fileIDs as $fileID) { - WCF::getSession()->unregister( - \sprintf(self::SESSION_VARIABLE, $fileID) - ); - } - } - - /** - * @return list - */ - private function getFileIDsFromSession(): array - { - $fileIDs = []; - foreach (\array_keys(WCF::getSession()->getVariables()) as $key) { - if (!\str_starts_with($key, self::SESSION_VARIABLE_PREFIX)) { - continue; - } - - $fileIDs[] = (int)\substr($key, \strlen(self::SESSION_VARIABLE_PREFIX)); - } - - return $fileIDs; + UploaderToken::release($fileIDs); } } diff --git a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php index 934a2625765..6172ee3d0ad 100644 --- a/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php +++ b/wcfsetup/install/files/lib/system/file/processor/IFileProcessor.class.php @@ -222,4 +222,34 @@ public function isSingleFile(): bool; * @since 6.3 */ public function serveSvgStatically(): bool; + + /** + * Opts into the tracking of ownership through a secret token that is only + * known to the uploader, see `UploaderToken`. The form field adds the token + * to the `$context` and it is sent along when the uploader deletes or + * downloads one of the files. + * + * Use this instead of the session to track the files of guests, their + * session is only started on demand and parallel uploads would otherwise + * each start a session of their own. + * + * @since 6.3 + */ + public function usesUploaderToken(): bool; + + /** + * Validates that the uploader identified by `$uploaderToken` can delete + * this file. Only invoked for requests that carry a token. + * + * @since 6.3 + */ + public function canDeleteWithUploaderToken(File $file, string $uploaderToken): bool; + + /** + * Validates that the uploader identified by `$uploaderToken` may download + * this file. Only invoked for requests that carry a token. + * + * @since 6.3 + */ + public function canDownloadWithUploaderToken(File $file, string $uploaderToken): bool; } diff --git a/wcfsetup/install/files/lib/system/file/processor/UploaderToken.class.php b/wcfsetup/install/files/lib/system/file/processor/UploaderToken.class.php new file mode 100644 index 00000000000..1d0ac6ea3f0 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/UploaderToken.class.php @@ -0,0 +1,152 @@ + + * @since 6.3 + */ +final class UploaderToken +{ + /** + * Name of the key in the context of the file processor. + */ + public const CONTEXT_KEY = 'uploaderToken'; + + public static function generate(): string + { + return Hex::encode(\random_bytes(16)); + } + + /** + * @phpstan-assert-if-true string $token + */ + public static function isValid(mixed $token): bool + { + return \is_string($token) && \preg_match('~^[a-f0-9]{32}\z~', $token) === 1; + } + + /** + * Returns the token from the context or `null` if it is missing or malformed. + * + * @param array $context + */ + public static function fromContext(array $context): ?string + { + $token = $context[self::CONTEXT_KEY] ?? null; + + return self::isValid($token) ? $token : null; + } + + /** + * Records the token as the owner of the file. + */ + public static function store(File $file, string $token): void + { + $sql = "INSERT INTO wcf1_file_uploader_token + (fileID, tokenHash) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE tokenHash = VALUES(tokenHash)"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $file->fileID, + self::hash($token), + ]); + } + + /** + * Returns true if no token was recorded for the file yet. + */ + public static function isUnclaimed(File $file): bool + { + $sql = "SELECT COUNT(*) + FROM wcf1_file_uploader_token + WHERE fileID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$file->fileID]); + + return $statement->fetchSingleColumn() === 0; + } + + /** + * Returns true if the file was uploaded with the given token and has not + * been released since. + */ + public static function matches(File $file, string $token): bool + { + $sql = "SELECT tokenHash + FROM wcf1_file_uploader_token + WHERE fileID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$file->fileID]); + $tokenHash = $statement->fetchSingleColumn(); + + if (!\is_string($tokenHash)) { + return false; + } + + return \hash_equals($tokenHash, self::hash($token)); + } + + /** + * Counts the files that were uploaded with the given token and have not + * been released since. + */ + public static function countFiles(string $token): int + { + $sql = "SELECT COUNT(*) + FROM wcf1_file_uploader_token + WHERE tokenHash = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([self::hash($token)]); + + return (int)$statement->fetchSingleColumn(); + } + + /** + * Revokes the ownership of the uploader, e.g. once the files were submitted. + * The files remain claimed and cannot be adopted by anyone else. + * + * @param list $fileIDs + */ + public static function release(array $fileIDs): void + { + if ($fileIDs === []) { + return; + } + + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('fileID IN (?)', [$fileIDs]); + + // Files without a record, e.g. uploaded before the update, would otherwise + // remain unclaimed and could still be adopted by someone else. + $sql = "INSERT INTO wcf1_file_uploader_token + (fileID, tokenHash) + SELECT fileID, NULL + FROM wcf1_file + {$conditionBuilder} + ON DUPLICATE KEY UPDATE tokenHash = NULL"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + } + + private static function hash(string $token): string + { + // Truncated to match the 128 bits of entropy of the token. + return \substr(\hash('sha256', $token, true), 0, 16); + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php index 3539085a7de..c539c282e18 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/FileProcessorFormField.class.php @@ -8,6 +8,7 @@ use wcf\data\IStorableObject; use wcf\system\file\processor\FileProcessor; use wcf\system\file\processor\IFileProcessor; +use wcf\system\file\processor\UploaderToken; use wcf\system\form\builder\data\processor\CustomFormDataProcessor; use wcf\system\form\builder\field\validation\FormFieldValidationError; use wcf\system\form\builder\IFormDocument; @@ -52,6 +53,11 @@ final class FileProcessorFormField extends AbstractFormField private bool $hideDeleteButton = false; private ?string $thumbnailSize = null; + /** + * secret token of the uploader if the processor opts into `IFileProcessor::usesUploaderToken()` + */ + private ?string $uploaderToken = null; + /** * @var listgetPrefixedId() . '_uploaderToken'; + if ($this->getDocument()->hasRequestData($uploaderTokenId)) { + $uploaderToken = $this->getDocument()->getRequestData($uploaderTokenId); + if (UploaderToken::isValid($uploaderToken)) { + $this->uploaderToken = $uploaderToken; + } + } + if ($this->getDocument()->hasRequestData($this->getPrefixedId())) { $value = $this->getDocument()->getRequestData($this->getPrefixedId()); @@ -88,14 +103,17 @@ public function hasSaveValue() #[\Override] public function getHtmlVariables() { + $context = $this->getProcessorContext(); + return [ 'fileProcessorHtmlElement' => FileProcessor::getInstance()->getHtmlElement( $this->getFileProcessor(), - $this->context + $context ), - 'maxUploads' => $this->getFileProcessor()->getMaximumCount($this->context), + 'maxUploads' => $this->getFileProcessor()->getMaximumCount($context), 'actionButtons' => $this->actionButtons, 'simpleReplace' => $this->simpleReplace, + 'uploaderToken' => $this->getUploaderToken(), ]; } @@ -227,8 +245,9 @@ public function validate() } $fileProcessor = $this->getFileProcessor(); + $context = $this->getProcessorContext(); - $maximumCount = $fileProcessor->getMaximumCount($this->context); + $maximumCount = $fileProcessor->getMaximumCount($context); if ($maximumCount !== null && \count($this->files) > $maximumCount) { $this->addValidationError( new FormFieldValidationError( @@ -243,7 +262,7 @@ public function validate() } foreach ($this->files as $file) { - if (!FileProcessor::getInstance()->canAdopt($fileProcessor, $file, $this->context)) { + if (!FileProcessor::getInstance()->canAdopt($fileProcessor, $file, $context)) { $this->addValidationError( new FormFieldValidationError( 'adopt', @@ -279,6 +298,39 @@ public function addActionButton( return $this; } + /** + * Returns the token of the uploader or `null` if the processor does not use it. + */ + private function getUploaderToken(): ?string + { + if (!$this->getFileProcessor()->usesUploaderToken()) { + return null; + } + + $this->uploaderToken ??= UploaderToken::generate(); + + return $this->uploaderToken; + } + + /** + * Returns the context that is passed to the file processor, including the + * token of the uploader. + * + * @return array + */ + private function getProcessorContext(): array + { + $uploaderToken = $this->getUploaderToken(); + if ($uploaderToken === null) { + return $this->context; + } + + return [ + ...$this->context, + UploaderToken::CONTEXT_KEY => $uploaderToken, + ]; + } + /** * Returns the context for the file processor. * diff --git a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygAttachmentFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygAttachmentFormField.class.php index a8546d93a81..dc7c446b544 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygAttachmentFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/wysiwyg/WysiwygAttachmentFormField.class.php @@ -61,7 +61,7 @@ public function attachmentHandler(?AttachmentHandler $attachmentHandler = null): $attachmentHandler->getObjectType()->objectType, $attachmentHandler->getParentObjectID(), $attachmentHandler->getObjectID(), - WCF::getUser()->userID ?: WCF::getSession()->sessionID, + AttachmentHandler::getTmpHashIdentifier(), ])); if ($this->getDocument()->isAjax()) { diff --git a/wcfsetup/install/files/lib/system/message/quote/MessageQuoteManager.class.php b/wcfsetup/install/files/lib/system/message/quote/MessageQuoteManager.class.php index 0dda356a067..332569ef03e 100644 --- a/wcfsetup/install/files/lib/system/message/quote/MessageQuoteManager.class.php +++ b/wcfsetup/install/files/lib/system/message/quote/MessageQuoteManager.class.php @@ -459,6 +459,12 @@ public function reset(): void */ protected function updateSession(): void { + // Guests never receive the list, see `headIncludeJavaScript.tpl`, storing it + // would only start a session. + if (WCF::getUser()->isGuest()) { + return; + } + WCF::getSession()->register('__messageQuotes', [ 'removeQuoteIDs' => $this->removeQuoteIDs, ]); diff --git a/wcfsetup/install/files/lib/system/session/SessionHandler.class.php b/wcfsetup/install/files/lib/system/session/SessionHandler.class.php index 0ac288a471c..09394681e17 100644 --- a/wcfsetup/install/files/lib/system/session/SessionHandler.class.php +++ b/wcfsetup/install/files/lib/system/session/SessionHandler.class.php @@ -20,6 +20,7 @@ use wcf\system\SingletonFactory; use wcf\system\spider\SpiderHandler; use wcf\system\user\storage\UserStorageHandler; +use wcf\system\CLIWCF; use wcf\system\WCF; use wcf\system\WCFACP; use wcf\util\CryptoUtil; @@ -53,6 +54,11 @@ final class SessionHandler extends SingletonFactory */ private bool $disableTracking = false; + /** + * prevents a guest session that has not been stored yet from being stored + */ + private bool $disablePersistence = false; + /** * group data and permissions * @var mixed[][]|null @@ -91,8 +97,40 @@ final class SessionHandler extends SingletonFactory private bool $firstVisit = false; + /** + * true if guests receive a session only once something needs to be stored + */ + private bool $onDemand = false; + + /** + * false while the session only exists within this request + */ + private bool $isPersisted = true; + private string $xsrfToken; + /** + * true if the request carried a valid `XSRF-TOKEN` cookie + */ + private bool $receivedXsrfCookie = false; + + /** + * true once the token of the request was discarded, the `XSRF-TOKEN` cookie + * must no longer be read + */ + private bool $xsrfTokenDiscarded = false; + + /** + * true if the request carried no session, even if one is started during the request + */ + private bool $startedWithoutSession = false; + + /** + * result of the spider detection for guests without a legacy session, `false` if the + * visitor is not a spider + */ + private string|false $detectedSpiderIdentifier; + private const GUEST_SESSION_LIFETIME = 2 * 3600; private const USER_SESSION_LIFETIME = 60 * 86400; @@ -120,6 +158,8 @@ public function __get(string $key): mixed { switch ($key) { case 'sessionID': + $this->persist(); + return $this->sessionID; case 'userID': return $this->user->userID; @@ -132,7 +172,11 @@ public function __get(string $key): mixed return null; } - return $this->legacySession->spiderIdentifier; + if ($this->legacySession !== null) { + return $this->legacySession->spiderIdentifier; + } + + return $this->detectSpider(); case 'pageID': case 'pageObjectID': case 'parentPageID': @@ -160,6 +204,23 @@ public function __get(string $key): mixed protected function init(): void { $this->isACP = (\class_exists(WCFACP::class, false) || \PACKAGE_ID === 0); + + // The CLI authenticates after it already printed output, a session can no + // longer be started on demand at that point. + $this->onDemand = !$this->isACP + && !\class_exists(CLIWCF::class, false) + && self::hasOnDemandGuestSessions(); + } + + /** + * Returns true if guests receive a session only once something needs to be stored. + * + * @since 6.3 + */ + public static function hasOnDemandGuestSessions(): bool + { + // The option does not exist yet while the update to 6.3 is running. + return \defined('VISITOR_ON_DEMAND_SESSION') && \VISITOR_ON_DEMAND_SESSION !== 0; } /** @@ -301,9 +362,24 @@ private function getCookieValue(): string */ public function hasValidCookie(): bool { + if (!$this->isPersisted) { + return false; + } + return $this->getSessionIdFromCookie($this->getParsedCookieData()) === $this->sessionID; } + /** + * Returns false if the session was not stored yet, because guests receive a session + * only once something needs to be stored. + * + * @since 6.3 + */ + public function isPersisted(): bool + { + return $this->isPersisted; + } + /** * @deprecated 5.4 - Sessions are managed automatically. Use loadFromCookie(). */ @@ -334,6 +410,15 @@ public function loadFromCookie(): void if ($hasSession) { $this->maybeRefreshCookie($cookieData); + } elseif ($this->onDemand) { + $this->initGuest(); + $this->startedWithoutSession = true; + + // Nothing replaces the cookie of a session that no longer exists, it would + // otherwise remain until it expires and cost a lookup on every request. + if (isset($_COOKIE[\COOKIE_PREFIX . 'user_session'])) { + $this->expireCookie(); + } } else { $this->create(); } @@ -400,13 +485,27 @@ public function disableTracking(): void $this->disableTracking = true; } + /** + * Prevents a guest session that has not been stored yet from being stored during this + * request. Variables can still be registered, but they only exist within the request + * and no cookie is sent. Intended for requests that the client issues in the background. + * + * @since 6.3 + */ + public function disablePersistence(): void + { + if (!$this->isPersisted) { + $this->disablePersistence = true; + } + } + /** * Initializes security token. */ private function initSecurityToken(): void { $xsrfToken = ''; - if (!empty($_COOKIE['XSRF-TOKEN'])) { + if (!$this->xsrfTokenDiscarded && !empty($_COOKIE['XSRF-TOKEN'])) { // We intentionally do not extract the signed value and instead just verify the correctness. // // The reason is that common JavaScript frameworks can use the contents of the `XSRF-TOKEN` cookie as-is, @@ -422,6 +521,7 @@ private function initSecurityToken(): void || CryptoUtil::getValueFromSignedString($_COOKIE['XSRF-TOKEN']) !== null ) { $xsrfToken = $_COOKIE['XSRF-TOKEN']; + $this->receivedXsrfCookie = true; } } @@ -432,25 +532,52 @@ private function initSecurityToken(): void $xsrfToken = Hex::encode(\random_bytes(16)); } - // We construct the cookie manually instead of using HeaderUtil::setCookie(), because: - // 1) We don't want the prefix. The `XSRF-TOKEN` cookie name is a standard name across applications - // and it is supported by default in common JavaScript frameworks. - // 2) We want to set the SameSite=lax parameter. - // 3) We don't want the HttpOnly parameter. + // Guests without a session are protected by `isSameOriginRequest()` instead, + // the cookie is sent once the session is persisted. + if ($this->isPersisted) { + $this->sendXsrfCookie($xsrfToken); + } + } - $sameSite = '; SameSite=lax'; + $this->xsrfToken = $xsrfToken; + } - // Workaround for WebKit Bug #255524. - // https://bugs.webkit.org/show_bug.cgi?id=255524 - $sameSite = ''; + /** + * Discards the token, the next call to `getSecurityToken()` generates a new one. + */ + private function discardSecurityToken(): void + { + unset($this->xsrfToken); + $this->receivedXsrfCookie = false; + $this->xsrfTokenDiscarded = true; + } - \header( - 'set-cookie: XSRF-TOKEN=' . \rawurlencode($xsrfToken) . '; path=/' . (RouteHandler::secureConnection() ? '; secure' : '') . $sameSite, - false - ); - } + private function expireXsrfCookie(): void + { + \header( + 'set-cookie: XSRF-TOKEN=; path=/; max-age=0' . (RouteHandler::secureConnection() ? '; secure' : ''), + false + ); + } - $this->xsrfToken = $xsrfToken; + private function sendXsrfCookie(string $xsrfToken): void + { + // We construct the cookie manually instead of using HeaderUtil::setCookie(), because: + // 1) We don't want the prefix. The `XSRF-TOKEN` cookie name is a standard name across applications + // and it is supported by default in common JavaScript frameworks. + // 2) We want to set the SameSite=lax parameter. + // 3) We don't want the HttpOnly parameter. + + $sameSite = '; SameSite=lax'; + + // Workaround for WebKit Bug #255524. + // https://bugs.webkit.org/show_bug.cgi?id=255524 + $sameSite = ''; + + \header( + 'set-cookie: XSRF-TOKEN=' . \rawurlencode($xsrfToken) . '; path=/' . (RouteHandler::secureConnection() ? '; secure' : '') . $sameSite, + false + ); } /** @@ -476,7 +603,43 @@ public function checkSecurityToken(string $token): bool // Convert it back before comparing. $token = \str_replace(' ', '+', $token); - return \hash_equals($this->getSecurityToken(), $token); + if (\hash_equals($this->getSecurityToken(), $token)) { + return true; + } + + if ($this->startedWithoutSession && !$this->receivedXsrfCookie) { + return $this->isSameOriginRequest(); + } + + return false; + } + + /** + * Returns true if the browser reports that the request was issued by this site. + * + * Requests lacking both `Sec-Fetch-Site` and `Origin` are rejected, the check must + * fail closed. `same-site` is not sufficient, it includes other subdomains. + */ + private function isSameOriginRequest(): bool + { + // Links and images in user generated content are same-origin too, only methods + // that links and images cannot issue may pass. + if (!\in_array($_SERVER['REQUEST_METHOD'] ?? '', ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return false; + } + + $fetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? ''; + if ($fetchSite !== '') { + return $fetchSite === 'same-origin'; + } + + // Browsers without Fetch Metadata still send the origin with unsafe requests. + $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; + if ($origin === '') { + return false; + } + + return \strtolower($origin) === \strtolower(RouteHandler::getHost()); } /** @@ -498,6 +661,8 @@ public function register(string $key, mixed $value): void $this->variables[$scope][$key] = $value; $this->variablesChanged = true; + + $this->persist(); } /** @@ -507,6 +672,10 @@ public function unregister(string $key): void { $scope = $this->isACP ? 'acp' : 'frontend'; + if (!\array_key_exists($key, $this->variables[$scope] ?? [])) { + return; + } + unset($this->variables[$scope][$key]); $this->variablesChanged = true; } @@ -530,6 +699,17 @@ public function getUser(): User return $this->user; } + /** + * Returns the session tracked for the users online list or `null` if there is none, + * which applies to the ACP and to guests with on-demand sessions. + * + * @since 6.3 + */ + public function getLegacySession(): ?LegacySession + { + return $this->legacySession; + } + /** * Tries to read existing session identified by the given session id. Returns whether * a session could be found. @@ -569,6 +749,7 @@ private function getExistingSession(string $sessionID): bool $this->sessionID = $sessionID; $this->user = $row['userID'] === null ? User::getGuestUser() : new User($row['userID']); $this->variables = $variables; + $this->isPersisted = true; // Update ipAddress, userAgent and lastActivityTime only once per minute to // reduce write traffic to the hot 'user_session' table. @@ -590,7 +771,8 @@ private function getExistingSession(string $sessionID): bool ]); } - if (!$this->isACP) { + // Guests with on-demand sessions are not tracked in the users online list. + if (!$this->isACP && ($row['userID'] !== null || !$this->onDemand)) { // Fetch legacy session. $condition = new PreparedStatementConditionBuilder(); @@ -648,25 +830,76 @@ private function getExistingSession(string $sessionID): bool /** * Creates a new session. + * + * @param int $cookieExpires expiry of the session cookie, `0` for a cookie that ends with the browser session */ - private function create(): void + private function create(int $cookieExpires = 0): void { - $this->sessionID = $this->generateSessionID(); + $this->initGuest(); + $this->store($cookieExpires); + } - $variables = [ + /** + * Initializes a guest session that only exists within this request. + */ + private function initGuest(): void + { + $this->variables = [ 'frontend' => [], 'acp' => [], ]; - - $this->variables = $variables; $this->user = User::getGuestUser(); $this->firstVisit = true; + $this->legacySession = null; + $this->isPersisted = false; + } + + /** + * Stores the session and sends the session cookie, unless this already happened. + * + * Sessions are stored implicitly once a variable is registered or the session id is + * read. Call this method only if a later request depends on the session cookie being + * present, without anything being stored yet. + * + * @throws \LogicException if the headers were already sent + * @since 6.3 + */ + public function persist(): void + { + $this->store(0); + } + + /** + * @param int $cookieExpires expiry of the session cookie, `0` for a cookie that ends with the browser session + */ + private function store(int $cookieExpires): void + { + if ($this->isPersisted) { + return; + } + + if ($this->disablePersistence) { + // Code reading the session id still receives one, it is just never stored. + $this->sessionID ??= $this->generateSessionID(); + + return; + } + + if ($this->onDemand && \headers_sent($file, $line)) { + throw new \LogicException(\sprintf( + "Unable to start the session, output was already sent at %s:%d.", + $file, + $line + )); + } + + $this->sessionID = $this->generateSessionID(); // Maintain legacy session table for users online list. - $this->legacySession = null; + $trackGuest = !$this->isACP && !$this->onDemand; $spiderIdentifier = null; - if (!$this->isACP) { + if ($trackGuest) { $spiderIdentifier = SpiderHandler::getInstance()->getIdentifier(UserUtil::getUserAgent()); if ($spiderIdentifier !== null) { $this->legacySession = $this->getSpiderLegacySession($spiderIdentifier); @@ -686,10 +919,10 @@ private function create(): void UserUtil::getUserAgent(), \TIME_NOW, \TIME_NOW, - \serialize($variables), + \serialize($this->variables), ]); - if (!$this->isACP && $this->legacySession === null) { + if ($trackGuest && $this->legacySession === null) { $this->legacySession = $this->createLegacySession($spiderIdentifier); } @@ -702,8 +935,41 @@ private function create(): void HeaderUtil::setCookie( "user_session", - $this->getCookieValue() + $this->getCookieValue(), + $cookieExpires ); + + // Read before the session counts as persisted, otherwise `initSecurityToken()` would send the cookie a second time. + $xsrfToken = $this->getSecurityToken(); + if (!$this->receivedXsrfCookie) { + $this->sendXsrfCookie($xsrfToken); + } + + $this->isPersisted = true; + + // The variables were written by the insert above. + $this->variablesChanged = false; + + if ($this->onDemand && \ENABLE_DEBUG_MODE !== 0) { + \header('x-session-started-by: ' . $this->getSessionStarter(), false); + } + } + + /** + * Returns the method that caused the session to be started on demand. + */ + private function getSessionStarter(): string + { + $trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS); + for ($i = 0, $length = \count($trace) - 1; $i < $length; $i++) { + if (($trace[$i]['class'] ?? '') === self::class && ($trace[$i + 1]['class'] ?? '') !== self::class) { + $caller = $trace[$i + 1]; + + return isset($caller['class']) ? $caller['class'] . '::' . $caller['function'] : $caller['function']; + } + } + + return 'unknown'; } /** @@ -1012,12 +1278,17 @@ private function changeUserVirtual(User $user): void } // We must delete the old session to not carry over any state across different users. - $this->delete(); + // On login the cookies are replaced below, expiring them would only add redundant + // headers. + $this->deleteSession($user->isGuest()); // If the target user is a registered user ... if (!$user->isGuest()) { - // ... we create a new session with a new session ID ... - $this->create(); + // ... we rotate the XSRF token, a token known before the login must not remain valid ... + $this->discardSecurityToken(); + + // ... we create a new session with a new session ID and a long-lived cookie ... + $this->create(\TIME_NOW + (self::USER_SESSION_LIFETIME + (7 * 86400))); // ... delete the newly created legacy session ... $sql = "DELETE FROM wcf1_session @@ -1065,13 +1336,6 @@ private function changeUserVirtual(User $user): void throw new \LogicException('Unreachable'); } - // Replace the session-lived cookie by a long-lived cookie. - HeaderUtil::setCookie( - 'user_session', - $this->getCookieValue(), - \TIME_NOW + (self::USER_SESSION_LIFETIME + (7 * 86400)) - ); - foreach ($saveVars as $key => $value) { $this->register($key, $value); } @@ -1218,7 +1482,7 @@ public function clearReauthentication(): void */ public function update(): void { - if ($this->doNotUpdate) { + if ($this->doNotUpdate || !$this->isPersisted) { return; } @@ -1300,6 +1564,11 @@ private function legacySessionNeedsUpdate(array $data): bool * Deletes this session and its related data. */ public function delete(): void + { + $this->deleteSession(true); + } + + private function deleteSession(bool $expireCookies): void { // clear storage if (!$this->user->isGuest()) { @@ -1310,7 +1579,35 @@ public function delete(): void $editor->update(['lastActivityTime' => \TIME_NOW]); } - $this->deleteUserSession($this->sessionID); + if ($this->isPersisted) { + $this->deleteUserSession($this->sessionID); + + if ($this->onDemand && $expireCookies) { + $this->expireCookie(); + $this->expireXsrfCookie(); + $this->discardSecurityToken(); + } + } + + if ($this->onDemand) { + // A session that is started later in this request must not inherit the state + // of the deleted one. + $this->variables = [ + 'frontend' => [], + 'acp' => [], + ]; + $this->variablesChanged = false; + $this->legacySession = null; + $this->isPersisted = false; + } + } + + /** + * Instructs the client to delete the session cookie. + */ + private function expireCookie(): void + { + HeaderUtil::setCookie('user_session', '', \TIME_NOW - 86400); } /** @@ -1369,9 +1666,27 @@ public function getLanguageID(): int public function setLanguageID(int $languageID): void { $this->languageID = $languageID; + + // Spiders follow the links of the language chooser, each of them would start a session. + if (!$this->isPersisted && $this->detectSpider() !== null) { + return; + } + $this->register('languageID', $this->languageID); } + /** + * Guests with on-demand sessions have no legacy session that stores the identifier. + */ + private function detectSpider(): ?string + { + if (!isset($this->detectedSpiderIdentifier)) { + $this->detectedSpiderIdentifier = SpiderHandler::getInstance()->getIdentifier(UserUtil::getUserAgent()) ?? false; + } + + return $this->detectedSpiderIdentifier === false ? null : $this->detectedSpiderIdentifier; + } + /** * Resets session-specific storage data. * diff --git a/wcfsetup/install/files/lib/util/HeaderUtil.class.php b/wcfsetup/install/files/lib/util/HeaderUtil.class.php index 075572dabc3..a0e36b9dfa6 100644 --- a/wcfsetup/install/files/lib/util/HeaderUtil.class.php +++ b/wcfsetup/install/files/lib/util/HeaderUtil.class.php @@ -229,7 +229,9 @@ public static function parseOutputStream(StreamInterface $input): StreamInterfac public static function redirect(string $location, bool $sendStatusCode = false, bool $temporaryRedirect = true): void { // https://github.com/WoltLab/WCF/issues/2568 - if (SessionHandler::getInstance()->isFirstVisit()) { + // On-demand sessions must not be started by a redirect, a guest without + // a stored session remains on their first visit anyway. + if (!SessionHandler::hasOnDemandGuestSessions() && SessionHandler::getInstance()->isFirstVisit()) { SessionHandler::getInstance()->register('__wcfIsFirstVisit', true); } diff --git a/wcfsetup/install/files/options.inc.php b/wcfsetup/install/files/options.inc.php index b5b7824bd82..ba1ec0ea32d 100644 --- a/wcfsetup/install/files/options.inc.php +++ b/wcfsetup/install/files/options.inc.php @@ -34,6 +34,7 @@ \define('LANGUAGE_USE_INFORMAL_VARIANT', 0); \define('URL_OMIT_INDEX_PHP', 0); \define('VISITOR_USE_TINY_BUILD', 0); +\define('VISITOR_ON_DEMAND_SESSION', 0); \define('ENABLE_DEVELOPER_TOOLS', 0); \define('LOG_MISSING_LANGUAGE_ITEMS', 0); \define('FORCE_LOGIN', 0); diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index c1a4fd09d92..ae48c4b58a9 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -1660,6 +1660,8 @@ Als Benachrichtigungs-URL in der Konfiguration der sofortigen Zahlungsbestätigu Warnung: Dieser Modus arbeitet sehr restriktiv und untersagt unter anderem jeglichen Schreibzugriff, bitte überprüfe{if !LANGUAGE_USE_INFORMAL_VARIANT}n Sie{/if}, ob alle installierten Plugins mit diesem Modus kompatibel sind.]]> + + Hinweis: Gäste und Suchmaschinen werden in der Liste der Benutzer online nicht mehr angezeigt oder mitgezählt.]]> App-Dashboard angefordert werden, und wird für die Open Graph-Tags zum Teilen verwendet.]]> @@ -3348,6 +3350,7 @@ Erlaubte Dateiendungen: {', '|implode:$attachmentHandler->getFormattedAllowedExt + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index e84667adc03..5ae3b402e3d 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -1643,6 +1643,8 @@ When prompted for the notification URL for the instant payment notifications, pl Warning: This mode is highly restricted and reduces the abilities of guests to interact with your site, such as creating new content. Please review your installed plugins before enabling to see if they are compatible with this setting.]]> + + Note: Guests and search engines are no longer shown or counted in the users online list.]]> app’s dashboard, and is used with the Open Graph tags for sharing.]]> @@ -3273,6 +3275,7 @@ Allowed extensions: {', '|implode:$attachmentHandler->getFormattedAllowedExtensi + diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index abda3a6a15c..7d412d20fc8 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -940,6 +940,19 @@ ->onDelete('CASCADE') ->onUpdate('NO ACTION'), ]), + DatabaseTable::create('wcf1_captcha_question_token') + ->columns([ + BinaryDatabaseTableColumn::create('nonce') + ->notNull() + ->length(16), + NotNullInt10DatabaseTableColumn::create('expires'), + ]) + ->indices([ + DatabaseTablePrimaryIndex::create() + ->columns(['nonce']), + DatabaseTableIndex::create('expires') + ->columns(['expires']), + ]), DatabaseTable::create('wcf1_category') ->columns([ ObjectIdDatabaseTableColumn::create('categoryID'), @@ -1585,6 +1598,26 @@ ->onDelete('CASCADE') ->onUpdate('NO ACTION'), ]), + DatabaseTable::create('wcf1_file_uploader_token') + ->columns([ + NotNullInt10DatabaseTableColumn::create('fileID'), + BinaryDatabaseTableColumn::create('tokenHash') + ->length(16), + ]) + ->indices([ + DatabaseTablePrimaryIndex::create() + ->columns(['fileID']), + DatabaseTableIndex::create('tokenHash') + ->columns(['tokenHash']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['fileID']) + ->referencedTable('wcf1_file') + ->referencedColumns(['fileID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + ]), /* As the flood control table can be a high traffic table and as it is periodically emptied, there is no foreign key on the `objectTypeID`