Back to Blog
high SEVERITY8 min read

How CSRF vulnerability happens in JavaScript fetch() calls and how to fix it

A high-severity CSRF vulnerability was discovered in Moodle's VvvebJs page builder where POST requests to `saveReusableUrl` and `saveUrl` endpoints lacked CSRF token validation. Without proper sesskey inclusion, attackers could trick authenticated users into executing unauthorized page modifications. The fix adds Moodle's sesskey token to both client-side fetch requests and enforces server-side validation with `require_sesskey()`.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 17, 2026•Reviewed August 17, 2026

Answer Summary

This is a Cross-Site Request Forgery (CSRF) vulnerability (CWE-352) in Moodle's VvvebJs page builder JavaScript code. The builder.js file sent POST requests to save page content without including Moodle's sesskey CSRF token, allowing attackers to forge requests from authenticated users. The fix adds `sesskey: M.cfg.sesskey` to the request payload in two locations (lines 2030 and 2063) and enforces token validation with `require_sesskey()` in save.php.

Vulnerability at a Glance

cweCWE-352
fixAdd sesskey to fetch() request body and enforce require_sesskey() validation
riskAttackers can trick authenticated admins into modifying site pages
languageJavaScript (client-side) and PHP (server-side)
root causePOST requests to saveReusableUrl and saveUrl omitted CSRF tokens
vulnerabilityCross-Site Request Forgery (CSRF)

The Vulnerability: Missing CSRF Protection in Moodle's Page Builder

In Moodle's VvvebJs page builder component, we discovered a high-severity CSRF vulnerability in _editor/VvvebJs/libs/builder/builder.js. The code responsible for saving reusable page components and full page content made POST requests without including any CSRF protection tokens. Specifically, at lines 2030 and 2063, the builder's save functions constructed request payloads that omitted Moodle's standard sesskey token used for CSRF protection.

This vulnerability affected two critical save operations:
- saveReusableComponent() - saves individual page components for reuse
- save() - saves complete page HTML to the server

Both functions used JavaScript's fetch() API to send POST requests with user-controlled data, but neither included the session key that Moodle relies on to verify request authenticity. The server-side endpoint at _editor/save.php also lacked the corresponding require_sesskey() validation check, creating a complete CSRF vulnerability chain.

Understanding the Vulnerable Code Pattern

Let's examine the specific vulnerable code in the saveReusableComponent() function at line 2030:

let data = {type, name, html : element.outerHTML};

fetch(saveReusableUrl, {method : "POST", body : new URLSearchParams(data)})
    .then((response) => {
        if (response.status == 200) {
            displayToast("bg-success", "Success", "Component saved!", 1500);
        }
    });

The problem is immediately visible: the data object contains only type, name, and html fields. There's no sesskey field being sent with the request. When this data is serialized via new URLSearchParams(data) and sent to the server, Moodle has no way to verify that this request came from a legitimate user action rather than a forged request from a malicious website.

The same pattern appeared in the main save() function at line 2063:

return fetch(saveUrl, {
    method  : "POST",
    headers : {'Content-Type' : 'application/x-www-form-urlencoded; charset=UTF-8'},
    body    : new URLSearchParams(data)
})

Again, the data object (constructed earlier in the function) lacked the critical sesskey field.

On the server side, _editor/save.php performed authentication checks but critically missed CSRF validation:

require_login();
require_capability("moodle/site:config", context_system::instance());

$page = required_param("page", PARAM_TEXT);

The code verified the user was logged in and had appropriate capabilities, but never called require_sesskey() to validate the CSRF token.

How This CSRF Attack Works

An attacker could exploit this vulnerability through a carefully crafted attack scenario:

  1. Setup: The attacker creates a malicious webpage at evil.com containing hidden JavaScript
  2. Target: A Moodle administrator with moodle/site:config capability visits evil.com while logged into their Moodle instance
  3. Exploitation: The malicious page executes JavaScript that sends a POST request to the victim's Moodle site:
// Attacker's malicious code
fetch('https://victim-moodle.edu/_editor/save.php', {
    method: 'POST',
    credentials: 'include', // Include victim's cookies
    headers: {'Content-Type': 'application/x-www-form-urlencoded'},
    body: new URLSearchParams({
        page: 'frontpage',
        html: '<script>/* malicious code */</script>',
        title: 'Compromised Page'
    })
});
  1. Impact: Because the victim's browser automatically includes their authenticated session cookies with the request, and because the server doesn't validate a CSRF token, the malicious page content is saved to the Moodle site.

The attacker could:
- Inject malicious JavaScript into public-facing pages
- Modify site layouts to display phishing content
- Delete or corrupt existing page content
- Save malicious reusable components that other admins might use

The requirement for moodle/site:config capability limits the victim pool to site administrators, but these are exactly the high-value targets attackers pursue. A successful CSRF attack against an admin account could compromise the entire Moodle installation.

The Fix: Adding Synchronizer Token Protection

The fix implements the synchronizer token pattern, a proven CSRF defense mechanism. It consists of two coordinated changes:

Client-Side: Including the Session Key

In builder.js, the session key is now explicitly added to both save operations. At line 2030:

// Before (vulnerable)
let data = {type, name, html : element.outerHTML};

// After (secure)
let data = {type, name, html : element.outerHTML, sesskey : M.cfg.sesskey};

And at line 2063:

// Before (vulnerable)
data["html"] = clearHtml();

return fetch(saveUrl, {
    method  : "POST",
    headers : {'Content-Type' : 'application/x-www-form-urlencoded; charset=UTF-8'},
    body    : new URLSearchParams(data)
})

// After (secure)
data["html"] = clearHtml();

data["sesskey"] = M.cfg.sesskey;  // <-- Added CSRF token

return fetch(saveUrl, {
    method  : "POST",
    headers : {'Content-Type' : 'application/x-www-form-urlencoded; charset=UTF-8'},
    body    : new URLSearchParams(data)
})

The M.cfg.sesskey value is a Moodle global object that contains the user's unique session key. This token is:
- Generated server-side and tied to the user's session
- Unpredictable and unique per session
- Available to legitimate JavaScript but not to external attackers

Server-Side: Enforcing Token Validation

In save.php, a single line addition enforces token validation:

require_login();
require_sesskey();  // <-- Added CSRF validation
require_capability("moodle/site:config", context_system::instance());

The require_sesskey() function compares the sesskey parameter from the POST request against the session key stored server-side. If they don't match (or if the parameter is missing), the request is immediately rejected with an error.

Why This Fix Works

This two-part fix creates a security gate that only legitimate requests can pass:

  1. Legitimate requests: When a real admin clicks "Save" in the builder interface, the JavaScript includes their valid session key. The server validates it and processes the request.

  2. Forged requests: When an attacker's malicious page tries to forge a request, it can't include the correct session key because:
    - The token is generated server-side and never exposed to external sites
    - Same-origin policy prevents the attacker's JavaScript from reading M.cfg.sesskey from the victim's Moodle tab
    - The token changes with each session, so previously captured tokens become invalid

The server rejects any request without a valid session key, preventing the CSRF attack.

Key Takeaways

  • The VvvebJs builder's fetch() calls to saveReusableUrl and saveUrl lacked sesskey parameters, creating a complete CSRF vulnerability when combined with missing server-side validation
  • Moodle's M.cfg.sesskey global provides the synchronizer token, and should be included in every state-changing POST request's data payload
  • Server-side require_sesskey() validation is non-negotiable - even if client code includes the token, attackers can bypass client-side checks
  • CSRF protection requires both client and server changes - this fix demonstrates the necessity of coordinated defense at both layers
  • The vulnerability required moodle/site:config capability, limiting the attack surface to administrators, but making successful exploitation highly impactful

How Orbis AppSec Detected This

  • Source: User-controlled data from the page builder interface (component HTML, page content, titles)
  • Sink: fetch() POST requests to saveReusableUrl and saveUrl endpoints in _editor/VvvebJs/libs/builder/builder.js at lines 2032 and 2063
  • Missing control: No CSRF token (sesskey) included in request payload; no require_sesskey() validation in _editor/save.php
  • CWE: CWE-352 (Cross-Site Request Forgery)
  • Fix: Added sesskey: M.cfg.sesskey to both fetch request bodies and enforced validation with require_sesskey() in the server-side handler

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This CSRF vulnerability in Moodle's VvvebJs page builder demonstrates why CSRF protection must be implemented as a coordinated defense across both client and server layers. The vulnerability existed because two separate weaknesses aligned: missing token inclusion in JavaScript fetch() calls and absent server-side validation. The fix properly implements the synchronizer token pattern by adding Moodle's sesskey to request payloads and enforcing validation with require_sesskey().

For developers working with AJAX-heavy applications, this case study highlights a critical lesson: framework-provided CSRF mechanisms like Moodle's sesskey system must be consistently applied to every state-changing operation, not just traditional form submissions. Modern JavaScript applications using fetch() or XMLHttpRequest require the same CSRF protections as classic form-based workflows.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #162

Related Articles

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.

critical

No Rate Limit on /api/uploads/presign Enables DoS

The `/api/uploads/presign` endpoint accepted unlimited concurrent requests to generate storage presigned URLs, giving an attacker a free lever to exhaust storage-provider quotas and server resources. The fix adds an `express-rate-limit` middleware capping each client to 30 requests per minute on that route.

high

CVE-2026-54673: builder-util-runtime Leaks Auth Headers on Redirect

electron-updater and electron-builder rely on builder-util-runtime to fetch update manifests and artifacts over HTTP. A flaw in that shared HTTP executor allowed credential headers attached to the original update-feed request to be re-sent after a redirect, exposing them to any host the redirect pointed to. The project fixes this by upgrading builder-util-runtime to 9.7.0 and collapsing a duplicate, older copy of the package that electron-updater had pinned on its own.

high

image-size 1.2.1 DoS: Zero-Valued Dimensions in Image Buffer Parser

A high-severity denial-of-service vulnerability in image-size 1.2.1 allows attackers to crash Node.js services using malicious image buffers with zero-valued dimensions. The fix removes the vulnerable `queue` dependency and tightens dimension validation in version 2.0.3.