Skip to content
academia.sh

Lesson 18 / 27

Security Headers

Enforcing transport security, restricting framing, narrowing referrer information, turning off MIME sniffing, and cross-origin isolation; a configuration checker that finds what is missing.

Contents

The previous three lessons built specific layers against specific attack classes. This lesson takes up a group that is not tied to a single class but reduces the impact of all of them: constraints declared through response headers and enforced by the browser.

What these headers have in common is that they live in server configuration, not application code. It is easy for them to go unnoticed during a release, because they never break any functionality; the only observable consequence of their absence appears when something else goes wrong. The checker at the end of this lesson removes that invisibility.

Enforcing Transport Security

The Role of HTTPS lesson in the How the Internet Works course established that encrypted transport provides confidentiality, integrity, and authentication. Even when an application is served entirely over an encrypted connection, one gap remains: when a user types the address without a scheme, the first request can go out unencrypted and be intercepted before the redirect happens.

The Strict-Transport-Security header closes this gap. It tells the browser to connect to this host only over an encrypted connection for the declared duration; an address typed without a scheme is converted before it ever hits the network. It has three components. max-age is the duration in seconds, and it is kept long for the protection to be meaningful. The includeSubDomains rule spreads it across subdomains; without it, a subdomain can stay unencrypted and affect the application through cookie scope. The third component is a preload declaration that registers the host in a list built into browsers; it covers even the first request, but reversing it is slow, and it permanently commits every subdomain to being served encrypted.

Sending the header on an unencrypted response has no effect, and should not: otherwise someone intercepting the connection could get a record written that makes the host unreachable.

Restricting Framing

The North Slope Measurement Station interface embeds a map frame showing the station’s location. That is one direction of framing. The other direction is the application itself being framed by another page, and that is the direction that needs defending.

A page placed inside a frame that has been made invisible sits underneath another interface the user sees. The user thinks they are clicking a button they see, but actually clicks a control belonging to the page inside the frame; because the session belongs to them, the action goes through. This class is called clickjacking, and its defense is declaring who is allowed to frame the document.

There are two mechanisms. The X-Frame-Options header takes the values DENY or SAMEORIGIN and declares a single decision. The content security policy’s frame-ancestors directive accepts a list of origins and is more detailed: framing can be allowed for specific origins only. In an implementation that understands the directive, the directive wins; writing both together provides protection in implementations that understand only one of them.

Narrowing Referrer Information

When a link is clicked or a subresource is loaded, the browser adds a header telling the destination which page the request came from. The default behavior can go as far as sending the full address. On the measurement station, this means the path and query components of an address in the form .../station/north-slope?measurement=4417 leak to a third party.

The Referrer-Policy header narrows this behavior. Its values sit on an axis, from an end that sends nothing to an end that sends the full address. The balanced choice is a value that sends the full address to the same origin, sends only the origin string to other origins, and sends nothing on a transition from encrypted to unencrypted. If analysis needs this information, it is drawn from the application’s own measurement, not from the header.

Turning Off MIME Sniffing

If a response’s content type is missing or wrong, the browser can look at the body and guess the type. In an application where users upload files, this guess turns into a vulnerability: an upload meant to be served as plain text, if interpreted as markup, runs as a document within the same origin.

The X-Content-Type-Options: nosniff header turns off the guess and forces the browser to honor the declared type. The header alone is not enough; the declared type being correct, user uploads being served from a separate origin, and being marked for download are all part of the same defense.

Cross-Origin Isolation and Permission Policy

The Cross-Origin-Resource-Policy header declares whether a resource may be embedded by other origins. When its value is same-origin, the resource can only be used in documents from its own origin. This is a constraint that limits data being pulled into another document’s memory space.

Two more headers belong to the same family: one that cuts browsing-context sharing with windows the document opens and windows that open it, and one that requires embedded resources to declare explicit permission. Together the two establish a state called cross-origin isolation. This state is a precondition for some capabilities and is not required in an application that does not use those capabilities; turning it on unnecessarily breaks legitimate embedding.

Permission policy, introduced in the Web Fundamentals and HTML course, is a separate axis: it lists which device capabilities the document and the frames it embeds may use. If the map frame has no need for the location capability, that capability is not granted to the frame.

Auditing the Configuration

Every one of these headers can be written correctly one at a time, and one can drop a release later. Turning the audit into a rule set makes a dropped header visible.

// headers.mjs — audits response headers against a rule set
const ONE_YEAR = 31536000;

const RULES = [
  {
    name: 'transport security',
    check: (h) => {
      const value = h['strict-transport-security'];
      if (!value) return 'Strict-Transport-Security missing';
      const duration = Number(/max-age=(\d+)/.exec(value)?.[1] ?? 0);
      if (duration < ONE_YEAR) return `max-age ${duration} seconds, shorter than a year`;
      if (!/includeSubDomains/i.test(value)) return 'includeSubDomains missing, subdomains excluded';
      return null;
    },
  },
  {
    name: 'content security policy',
    check: (h) => {
      const value = h['content-security-policy'];
      if (!value) return 'Content-Security-Policy missing';
      if (/script-src[^;]*'unsafe-inline'/.test(value)) return "script-src contains 'unsafe-inline'";
      if (!/frame-ancestors/.test(value)) return 'frame-ancestors directive missing';
      return null;
    },
  },
  {
    name: 'framing',
    check: (h) =>
      h['x-frame-options'] || /frame-ancestors/.test(h['content-security-policy'] ?? '')
        ? null
        : 'framing not restricted',
  },
  {
    name: 'MIME sniffing',
    check: (h) => (h['x-content-type-options'] === 'nosniff' ? null : 'X-Content-Type-Options: nosniff missing'),
  },
  {
    name: 'referrer policy',
    check: (h) => {
      const value = h['referrer-policy'];
      if (!value) return 'Referrer-Policy missing';
      const loose = ['unsafe-url', 'no-referrer-when-downgrade', 'origin-when-cross-origin'];
      return loose.includes(value) ? `loose value: ${value}` : null;
    },
  },
  {
    name: 'cross-origin resource policy',
    check: (h) => (h['cross-origin-resource-policy'] ? null : 'Cross-Origin-Resource-Policy missing'),
  },
  {
    name: 'session cookie',
    check: (h) => {
      const value = h['set-cookie'];
      if (!value) return null;
      const missing = ['HttpOnly', 'Secure', 'SameSite'].filter((n) => !new RegExp(n, 'i').test(value));
      return missing.length ? `cookie attributes missing: ${missing.join(', ')}` : null;
    },
  },
];

function audit(name, headers) {
  const findings = RULES.map((r) => [r.name, r.check(headers)]).filter(([, f]) => f !== null);
  console.log(`\n${name}: ${findings.length} findings`);
  for (const [rule, finding] of findings) console.log('  -', `${rule}:`, finding);
  if (findings.length === 0) console.log('  - no findings');
}

const initialRelease = {
  'content-type': 'text/html; charset=utf-8',
  'strict-transport-security': 'max-age=86400',
  'content-security-policy': "default-src 'self'; script-src 'self' 'unsafe-inline'",
  'referrer-policy': 'unsafe-url',
  'set-cookie': 'measurement_session=abc; Path=/',
};

const fixed = {
  'content-type': 'text/html; charset=utf-8',
  'strict-transport-security': 'max-age=31536000; includeSubDomains',
  'content-security-policy': "default-src 'self'; script-src 'self' 'nonce-r4k2'; frame-ancestors 'none'",
  'x-frame-options': 'DENY',
  'x-content-type-options': 'nosniff',
  'referrer-policy': 'strict-origin-when-cross-origin',
  'cross-origin-resource-policy': 'same-origin',
  'set-cookie': 'measurement_session=abc; Path=/; HttpOnly; Secure; SameSite=Lax',
};

audit('initial release', initialRelease);
audit('fixed configuration', fixed);
$ node headers.mjs

initial release: 7 findings
  - transport security: max-age 86400 seconds, shorter than a year
  - content security policy: script-src contains 'unsafe-inline'
  - framing: framing not restricted
  - MIME sniffing: X-Content-Type-Options: nosniff missing
  - referrer policy: loose value: unsafe-url
  - cross-origin resource policy: Cross-Origin-Resource-Policy missing
  - session cookie: cookie attributes missing: HttpOnly, Secure, SameSite

fixed configuration: 0 findings
  - no findings

Each defect in the first configuration is small on its own and none of them breaks the application: the transport rule’s duration is short, the policy allows inline code, framing is not restricted, the session cookie carries none of its three attributes. The findings list makes all of this visible in a single run.

The value of this audit is in running it continuously. Run as a pre-release step, a configuration change that drops a header shows up the same day, not at the next security review. The rule set also changes over time; when a new rule is added, the findings list getting longer is the expected behavior.

Summary

  • Security headers live in server configuration and go unnoticed because their absence breaks no function silently.
  • Strict-Transport-Security closes the first-request gap; without includeSubDomains, subdomains remain unprotected.
  • Framing is restricted with X-Frame-Options or the frame-ancestors directive; the latter is more detailed because it accepts a list of origins.
  • Referrer-Policy narrows path and query information leaking out; X-Content-Type-Options: nosniff turns off MIME sniffing.
  • A rule resolver that audits the header set makes a dropped header visible the same day; the audit is run as a step in the release pipeline.

Next Step

The defenses built so far rested on the application’s own code and its own server. The measurement interface also runs code it did not write: the map frame, the script that draws the measurement chart, the hundreds of packages in the dependency tree. Every line of this code runs with the authority of the application’s origin. The next lesson takes up the risk third-party code creates, where it arises across the supply chain, and how subresource integrity narrows it.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close