Back to Blog
critical SEVERITY7 min read

redmine_drawio View Hook Inlines Base64 Redmine API Keys

The Redmine drawio plugin's body-bottom view listener embedded the logged-in user's Redmine REST API key into client-side JavaScript on every wiki and issue page, "protected" only by Base64 encoding of the reversed string. Any script on the page — or anyone with browser developer tools, a cached copy of the HTML, or a proxy log — could decode it in one line and act as that user through the Redmine REST API. The fix removes the embedded credential from the rendered page entirely; the plugin's dia

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published October 4, 2026•Reviewed October 4, 2026

Answer Summary

The affected code is the Redmine drawio plugin's `Redmine::Hook::ViewListener` body-bottom hook, which injected the current user's Redmine REST API key into inline JavaScript on every wiki and issue page in the 1.5.4 line. The key was obfuscated with a string reverse plus Base64, which is trivially reversible, so any party able to read the page HTML — a malicious browser extension, a third-party script, an XSS payload, or a proxy/CDN cache — could recover a long-lived bearer credential and drive the Redmine REST API as that user, including admin actions if the victim was an administrator. The fix removes the encoded key from the rendered markup and relies on the user's authenticated session for same-origin requests; it ships in the 1.5.5 development line, though no fixed release version is recorded in the advisory data. This is CWE-798 (Use of Hard-coded Credentials); no CVE or GHSA has been assigned.

Vulnerability at a Glance

cweCWE-798
fixStop emitting the API key in rendered HTML and authenticate plugin requests with the existing session and CSRF token
riskFull account takeover via the Redmine REST API; admin escalation if the victim is an administrator
languageRuby (Redmine plugin) with inline JavaScript
root causeThe plugin's body-bottom view hook serialized `User.current`'s API key into an inline `<script>` variable using reversed Base64 as "encryption"
vulnerabilityHard-coded / client-exposed credential (Redmine REST API key leaked into page JavaScript)

Summary

The Redmine drawio plugin shipped a credential leak that no amount of code review of the "crypto" would have saved: the logged-in user's Redmine REST API key was reversed, Base64-encoded, and written into an inline <script> block rendered on every wiki and issue page. Reversing that encoding takes one line of JavaScript in a browser console. The fix removes the credential from the rendered page entirely.

The Short Version

A Redmine::Hook::ViewListener in the plugin ran on the body-bottom hook — the hook that fires on essentially every Redmine layout render — and built a JavaScript variable from User.current's API key. The intent was presumably to let the client-side drawio integration call Redmine's REST endpoints when loading and saving diagram attachments. The effect was to hand a long-lived bearer credential to the page DOM, where anything running in that page can read it.

Affected not applicable (first-party code) — the plugin's body-bottom view listener in the 1.5.4 line
Fixed in not applicable (first-party code) — fix commit removes the embedded key; plugin version bumped to 1.5.5
Ecosystem unknown (Redmine plugin, distributed from source)
CVE / GHSA not assigned
CWE CWE-798: Use of Hard-coded Credentials

A note on confidence: this fix was produced and reviewed, but no automated test suite could be executed against the repository, so treat the change as a reviewed suggestion rather than a regression-tested release.

The Vulnerability Explained

Redmine's REST API key is not a session token. It is a long-lived, non-expiring bearer credential tied to a user account, accepted as the key query parameter or the X-Redmine-API-Key header on every REST endpoint the user can reach. Possessing it is functionally equivalent to possessing the account.

The plugin's view listener wrote that credential into the page. The single offending line built a script variable from the current user's key with a reverse-then-Base64 transform. The pattern, reduced to its essentials:

# Emitted by the plugin's body-bottom view hook on every wiki and issue page
javascript_tag(
  "var drawioApiKey = '#{Base64.strict_encode64(User.current.api_key.reverse)}';"
)

String#reverse followed by Base64 is encoding, not encryption. There is no key, no secret, nothing an attacker does not already have. Recovering the plaintext is a one-liner against the value sitting in the DOM:

atob(drawioApiKey).split('').reverse().join('');
// -> the victim's live Redmine REST API key

Why "only the user can see their own key" is not a defence

The usual objection is that the rendered key belongs to the person viewing the page, so nothing crosses a trust boundary. That is wrong in several concrete ways on this code path:

  • Any script on the page can read it. Because the emission happens on the body-bottom hook, the variable exists on wiki pages and issue pages — exactly the pages that render user-authored content, embedded SVG diagrams, and macro output. The plugin's own changelog records an XSS fix in SVG sanitization in the immediately preceding release; combine a script-injection bug on a wiki page with a global drawioApiKey and you have silent, one-request credential exfiltration instead of a scoped XSS. One bug class feeding the other is the real severity driver here.
  • Browser extensions read it for free. A content script with access to the Redmine origin does not need an exploit; it reads the variable.
  • HTML gets stored in places credentials should not go. Reverse-proxy and CDN response logs, browser disk cache, "Save page as", support screenshots, HAR files attached to bug reports, corporate DLP archives — all of them now contain a durable account credential rather than a short-lived session cookie.
  • Session logout does not help. A session cookie dies on logout or expiry. A Redmine API key does not. An attacker who scraped the variable in March still has valid access in September unless the key is manually rotated.

Attack scenario

An attacker with permission to edit a single wiki page on a shared Redmine instance plants content that executes in the page context (via an SVG-based injection, a permissive HTML macro, or any other script-execution primitive on that page). The payload does not need to touch the attacker's own data at all:

  1. Read drawioApiKey from the page.
  2. atob(...) and reverse it to recover the viewer's plaintext key.
  3. fetch it out to an attacker-controlled endpoint.
  4. Wait for an administrator to open the page.

With an admin's key, the REST API allows creating a new admin user, reading every private project and issue, and downloading every attachment — all as authenticated API traffic indistinguishable from legitimate automation. No password, no MFA prompt, no session hijack required.

The Fix

The change removes the credential emission from the view hook. The plugin no longer serializes User.current.api_key — encoded or otherwise — into the response body.

Before (pattern): the body-bottom hook renders an inline script containing the reversed-Base64 API key, so the credential is present in the HTML of every wiki and issue page.

After: the hook renders no credential at all. The drawio integration's attachment load and save requests are ordinary same-origin requests made by an already-authenticated browser, so they are authorized by the Redmine session cookie and protected by the Rails CSRF token that Redmine already injects into the layout. The authorization data never needs to be readable by page JavaScript, which is precisely the property a credential should have.

This is the important conceptual move: the API key was being used as a transport for authorization the browser already had. Once you notice that, the fix is subtraction rather than substitution — there is no "better encoding" to reach for, because the client did not need the secret in the first place. Had the client genuinely needed a token (for example, for a cross-origin call to a self-hosted drawio instance), the correct answer would be a short-lived, narrowly scoped token minted per diagram, not the user's permanent account key.

Two unrelated maintenance changes rode along in the same release and are worth distinguishing from the security fix so nobody mistakes them for part of it:

html = context[:controller].send(:render_to_string,
                                { partial: 'redmine_drawio/macro_dialog',
                                  formats: [:html],
                                  locals: { svg_enabled: DrawioSettings.svg_enabled? } })

The explicit formats: [:html] pins the macro dialog partial lookup so render_to_string cannot resolve it against a non-HTML format inherited from the current request. Separately, a stylesheet rule hides a stray span[title="Edit"] control in the drawio viewer (upstream issue #158). Neither affects the credential exposure; the plugin version was bumped from 1.5.4 to 1.5.5 to carry the set.

If you ran an affected version

Removing the line stops future exposure; it does not revoke what was already published. Rotate API keys for every user who browsed wiki or issue pages on an affected install (My Account → API access key), and treat any retained HTML caches or response logs from that period as containing live credentials until rotation completes.

Key Takeaways

  • Reversing a string and Base64-encoding it is obfuscation with a cost and no benefit. It made the exposed Redmine API key marginally harder to spot in a code review while providing zero protection against atob(...).split('').reverse().join('').
  • A Redmine REST API key is not a session token. It never expires, logout does not revoke it, and it authorizes the full REST surface — including admin user creation. It belongs in the server process and in the user's own clipboard, nowhere else.
  • A body-bottom view hook runs on every page. Anything a Redmine::Hook::ViewListener emits there lands on the pages that render untrusted wiki and issue content, which is the worst possible neighbourhood for a credential.
  • Credential-in-DOM turns any XSS into account takeover. The plugin's own preceding release fixed XSS in SVG sanitization; a global drawioApiKey on the same pages would have upgraded that bug from script execution to permanent credential theft.
  • If the browser is already authenticated, do not ship it a second credential. Same-origin plugin requests should ride the existing session cookie plus the Rails CSRF token; needing a token in JavaScript is a signal to mint a scoped, short-lived one, never to reuse the account key.

How Orbis AppSec Detected This

  • Source: the authenticated user's Redmine REST API key, read from User.current inside the plugin's Redmine::Hook::ViewListener body-bottom hook.
  • Sink: string interpolation into an inline <script> variable emitted into the HTTP response body on every wiki and issue page render.
  • Missing control: no boundary between server-side secret material and client-readable markup — the "protection" applied was a String#reverse plus Base64 encode, which is reversible by any reader, and no scoped or short-lived token was substituted for the long-lived account key.
  • CWE: CWE-798 — Use of Hard-coded Credentials.
  • Fix: the view hook no longer emits the encoded API key; plugin requests authenticate with the existing Redmine session and CSRF token instead.

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

Prevention and further reading

Frequently Asked Questions

Does the drawio editor still function after the Redmine API key is removed from the page JavaScript?

Yes. The diagram attachment load and save requests originate from the browser of an already-authenticated Redmine user, so the session cookie plus the Rails CSRF token carry the same authorization the API key was standing in for — no separate bearer credential needs to reach the client.

How do I tell whether my Redmine instance leaked API keys through the plugin's body-bottom hook, and what do I do about it?

View the source of any wiki or issue page on an affected install and look for an inline script variable holding a Base64 blob; if it decodes (reversed) to your key, treat every key on the instance as exposed. Rotate keys from My Account → API access key, because cached HTML, screenshots, and proxy logs may still hold the old value.

Why did the same plugin release also add `formats: [:html]` to the macro dialog partial render and hide `span[title="Edit"]` in CSS?

Those are unrelated maintenance changes shipped alongside the credential removal: the explicit `formats: [:html]` keeps `render_to_string` from resolving the macro dialog partial to a non-HTML format, and the CSS rule hides a stray Edit button in the drawio viewer (upstream issue #158).

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #161

Related Articles

critical

Yandex Translate API Key Leaked via URL Query Parameter

The `translateYandex()` helper built its request URL by interpolating the caller-supplied API key directly into the query string, meaning every call leaked the credential into server access logs, proxy logs, and any Referer header sent by intermediaries. The fix switches the request from a GET with the key in the URL to a POST with the key in the request body via `URLSearchParams`, removing the credential from any URL-logging surface entirely.

critical

Google Generative AI Keys Exposed in Client-Side Fetch URLs

A client-side AI model discovery utility was embedding Google Generative AI API keys directly in fetch request URLs, making them visible to any user inspecting network traffic or browser DevTools. The fix moves the key from the URL query parameter to a secure HTTP header, eliminating exposure while maintaining API authentication.

critical

How credential leakage through console logging happens in JavaScript browser extensions and how to fix it

A browser extension's `src/background/credentials.js` printed the full Strava authentication cookie string — including a signed JWT and CloudFront-Signature values — straight into the extension console via `console.debug`. Anyone who could open DevTools on the background page (or any tooling that scraped the console) could copy a live session and impersonate the user. The fix replaces the credential payload in both log statements with `Boolean(credentials)` and strips a realistic-looking JWT out

critical

How Hardcoded Secrets Compromise Authentication in JavaScript and How to Fix It

A critical vulnerability in `Tool/QuantumultX/Rewrite/RRSP.js` exposed hardcoded API authentication credentials—a TOKEN and UMID device identifier—directly in source code. Anyone with repository access could extract these credentials to impersonate the legitimate user and gain full account access to the RRTV API service. The fix replaced hardcoded secrets with empty placeholders, forcing users to manually configure credentials through secure channels.

critical

How API Key Exposure in Request Bodies happens in React and how to fix it

The Chatbot component in gitforme was transmitting Azure OpenAI API keys inside JSON request bodies, causing them to be logged by servers, proxies, and middleware. By moving the apiKey from requestBody.apiKey to an Authorization header, credentials are now protected from persistence in generic request logging infrastructure.

critical

Discord Bot Auto-Role CWE-269: Privilege Escalation via role.position

A Discord.js bot's auto-role feature allowed privilege escalation where any user with ManageRoles permission could add Administrator roles to automatic assignment lists, regardless of their own role hierarchy. The vulnerability existed because role.position validation checked only the bot's permissions, not the invoking user's role hierarchy relative to the target role.