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
drawioApiKeyand 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:
- Read
drawioApiKeyfrom the page. atob(...)and reverse it to recover the viewer's plaintext key.fetchit out to an attacker-controlled endpoint.- 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::ViewListeneremits 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
drawioApiKeyon 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.currentinside the plugin'sRedmine::Hook::ViewListenerbody-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#reverseplusBase64encode, 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.