Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6341887
Stop writing guest sessions for first-visit tracking and trigger pend…
dtdesign Sep 29, 2026
f59dba5
Add opt-in on-demand guest sessions option and sessionless signed cap…
dtdesign Sep 29, 2026
86dd3cf
Use a random guest attachment tmpHash instead of the session ID while…
dtdesign Sep 29, 2026
d9b5957
Start guest sessions on demand, only once session state needs to be s…
dtdesign Sep 29, 2026
a7f0ad9
Defer the XSRF-TOKEN cookie until the guest session is persisted and …
dtdesign Sep 29, 2026
84e4359
Extract the attachment tmpHash identifier into AttachmentHandler::get…
dtdesign Sep 30, 2026
a9c8a49
Add a helper to probe the legacy session
dtdesign Sep 30, 2026
b4ddbd7
Rotate the XSRF token on login and clear session cookies on logout an…
dtdesign Sep 30, 2026
b5918a6
Improve the phrasing to highlight the benefits
dtdesign Sep 30, 2026
fd3b43d
Refresh the XSRF token in reused AJAX and upload URLs when sending
dtdesign Sep 30, 2026
d701bc7
Ask a new captcha question when the token was already used and report…
dtdesign Sep 30, 2026
6f41290
Detect spiders for guests without a legacy session
dtdesign Sep 30, 2026
6c2ef6e
Track ownership of guest contact form uploads with a secret uploader …
dtdesign Sep 30, 2026
61f3f70
Reset session variables and legacy session when deleting an on-demand…
dtdesign Sep 30, 2026
5ebc831
Restrict the same-origin XSRF fallback for sessionless guests to unsa…
dtdesign Sep 30, 2026
ebdd045
Do not start on-demand guest sessions for cronjob and background queu…
dtdesign Sep 30, 2026
915f142
Do not store quote removal state in the session for guests
dtdesign Sep 30, 2026
b39fa94
Require the tmpHash to delete temporary attachments of guests, preven…
dtdesign Sep 30, 2026
33da0b0
Reject uploader tokens with a trailing newline and keep released cont…
dtdesign Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions com.woltlab.wcf/option.xml
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,11 @@
<optiontype>boolean</optiontype>
<defaultvalue>0</defaultvalue>
</option>
<option name="visitor_on_demand_session">
<categoryname>module.system</categoryname>
<optiontype>boolean</optiontype>
<defaultvalue>0</defaultvalue>
</option>
<option name="module_article">
<categoryname>module.content</categoryname>
<optiontype>boolean</optiontype>
Expand Down
7 changes: 7 additions & 0 deletions com.woltlab.wcf/templates/footer.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
<script data-relocate="true">
require(["WoltLabSuite/Core/BackgroundQueue"], (BackgroundQueue) => BackgroundQueue.invoke());
</script>
{/if}

<div class="pageFooterStickyNotice">
{event name='pageFooterStickyNotice'}

Expand Down
29 changes: 14 additions & 15 deletions com.woltlab.wcf/templates/permissionDenied.tpl
Original file line number Diff line number Diff line change
@@ -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'}
<li id="backToReferrer" style="display: none"><a href="#" class="button" rel="noopener">{icon name='arrow-left'} {lang}wcf.page.error.backward{/lang}</a></li>
{/capture}

<script data-relocate="true">
(function() {
if (document.referrer) {
var backToReferrer = elById('backToReferrer');
elShow(backToReferrer);
backToReferrer.children[0].href = document.referrer;
}
})();
</script>
{/if}
{capture assign='contentHeaderNavigation'}
<li id="backToReferrer" style="display: none"><a href="#" class="button" rel="noopener">{icon name='arrow-left'} {lang}wcf.page.error.backward{/lang}</a></li>
{/capture}

<script data-relocate="true">
(function() {
// Visitors arriving from another site, e.g. a search engine, must not be sent back there.
if (document.referrer && new URL(document.referrer).origin === window.location.origin) {
var backToReferrer = elById('backToReferrer');
elShow(backToReferrer);
backToReferrer.children[0].href = document.referrer;
}
})();
</script>

{include file='header' __disableAds=true}

Expand Down
7 changes: 5 additions & 2 deletions com.woltlab.wcf/templates/shared_fileProcessorFormField.tpl
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
{unsafe:$fileProcessorHtmlElement}
{if $uploaderToken !== null}
<input type="hidden" id="{$field->getPrefixedId()}_uploaderToken" name="{$field->getPrefixedId()}_uploaderToken" value="{$uploaderToken}">
{/if}

{assign var="files" value=$field->getFiles()}
{if $field->isBigPreview()}
<div class="fileUpload__preview">
{if $field->getValue()}
{assign var="file" value=$files|reset}
{unsafe:$file->toHtmlElement()}
{unsafe:$file->toHtmlElement(null, $uploaderToken)}
{/if}
</div>
{else}
<ul class="fileList">
{foreach from=$files item=file}
<li class="fileList__item">
{unsafe:$file->toHtmlElement()}
{unsafe:$file->toHtmlElement(null, $uploaderToken)}
</li>
{/foreach}
</ul>
Expand Down
1 change: 1 addition & 0 deletions phpstan-ambient.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion ts/WoltLabSuite/Core/Ajax/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
}
Expand Down
9 changes: 7 additions & 2 deletions ts/WoltLabSuite/Core/Api/Files/DeleteFile.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result";

export async function deleteFile(fileId: number): Promise<ApiResult<[]>> {
export async function deleteFile(fileId: number, uploaderToken?: string): Promise<ApiResult<[]>> {
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);
}
Expand Down
5 changes: 4 additions & 1 deletion ts/WoltLabSuite/Core/Api/Files/GenerateThumbnails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ type Response = {
thumbnails: Thumbnail[];
};

export async function generateThumbnails(fileID: number): Promise<ApiResult<Response>> {
export async function generateThumbnails(fileID: number, uploaderToken?: string): Promise<ApiResult<Response>> {
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 {
Expand Down
23 changes: 18 additions & 5 deletions ts/WoltLabSuite/Core/Component/Attachment/Entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.", {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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";
Expand All @@ -152,7 +165,7 @@ function getDeleteAttachButton(
return;
}

(await deleteFile(fileId)).unwrap();
(await deleteFile(fileId, uploaderToken)).unwrap();

dispatchToCkeditor(editor).removeAttachment({
attachmentId,
Expand All @@ -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");

Expand All @@ -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);
Expand Down
16 changes: 11 additions & 5 deletions ts/WoltLabSuite/Core/Component/Attachment/List.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -79,7 +85,7 @@ export function setup(editorId: string): void {

let showOrder = -1;
uploadButton.addEventListener("uploadStart", (event: CustomEvent<WoltlabCoreFileElement>) => {
fileToAttachment(fileList, event.detail, editor);
fileToAttachment(fileList, event.detail, editor, getUploaderToken(uploadButton));

const context = JSON.parse(uploadButton.dataset.context!) as Record<string, unknown>;
context.showOrder = ++showOrder;
Expand Down Expand Up @@ -126,7 +132,7 @@ export function setup(editorId: string): void {
const existingFiles = container.querySelector<HTMLElement>(".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") {
Expand Down
15 changes: 15 additions & 0 deletions ts/WoltLabSuite/Core/Component/File/Helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).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");
Expand Down
11 changes: 8 additions & 3 deletions ts/WoltLabSuite/Core/Component/File/Upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -121,15 +122,19 @@ function notifyChunkProgress(element: WoltlabCoreFileElement, currentChunk: numb
element.dispatchEvent(event);
}

async function chunkUploadCompleted(fileElement: WoltlabCoreFileElement, result: UploadChunkResponse): Promise<void> {
async function chunkUploadCompleted(
fileElement: WoltlabCoreFileElement,
result: UploadChunkResponse,
uploaderToken: string | undefined,
): Promise<void> {
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 generateThumbnails(result.fileID)).unwrap();
const { filename, fileSize, mimeType, thumbnails } = (await generateThumbnails(result.fileID, uploaderToken)).unwrap();
fileElement.setThumbnails(thumbnails);
fileElement.updateFileData(filename, fileSize, mimeType);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
10 changes: 9 additions & 1 deletion ts/WoltLabSuite/Core/Form/Builder/Field/FileProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 15 additions & 3 deletions ts/WoltLabSuite/Core/Form/XsrfToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>("input.xsrfTokenInput").forEach((input) => {
input.value = token;
});
},
{ capture: true },
);
}
Loading
Loading