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:
- Setup: The attacker creates a malicious webpage at
evil.comcontaining hidden JavaScript - Target: A Moodle administrator with
moodle/site:configcapability visitsevil.comwhile logged into their Moodle instance - 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'
})
});
- 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:
-
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.
-
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 readingM.cfg.sesskeyfrom 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.
Prevention & Best Practices
To avoid CSRF vulnerabilities in your own applications:
1. Always Use CSRF Tokens for State-Changing Operations
Any POST, PUT, DELETE, or PATCH request that modifies server state must include a CSRF token. This applies to:
- Form submissions
- AJAX/fetch() requests
- API calls that modify data
// Good: Include CSRF token in fetch requests
fetch('/api/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken() // Custom header approach
},
body: JSON.stringify(data)
});
2. Validate Tokens Server-Side
Client-side token inclusion is meaningless without server-side validation:
// Good: Always validate CSRF tokens
if (!validate_csrf_token($_POST['csrf_token'])) {
http_response_code(403);
die('CSRF token validation failed');
}
3. Use Framework-Provided CSRF Protection
Modern frameworks provide built-in CSRF protection:
- Laravel: @csrf blade directive and VerifyCsrfToken middleware
- Django: {% csrf_token %} template tag and middleware
- Express.js: csurf middleware
- Moodle: sesskey system as demonstrated in this fix
Always use these framework features rather than implementing your own.
4. Implement Defense-in-Depth
Combine CSRF tokens with additional protections:
- Set SameSite=Strict or SameSite=Lax on session cookies
- Verify Origin and Referer headers for additional validation
- Require re-authentication for sensitive operations
- Implement rate limiting to slow down attack attempts
5. Use Static Analysis Tools
Tools like Semgrep can detect missing CSRF tokens:
# Semgrep rule to detect fetch() without CSRF tokens
rules:
- id: fetch-missing-csrf-token
pattern: |
fetch($URL, {method: "POST", ...})
message: "POST request may be missing CSRF token"
6. Security Testing
Include CSRF testing in your security review process:
- Manual testing: Try submitting requests without tokens
- Automated scanning: Use tools like Burp Suite or OWASP ZAP
- Code review: Check that all state-changing endpoints validate tokens
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 tosaveReusableUrlandsaveUrlendpoints in_editor/VvvebJs/libs/builder/builder.jsat 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.sesskeyto both fetch request bodies and enforced validation withrequire_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.