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
Published August 17, 2026Reviewed 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.

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 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.

References

Frequently Asked Questions

What is CSRF (Cross-Site Request Forgery)?

CSRF is an attack where malicious websites trick authenticated users into submitting unwanted requests to a web application they're logged into. Without CSRF tokens, the application can't distinguish legitimate requests from forged ones.

How do you prevent CSRF in JavaScript fetch() calls?

Include a unique, unpredictable token (like Moodle's sesskey) in every state-changing request. The server must validate this token matches the user's session. Use synchronizer tokens in request bodies or custom headers, never in URLs.

What CWE is CSRF?

CSRF is classified as CWE-352 (Cross-Site Request Forgery). It's part of the OWASP Top 10 and occurs when applications don't verify that requests originate from legitimate users rather than malicious third-party sites.

Is SameSite cookie attribute enough to prevent CSRF?

While SameSite=Lax or Strict cookies provide defense-in-depth, they're not sufficient alone. Browser support varies, and Lax mode still allows GET requests. Always implement synchronizer tokens for state-changing operations as primary defense.

Can static analysis detect CSRF vulnerabilities?

Yes, static analysis tools can detect missing CSRF tokens by tracking POST/PUT/DELETE requests without token inclusion and identifying endpoints without token validation. Tools like Semgrep can flag fetch() calls lacking security tokens.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #162

Related Articles

critical

How Command Injection via Unescaped Line Terminators Happens in Node.js and How to Fix It

A critical command injection vulnerability (CVE-2026-9277) was discovered in the shell-quote npm package version 1.8.3, where unescaped line terminators could allow attackers to execute arbitrary code. This fix upgrades shell-quote to version 1.9.0 using npm overrides to ensure all instances in the dependency tree are patched, eliminating the attack vector across the entire application.

critical

How Distributed Lock Takeover Happens in Node.js and How to Fix It

A critical vulnerability in `redis-lock/server.mjs` allowed any authenticated client to release another client's lock by guessing predictable holder identifiers like process IDs or hostnames. The fix implements cryptographically random `lockId` values that are minted on lock acquisition and validated on release, eliminating the exploit primitive entirely.

high

How Denial of Service via Infinite Loop happens in JavaScript (nanoid) and how to fix it

A high-severity denial of service vulnerability (CVE-2026-67213) was discovered in nanoid versions before 5.1.6 and 3.3.18, where the `customAlphabet` function could enter an infinite loop during random ID generation. The fix upgrades the transitive nanoid dependency from 3.3.16 to 3.3.18 using pnpm overrides, ensuring the vulnerable code path is eliminated from the entire dependency tree including PostCSS.

high

How Information Disclosure via Unstripped Credential Headers Happens in Electron Apps and How to Fix It

A high-severity vulnerability (CVE-2026-54673) in the builder-util-runtime package allowed sensitive credential headers to leak during HTTP redirects in Electron applications. The fix upgrades builder-util-runtime from version 9.5.1 to 9.7.0, which properly strips authentication headers before following redirects to prevent information disclosure.

high

How Command Injection happens in PHP and how to fix it

A high-severity command injection vulnerability was discovered in `lib/Controller/Helper.php` where the `corruptline()` method used `exec()` to run sed and awk commands with user-controlled input. The fix replaced all shell command execution with native PHP file operations using `SplFileObject`, eliminating the command injection attack surface entirely.

high

How Missing CSRF Middleware happens in Express.js and how to fix it

A high-severity CSRF vulnerability was discovered in `libProxy.js` of an Express.js application — the app had no CSRF middleware protecting its state-changing routes, leaving them open to cross-site request forgery attacks. The fix introduces a `csrf` token library, a `/csrf-token` endpoint to issue tokens, and a middleware that validates `x-csrf-token` headers or `_csrf` body fields on all non-safe HTTP methods. This proactive hardening removes an exploit primitive that could be chained with ot