Sitemap
Namu CHO

Namu CHO

외노자 개발자 나무 🇸🇬

What I Learned Building a Web Accessibility Chrome Extension

9 min readAug 23, 2026

--

From font scaling to an SSRF vulnerability I found the day before open-sourcing it

Press enter or click to view image in full size

I set out to build an accessibility extension. The premise was simple: enlarge text, stop flashing animations, override colors, and use AI to describe images that have no alt text.

What surprised me was where the time actually went. The hard part wasn’t implementing features — it was discovering that code which appeared to work was quietly wrong. Here are the moments that taught me the most.

What this post covers:

  • Why browser zoom isn’t enough, and how to scale text alone
  • The bug where the popup responded but the page didn’t
  • Why you can’t trust the lang attribute
  • The SSRF vulnerability I found right before publishing

1. Browser zoom isn’t enough

The first feature was font scaling. You might reasonably ask: why not just press Cmd +?

Because browser zoom scales the entire layout. As text grows, so do the sidebars, images, and padding. You end up with less information on screen and a horizontal scrollbar — the exact thing that makes reading harder.

What many low-vision users actually want isn’t “magnify everything,” it’s “make the words readable.” So I scaled text only.

The complication is that sites mix font units freely — px, rem, em, %. And em is relative to its parent, so applying a blanket multiplier compounds the scaling on nested elements.

The fix was to work from the computed value. The browser has already resolved every unit into pixels, so multiplying that result and writing it back as px makes the original unit irrelevant.

const base = parseFloat(getComputedStyle(el).fontSize);
el.style.setProperty('font-size', (base * scale).toFixed(2) + 'px', 'important');

There was a second trap. You can’t apply this to every element. Given <div><p>text</p></div>, scaling both the div and the p re-introduces the compounding problem through em inheritance.

So I selected only elements that directly contain a text node:

function hasDirectText(el) {
for (const node of el.childNodes) {
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) return true;
}
return false;
}

Walking childNodes for a TEXT_NODE is the important detail here. Checking el.textContent would not work — it concatenates all descendant text, so every container element would pass.

Takeaway: Don’t parse CSS units yourself. The browser already did the math.

2. The popup updated. The page didn’t.

Dragging the slider grew the preview text inside the popup perfectly. The actual webpage behind it didn’t move at all.

The cause was almost embarrassingly simple: tabs that were already open before the extension was installed never receive the content script. The content_scripts entry in manifest.json applies to pages loaded from that point forward. A tab that finished rendering earlier has no idea the extension exists.

This is easy to miss during development, because you’re constantly opening fresh tabs. But real users install an extension and immediately try it on the page they were already looking at. If nothing happens, they conclude it’s broken — and they’re not wrong to.

The fix is to ping on popup open and inject on demand when there’s no answer:

try {
await chrome.tabs.sendMessage(tab.id, { type: 'PING' });
} catch (_) {
// No response means no content script is present
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: ['content.js'],
});
}

That introduces a double-execution risk: inject into an already-injected tab and you register every event listener twice. So the whole script is wrapped in an IIFE behind a global flag:

(() => {
if (window.__a11yHelperLoaded) return;
window.__a11yHelperLoaded = true;
// ...
})();

Takeaway: “Already-open tabs, immediately after install” is the scenario extension developers forget most often.

3. You can widen click targets with CSS alone

For someone with a tremor or limited fine motor control, a link that only responds when you hit the text exactly is a real barrier.

WCAG 2.5.5 Target Size (Enhanced) recommends a minimum target size of 44×44px. You can enforce that with pure CSS:

a {
display: inline-block !important;
padding: 8px 10px !important;
}
button, [role="button"], input[type="submit"] {
min-height: 44px !important;
min-width: 44px !important;
padding: 10px 14px !important;
}

The display: inline-block on a is doing real work. In the default inline state, vertical padding doesn't expand the clickable area at all.

But applying this everywhere makes image links balloon. On a site with a thumbnail grid, the spacing explodes. Hence the exception:

a:has(> img:only-child) { padding: 4px !important; }

Now that :has() ships in every major browser, this "select the parent based on its child" pattern is finally available in CSS. A few years ago this would have required a JavaScript pass over the DOM.

I’ll be honest: this feature breaks the layout on some sites. Force-injecting padding into a densely designed UI will do that. So it ships off by default and is documented as a known limitation. Accessibility tools frequently have to choose between pixel-perfect rendering and actual usability, and usability wins.

4. The <html lang> attribute lies

When I added text-to-speech, I started here:

u.lang = document.documentElement.lang || navigator.language || 'en-US';

Clean enough. The page declares its own language, so use it.

In practice, that attribute is wrong constantly. Plenty of Korean sites still carry lang="en" from a template default. When that happens, an English voice engine tries to pronounce Hangul and either mangles it or skips it entirely. The feature fails silently.

So I stopped reading the declaration and started counting the actual characters — measuring the ratio of Unicode blocks in the text:

const hangul = count(/[\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F]/g);
const kana = count(/[\u3040-\u309F\u30A0-\u30FF]/g);
const han = count(/[\u4E00-\u9FFF]/g);
if (hangul / total > 0.15) return 'ko-KR';
if (kana / total > 0.08) return 'ja-JP';
if (han / total > 0.15) return 'zh-CN';

The real implementation covers seven scripts including Cyrillic, Arabic, Thai and Devanagari; these three are excerpted because their ordering matters.

Why only these scripts? Because the goal isn’t language identification — it’s voice selection. Confusing English with German costs you an awkward accent, since a Latin-script voice will read either one. Handing Hangul to an English voice costs you everything: nothing intelligible comes out. That asymmetry drew the line — always catch a change of script, give up on distinctions within one. These scripts map almost one-to-one onto languages, so a few regexes settle it; Latin script is shared by hundreds of languages and would need a statistical model. That’s exactly where the boundary sits. And this code is injected into every page the user visits. Loading a few hundred kilobytes of language-detection model everywhere, in the name of accuracy, would make an accessibility tool that slows down the web — a contradiction in terms. Cheap inference handles the common cases; the manual override handles the rest.

Get Namu CHO’s stories in your inbox

Join Medium for free to get updates from this writer.

The thresholds differ per script, also deliberately.

Hangul and kana are effectively exclusive to one language each. A small proportion is already strong evidence. Kana gets a lower bar (8%) than Hangul (15%) because Japanese sentences are heavily interleaved with kanji, which structurally suppresses the kana ratio.

Han characters need the opposite treatment. They appear in both Japanese and Chinese, so the kana check has to run first — otherwise Japanese text gets misidentified as Chinese. The ordering is part of the logic, not an implementation detail.

For Latin-script pages, character analysis can’t distinguish anything, so that’s when it falls back to the declared lang, then the browser language.

The edge cases behave as intended:

Input Result Korean body with an English quotation ko-KR ✅ English body containing one Korean word stays English ✅ Japanese with heavy kanji ja-JP ✅

Automatic detection can always be wrong, though, so there’s a manual language dropdown alongside it. Crucially, that list is populated from speechSynthesis.getVoices() — only languages the machine actually has a voice for. Few things are worse than selecting a language and hearing silence.

Takeaway: Trust the data over the metadata. And always leave a manual override next to an inference.

5. The SSRF I found the day before publishing

With everything working, I did a security pass before pushing to GitHub. That’s where the worst bug was hiding.

The AI description feature works like this: find images with no alt → the service worker fetches each URL and converts it to base64 → send it to the API and write back the description.

The problem is that a service worker’s fetch can reach places an ordinary web page cannot. The extension holds <all_urls> permission and isn't subject to CORS.

The attack writes itself. A malicious site embeds:

<img src="http://192.168.1.1/admin/status">
<img src="http://localhost:8080/internal-dashboard">

When the user clicks “Add image descriptions” on that page, the extension fetches pages from inside the user’s private network on the attacker’s behalf, asks an AI to describe them, and writes the result into the alt attribute — where the page's own JavaScript can simply read it back. The extension becomes a bypass around the browser sandbox that was protecting that network.

This is textbook SSRF (Server-Side Request Forgery), in browser-extension form. The defense is an explicit blocklist:

if (host === 'localhost' || host === '::1') return false;
if (host.endsWith('.local') || host.endsWith('.internal')) return false;
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])];
if (a === 0 || a === 10 || a === 127) return false; // loopback, private
if (a === 169 && b === 254) return false; // link-local (cloud metadata!)
if (a === 172 && b >= 16 && b <= 31) return false; // private
if (a === 192 && b === 168) return false; // private
if (a === 100 && b >= 64 && b <= 127) return false; // CGNAT
if (a >= 224) return false; // multicast
}

169.254.x.x deserves particular attention. On AWS, GCP and most cloud providers, instance metadata — including credentials — is served from 169.254.169.254. It's the classic path by which an SSRF turns into a full compromise.

The same review turned up two more issues.

Cookies were being sent with image fetches. Credentials rode along with every request, meaning private images behind a login — an internal wiki, a locked photo album — could be pulled and shipped to an external API. One line fixed it: credentials: 'omit'.

Color values were interpolated into a stylesheet without validation. Tampered storage values meant arbitrary CSS injection:

function safeHex(value, fallback) {
return /^#[0-9a-f]{6}$/i.test(value || '') ? value : fallback;
}

Takeaway: An extension’s privileges are far broader than a page’s. Any code that takes a URL from the page and passes it straight to fetch deserves suspicion.

6. Choosing to put the API key on the user

The AI feature calls a paid API. Two options:

Proxy through my own backend — smooth onboarding; install and go. But I’d pay for server hosting, absorb the bill when someone abuses it, and route every user’s images through my infrastructure.

Bring your own key — a real barrier. Sign up for a console, add a payment method, copy a key. Drop-off is guaranteed. But there’s no server, the cost sits with whoever incurs it, and images never touch a third party.

For a personal project going out as open source, I took the second. Then spent real effort lowering the barrier:

  • When no key is saved, a hint box appears under the button automatically
  • The UI says “image descriptions,” not “alt text” — the target user isn’t a developer
  • Key creation is broken into three linked steps
  • It states plainly that the other three features work without a key

That last point matters most to me. Nobody should open a settings screen and wonder whether the whole extension is useless until they complete a signup flow.

I also translated API errors into human language. The original code dumped the raw response body, and showing someone 401 {"type":"error",...} helps nobody:

if (res.status === 401 || res.status === 403) return { error: 'BAD_KEY' };
if (res.status === 429) return { error: 'RATE_LIMIT' };

→ “That API key was rejected. Check it in settings.”

Not leaking raw API errors is the right call for information disclosure reasons too.

What still doesn’t work

Some things are worth stating plainly.

Cross-origin GIFs can’t be frozen. The GIF-stopping feature draws the first frame to a canvas and swaps in the still. Drawing an image from another domain taints the canvas, and toDataURL() throws. It's browser security policy, with no workaround. For a feature whose purpose is preventing photosensitive seizures, that limitation genuinely stings.

The API key is stored in plaintext. chrome.storage.local isn't encrypted. There's no way around it without a backend, so the docs recommend creating a dedicated key and setting a spend limit in the console.

Color override and enlarged click targets break some sites. The tradeoff discussed above.

Writing these into a README can feel like arguing against your own project. But accessibility tools often fail in ways the user cannot detect. If the voice skips a paragraph, someone who can’t see the screen has no way to know what was lost. Hiding limitations is itself an accessibility problem.

Closing

Looking back, the time didn’t go into building features. It went into the moments of realizing that working-looking code was wrong.

The lang attribute was there, and the code read it, and it ran. It was simply the wrong value. The fetch retrieved images correctly. It could also retrieve things it had no business retrieving.

In accessibility tooling especially, that kind of silent failure is dangerous — precisely because the person affected can’t tell it happened.

Owlense is MIT licensed. Issues and PRs welcome.

--

--

Written by Namu CHO

외노자 개발자 나무 🇸🇬