Host Header Poisoning

The Host header in an HTTP request is set by the browser and can be used by backend servers to distinguish requests from the different domains being served on the same internet protocol address. However, if a web-server relies on the supplied value of the Host header, a malicious user can provide a spoofed value to generate misleading links on your website and in transactional emails.

Risks

Prevalence Rare
Exploitability Easy
Impact Harmful

Discussion

Most web applications do not know what domain they are being hosted on by default. Domain names are registered in the Domain Name System, which tells a browser to route internet traffic for a domain to a number of IP addresses. HTTP and HTTPS traffic sent to these IP addresses will be routed to a load-balancer, which then distributes the requests amongst various web-servers. The running application will sit downstream of this, unaware of how the requests entered the network, passing responses back along the same route.

Browsers set a Host header in each HTTP request, indicating the intended domain of the HTTP request. However, there’s generally no way to check whether the domain in the Host header corresponds to the IP address the underlying TCP handshake was initiated with. A malicious user-agent - like a script run by a hacker - can set the Host header to anything they choose.

Relative vs Absolute URLs

Most of the time a website doesn’t actually need to know what domain it is running under, providing links in HTML are relative URLs, and client-side imports are reference relative URLs in the same manner:


<!-- Links within the site don't need the domain specified. -->
<a href="/profile">Profile</a>

<!-- Local imports don't need the domain specified either. -->
<script src="js/navigation.js"/>

Using relative URLs is best practice in web development: they make it easier to deploy web-code under different environments. However, there are some circumstances when absolute URLs are required, such as when generating website links in transactional emails. If a user requests a password reset email, for example, the link in the email needs to contain the full domain of your website, since the user will be navigating from an external source (the email client).

Generating Absolute URLs

In the password reset scenario, it is tempting to simply take the domain from the Host header. However, remember this can be set to any value an attacker chooses, so the following functions are open to abuse:

import smtplib
from email.message import EmailMessage

def send_password_reset_email(request, user):
  message = EmailMessage()

  # Trusting the "Host" header from the request is not safe. Don't do this!
  password_reset_url = 'https://{}/password/reset/{}'.format(
                         request.headers['Host'], user.reset_token)
  
  message.set_content("Click here to reset your password: " + password_reset_url)
  message['Subject'] = "Forget your password?",
  message['To']      = user.email
  message['From']    = "support@example.com",
  
  smtp = smtplib.SMTP(os.environ.get("SMTP_HOST"))
  smtp.send_message(message)
  smtp.quit()
const nodemailer = require('nodemailer')

async function sendPasswordResetEmail(request, user) {
  let transporter = nodemailer.createTransport({
    host   : process.env.SMTP_HOST,
    port   : 465,
    secure : true,
    auth: {
      user : process.env.SMTP_USER,
      pass : process.env.SMTP_PASSWORD
    }
  })

  // Trusting the "Host" header from the request is not safe. Don't do this!
  const passwordResetURL = `https://${request.headers.host}/password/reset/${user.reset_token}`

  let info = await transporter.sendMail({
    from:    'support@example.com',
    to:      user.email,
    subject: 'Forget your password?',
    text:    `Click here to reset your password: ${passwordResetURL}`,
    html:    `<a href="${passwordResetURL}">Click here to reset your password</a>`
  })

  console.log('Password reset sent: %s', info.messageId)
}
public static void sendPasswordResetEmail(HttpServletRequest request, 
                                          String             toAddress, 
                                          String             passwordResetToken) throws MessagingException 
{
    // Trusting the "Host" header from the request is not safe. Don't do this!
    String passwordResetURL = "https://" + request.getHeader("Host")
                                         + "/password/reset/" 
                                         + passwordResetToken;

    Properties  properties = System.getProperties();
    Session     session    = Session.getDefaultInstance(properties);
    MimeMessage message    = new MimeMessage(session);

    message.setFrom(new InternetAddress("support@example.com"));
    message.addRecipient(Message.RecipientType.TO, new InternetAddress(toAddress));
    message.setSubject("Forget your password?");
    message.setText("Click here to reset your password: " + passwordResetURL);

    Transport.send(message);
}
public async void SendPasswordResetEmail(IdentityUser user)
{
    var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
    
    // Trusting the "Host" header from the request is not safe. Don't do this!
    var passwordResetUrl = $"https://:{Request.Host}/Account/ResetPassword?userId=${user.Id}&code=${resetToken}";
    
    var text = $"Please click on this link to reset your password: {passwordResetUrl}";
    
    MailMessage msg = new MailMessage();
    
    msg.From    = new MailAddress("support@example.com");
    msg.Subject = "Forget your password?";
    
    msg.To.Add(new MailAddress(user.Email));
    msg.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(text, null, MediaTypeNames.Text.Plain));
    
    SmtpClient smtpClient = new SmtpClient(Configuration.EmailHost, Convert.ToInt32(Configuration.EmailPort));
    smtpClient.Send(msg);
}

Safely Generating Absolute URLs

An attacker could abuse these functions to send a password reset email to a victim with a link back to a malicious website under their control. If this site looks confusingly similar to yours - and has a similar enough domain name that the victim does not notice - the attacker has an easy way to harvest credentials.

For this reason you should always take the domain name for absolute URLs from a configuration file stored securely on the server. Below are some ways to safely generate password reset emails:

import smtplib
from email.message import EmailMessage

def send_password_reset_email(user):
  message = EmailMessage()

  # Taking the Host from the configuration parameters is safe.
  password_reset_url = 'https://{}/password/reset/{}'.format(
                          os.environ.get("DOMAIN"), user.reset_token)
  
  message.set_content("Click here to reset your password: " + password_reset_url)
  message['Subject'] = "Forget your password?",
  message['To']      = user.email
  message['From']    = "support@example.com",
  
  smtp = smtplib.SMTP(os.environ.get("SMTP_HOST"))
  smtp.send_message(message)
  smtp.quit()
const nodemailer = require('nodemailer')

async function sendPasswordResetEmail(request, user) {
  let transporter = nodemailer.createTransport({
    host   : process.env.SMTP_HOST,
    port   : 465,
    secure : true,
    auth: {
      user : process.env.SMTP_USER,
      pass : process.env.SMTP_PASSWORD
    }
  })

  // Taking the Host from the configuration parameters is safe.
  const passwordResetURL = `https://${process.env.HOST}/password/reset/${user.reset_token}`

  let info = await transporter.sendMail({
    from:    'support@example.com',
    to:      user.email,
    subject: 'Forget your password?',
    text:    `Click here to reset your password: ${passwordResetURL}`,
    html:    `<a href="${passwordResetURL}">Click here to reset your password</a>`
  })

  console.log('Password reset sent: %s', info.messageId)
}
public static void sendPasswordResetEmail(String toAddress, String passwordResetToken) throws MessagingException
{
    Properties properties = System.getProperties();

    // Taking the Host from the configuration parameters is safe.
    String passwordResetURL = "https://" + properties.getProperty("host") 
                                         + "/password/reset/" 
                                         + passwordResetToken;

    Session     session = Session.getDefaultInstance(properties);
    MimeMessage message = new MimeMessage(session);

    message.setFrom(new InternetAddress("support@example.com"));
    message.addRecipient(Message.RecipientType.TO, new InternetAddress(toAddress));
    message.setSubject("Forget your password?");
    message.setText("Click here to reset your password: " + passwordResetURL);

    Transport.send(message);
}
public async void SendPasswordResetEmail(IdentityUser user)
{
    var resetToken = await userManager.GeneratePasswordResetTokenAsync(user);
    
    // Taking the Host from the configuration parameters is safe.
    var passwordResetUrl = Url.Action(
                              "ResetPassword", "Account", 
                              new { UserId = user.Id, code = resetToken }, 
                              protocol: Request.Scheme);
    
    var text = $"Please click on this link to reset your password: {passwordResetUrl}";
     
    MailMessage msg = new MailMessage();
    
    msg.From    = new MailAddress("support@example.com");
    msg.Subject = "Forget your password?";
    
    msg.To.Add(new MailAddress(user.Email));
    msg.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(text, null, MediaTypeNames.Text.Plain));
   
    SmtpClient smtpClient = new SmtpClient(Configuration.EmailHost, Convert.ToInt32(Configuration.EmailPort));
    smtpClient.Send(msg);
}

Mitigation

In summary, you can protect your site against Host header poisoning by:

  • Using relative URLs wherever possible.
  • Where absolute URLs are required - like in transactional emails - take the domain name from server-side configuration.

Further Reading