Skip to main content
RunBook Academy

ObservabilityLXIV · TLS MonitoringTLSMonitoring

Mixed Content Detection

Intermediate⏱ ~22 minbashbrowser-devtools

What you'll learn

  • Recognise mixed-content violations in browser DevTools and CSP reports
  • Distinguish active mixed content (blocked) from passive mixed content (was allowed, now blocked)
  • Configure CSP directives that prevent mixed content at the browser layer
  • Build a synthetic monitor that loads HTTPS pages with a headless browser and reports mixed content

Prerequisites

Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13

Not yet marked complete on this device.

A customer opened a support ticket with a screenshot of the browser console. The page loaded over HTTPS but every JavaScript file was failing to load. The error was Mixed Content: The page at 'https://app.example.com/' was loaded over HTTPS, but requested an insecure script 'http://cdn.legacy.example.com/legacy.js'. This request has been blocked; the content must be served over HTTPS.

The application team looked at the source. The HTML template contained a hardcoded http:// reference to a CDN that had been migrated to HTTPS years ago. The CDN still served the content over HTTP for legacy clients; the new domain was the same content under HTTPS. Nobody had updated the template in six years.

The page was functionally broken. The error appeared in console but not in the network panel as a failed HTTP request (because the browser blocked it before the request was sent). Server-side monitoring showed green: the HTTPS page returned 200; the HTTP resource was not requested at all.

That incident is the reason this lesson exists.

What it is

Mixed content is the condition that arises when an HTTPS page loads a sub-resource over HTTP. The TLS session is established between the browser and the page’s origin, but the sub-resource traverses an unencrypted connection. An attacker on the network path can read or modify the sub-resource without breaking the TLS session to the page.

The W3C Mixed Content Level 2 specification defines two categories:

  • Active mixed content. Scripts, stylesheets, iframes, fonts, XHR/fetch requests, WebSocket connections. Historically blocked by default by every browser; the W3C spec formally classifies these as “blockable.”
  • Passive mixed content. Images, video, audio. Historically allowed with a console warning; since Chrome 86 (2020) and the equivalent Firefox/Safari updates, these are now also blocked by default in most contexts.

The browser’s behaviour when it encounters mixed content:

  Browser loads https://app.example.com/  (TLS negotiated)
       |
       +-- discovers <img src="http://cdn.example.com/foo.jpg">
       |
       v
  Does the CSP header include block-all-mixed-content?
       |
       +-- yes: block the request, do not send
       |
       +-- no: send the request, log a console warning
       |       (and block it if passive-mixed-content is
       |        also now blocked)
       |
       v
  Console:
    Mixed Content: The page at 'https://app.example.com/'
    was loaded over HTTPS, but requested an insecure
    image 'http://cdn.example.com/foo.jpg'.

The warning is the visible signal. The block is the user-visible behaviour. Modern browsers also send a CSP report (if the page declares a report-uri or report-to directive) so the server team can see the violation in their logs.

Why a sysadmin cares

Mixed content is one of the few TLS-layer failures that escapes server-side monitoring. The TLS handshake to the page succeeds. The HTTP request to the sub-resource never reaches the server (because the browser blocks it before sending). The server sees no failed requests, no elevated error rate, no anomaly. The user sees a broken page.

The cost is real and asymmetric:

  • It is silent at the server. Standard Prometheus blackbox probes, expiry metrics, and handshake metrics (lessons 01-04) all report green. The failure is between the browser and the sub-resource, which the server never sees.
  • It is loud to the user. The console fills with red errors. The page may render incorrectly (missing stylesheets, broken JavaScript). The user blames the application, not the underlying transport.
  • It is easy to introduce. A legacy CDN reference, a template that hardcodes http://, a third-party tracker added by marketing. Each one is a single line of HTML.

The detection mechanisms are also asymmetric: the server cannot detect it directly; only the browser can. So the monitoring must also be browser-side: synthetic monitors that load the page with a headless browser and report mixed content warnings.

How it works

The detection mechanisms, layered from least to most authoritative:

  Layer 1: Browser DevTools
    - Console warning on every mixed-content request.
    - Network panel marks the request as "blocked".
    - Manual review only; not automatable at scale.

  Layer 2: Content Security Policy (CSP)
    - Server emits a CSP header with block-all-mixed-content.
    - Browser blocks the request before sending.
    - Browser sends a CSP report to the report-uri if declared.
    - Server team ingests the CSP reports and counts violations.

  Layer 3: Synthetic monitor
    - Headless browser (Chrome via Puppeteer, Playwright) loads
      the page on a schedule.
    - Reads the console messages; counts mixed-content warnings.
    - Pushes a counter to Prometheus.

  Layer 4: Real User Monitoring (RUM)
    - Browser SDK in the page reports CSP violations as they
      happen.
    - Aggregated to a counter in Prometheus or a SaaS RUM
      backend.

A production-grade implementation uses layers 2 and 3. Layer 1 is for development. Layer 4 is the gold standard but requires JS in the page.

The browser-side detection is:

  page URL: https://app.example.com/

  response headers:
    Content-Security-Policy: default-src 'self';
                              block-all-mixed-content;
                              upgrade-insecure-requests;
                              report-uri /csp-report

  response body contains:
    <img src="http://cdn.legacy.example.com/banner.jpg">
    <script src="http://cdn.legacy.example.com/legacy.js"></script>

  browser behaviour:
    1. img: passive mixed content. Blocked (Chrome 86+).
    2. script: active mixed content. Blocked unconditionally.
    3. CSP violation: report sent to /csp-report.
    4. Console: two Mixed Content warnings.

The browser sends a POST to /csp-report with a JSON body describing the violation. The server team ingests these into Loki or a dedicated CSP-report endpoint.

Under the hood

How to configure it

Three pieces: the CSP header on the origin server, the CSP report ingestion, and the synthetic monitor.

The CSP header

nginx (/etc/nginx/conf.d/csp.conf):

# Block all mixed content. Upgrade http:// to https://.
# Report violations to the CSP endpoint.
add_header Content-Security-Policy "
    default-src 'self';
    block-all-mixed-content;
    upgrade-insecure-requests;
    report-uri /csp-report;
    report-to csp-endpoint;
" always;

add_header Report-To '{"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"/csp-report"}]}' always;

HAProxy (/etc/haproxy/haproxy.cfg):

http-response set-header Content-Security-Policy "default-src 'self'; block-all-mixed-content; upgrade-insecure-requests; report-uri /csp-report"

The upgrade-insecure-requests directive is the primary defence: the browser rewrites http:// to https:// and the resource loads successfully. The block-all-mixed-content directive is the fallback: if upgrade-insecure-requests cannot find an HTTPS equivalent, the resource is blocked. The combination is the modern recommendation from the W3C.

The CSP report endpoint

The simplest endpoint accepts the POST and logs the JSON body. A 5-line nginx config:

location = /csp-report {
    if ($request_method != POST) { return 405; }
    access_log /var/log/nginx/csp-report.log json_combined;
    proxy_pass http://localhost:5000/csp-report;
}

The upstream service parses the JSON and pushes a metric:

# Pseudocode for the CSP endpoint
@app.post("/csp-report")
async def csp_report(request: Request):
    body = await request.json()
    report = body.get("csp-report", {})
    blocked_uri = report.get("blocked-uri", "unknown")
    directive = report.get("violated-directive", "unknown")
    csp_violations_total.labels(
        page=report.get("document-uri", "unknown"),
        directive=directive,
        blocked_uri=blocked_uri,
    ).inc()
    return Response(status_code=204)

The Prometheus counter csp_violations_total is the metric the alert watches.

The synthetic monitor

A Puppeteer-based monitor that loads the page and counts mixed-content warnings:

// /opt/monitors/mixed-content.js
const puppeteer = require('puppeteer');
const { collectDefaultMetrics, Counter, Registry } = require('prom-client');

const registry = new Registry();
collectDefaultMetrics({ register: registry });

const violations = new Counter({
  name: 'mixed_content_violations_total',
  help: 'Number of mixed-content violations detected on a page load',
  labelNames: ['page', 'directive', 'blocked_uri'],
  registers: [registry],
});

const PAGES = [
  'https://app.example.com/',
  'https://app.example.com/checkout',
  'https://app.example.com/account',
];

async function check(page) {
  const browser = await puppeteer.launch();
  const p = await browser.newPage();
  const consoleMessages = [];
  p.on('console', (msg) => consoleMessages.push(msg.text()));
  await p.goto(page, { waitUntil: 'networkidle0', timeout: 30000 });
  await browser.close();
  for (const text of consoleMessages) {
    if (text.includes('Mixed Content:')) {
      const match = text.match(/'(http[^']+)'/);
      const blocked = match ? match[1] : 'unknown';
      violations.labels(page, 'mixed-content', blocked).inc();
    }
  }
}

async function loop() {
  for (const page of PAGES) {
    try {
      await check(page);
    } catch (e) {
      console.error(`failed to check ${page}: ${e}`);
    }
  }
  // expose metrics
  const express = require('express');
  const app = express();
  app.get('/metrics', async (_req, res) => {
    res.set('Content-Type', registry.contentType);
    res.end(await registry.metrics());
  });
  app.listen(9100);
}

setInterval(loop, 5 * 60 * 1000);
loop();

This runs as a long-lived service, exposes /metrics on port 9100, and counts mixed-content warnings per page per 5-minute window.

The alert

groups:
  - name: mixed_content_alerts
    interval: 5m
    rules:
      - alert: MixedContentViolations
        expr: >
          sum by (page) (
            rate(csp_violations_total[1h])
          ) > 0
        for: 30m
        labels:
          severity: warning
          category: tls
        annotations:
          summary: 'Mixed content violations detected on {{ $labels.page }}'
          description: '{{ $value }} mixed-content violations per second over the last hour on {{ $labels.page }}. Investigate the blocked_uri label to find the offending resource.'
          runbook_url: 'https://runbooks.example.com/tls/mixed-content'

How to validate it

Three checks: confirm the CSP header is emitted, trigger a violation, and inspect the synthetic monitor output.

Confirm the CSP header:

curl -sI https://app.example.com/ | grep -i content-security-policy

Realistic output:

Content-Security-Policy: default-src 'self'; block-all-mixed-content; upgrade-insecure-requests; report-uri /csp-report; report-to csp-endpoint

Trigger a violation by adding a <img src="http://..."> tag to a staging page. Open the page in Chrome and watch the console:

Mixed Content: The page at 'https://staging.example.com/'
was loaded over HTTPS, but requested an insecure image
'http://httpbin.org/image/png'. This request has been
blocked; the content must be served over HTTPS.

The httpbin.org reference is a useful test target; it serves content over both HTTP and HTTPS, so you can confirm the browser behaviour on a known source.

Inspect the synthetic monitor:

curl -s http://monitor.example.com:9100/metrics | grep mixed_content

Realistic output:

mixed_content_violations_total{page="https://app.example.com/",directive="mixed-content",blocked_uri="http://cdn.legacy.example.com/banner.jpg"} 3

The counter has incremented three times (one per 5-minute scrape). The blocked_uri label identifies the offending resource.

In the CSP report stream (Loki):

{job="csp-report"} | json | violated_directive="block-all-mixed-content"

This returns every CSP violation report received in the selected time range. The blocked_uri field tells you which resource was blocked.

How it can fail

Five failure modes appear in production.

  1. The CSP header is set but report-uri points to a non-existent endpoint. The browser sends the report; the server returns 404; the violation is logged nowhere. Symptom: the alert never fires because no reports reach the metric endpoint.

  2. upgrade-insecure-requests is set but the target does not support HTTPS. The browser tries https://, fails, and blocks the resource. Symptom: pages render without critical assets that were loaded from HTTP-only CDNs. The fix is to migrate the resource to HTTPS or remove the reference.

  3. The CSP header is set on the application but not on the CDN that serves static assets. The CDN serves with its own cache-control and no CSP. If the page includes an <iframe> to the CDN, the iframe is treated as a separate document and the CSP does not apply. Symptom: mixed content violations in iframes appear without CSP reports.

  4. The synthetic monitor loads a page that requires authentication. The monitor logs in once, then keeps a session, but the session cookie expires. Symptom: the monitor loads the login page (no mixed content) and reports zero violations for an application that has them.

  5. Third-party scripts introduce mixed content. Marketing adds a tracking pixel from a vendor that has not migrated to HTTPS. The application team cannot control the third party; the CSP report shows the violation but the fix is external. Symptom: persistent violations with no internal resolution path.

How to troubleshoot it

The diagnostic order for “the alert fires”:

  1. Inspect the CSP report stream. The blocked_uri label is the answer. Most mixed-content violations are caused by one of: a hardcoded http:// in a template, a legacy CDN reference, a third-party tracker.
  2. Search the codebase. grep -r 'http://' src/ | grep -v 'https://' — every literal http:// in the source is a candidate. (Ignore XML namespace URIs and similar.)
  3. Check the third-party list. If the violation is for a third-party domain, contact the vendor. Many have an HTTPS migration already in progress; the CSP report is the signal that triggers the migration.
  4. Confirm the CDN supports HTTPS. For first-party CDN references, verify the CDN origin is reachable over HTTPS. curl -I https://cdn.example.com/asset.js should return 200. If it returns 404 or refuses, the resource must be re-uploaded to the HTTPS endpoint.
  5. Confirm the CSP header is on every response. Some CDN configs strip CSP headers from cached responses. Check the response from a CDN edge, not just the origin.
  6. Re-test in the browser. After the fix, open the page in Chrome with DevTools open, reload, and confirm the console is clean.

Security implications

Mixed content is a security vulnerability, not just a UX issue:

  • Passive mixed content can be modified in transit. A network attacker can replace banner.jpg with an image that exfiltrates information or carries a phishing payload. The TLS session to the page does not protect the resource.
  • Active mixed content can be modified to execute arbitrary code. A modified legacy.js runs in the page’s origin and has access to cookies, local storage, and the DOM. This is the same security context as the page itself.
  • Mixed content defeats HSTS. HSTS upgrades http:// to https:// at the browser level, but only for the page request. Sub-resources are still subject to mixed-content rules.

The CSP header is the defence. upgrade-insecure-requests makes the violations disappear (the resource loads over HTTPS). block-all-mixed-content makes them hard failures (the resource is blocked). The combination is the W3C-recommended baseline.

The synthetic monitor is itself a security control: a production page that silently regresses to mixed content is a regression that no other layer of the observability stack catches. The monitor is the canary.

Performance implications

The CSP header itself is cheap: a few hundred bytes per response, parsed once by the browser. The report-uri POST adds a request per violation; in production, a single violating resource may produce one report per page load, which is a few hundred requests per hour per affected user. The report endpoint must handle this rate; a 1kB POST is trivial for any HTTP server.

The synthetic monitor is the dominant cost:

  • A single page load via headless Chrome: 1-3 seconds (startup + render).
  • 50 pages, 5-minute interval: ~600 page loads per hour.
  • CPU on the monitor host: ~10-20 percent of one core.
  • Memory: ~200-400 MiB resident (one headless Chrome per concurrent page).

For larger inventories, parallelise across multiple monitor hosts or use a SaaS synthetic monitoring service.

How to roll this back

Removing the CSP header is a configuration change.

  1. Remove the add_header Content-Security-Policy ... directive from nginx (or equivalent from HAProxy/Envoy).
  2. nginx -t to validate.
  3. nginx -s reload.
  4. Confirm with curl -sI https://app.example.com/ | grep -i content-security-policy — should return empty.
  5. Disable the alert in Prometheus (or leave it active to catch regressions in the future).

The synthetic monitor can stay; it will report zero violations once the CSP header is removed and the page loads without browser-side enforcement. The CSP report endpoint can stay or be decommissioned.

The rollback does not affect the certificate or the TLS configuration. It is a pure HTTP-header change.

Verification

You should now be able to answer:

  • What is the difference between active and passive mixed content, and how does modern browser behaviour treat each?
  • What does the CSP upgrade-insecure-requests directive do, and why is it paired with block-all-mixed-content?
  • Why is server-side monitoring insufficient for detecting mixed content, and what fills the gap?
  • How does a synthetic monitor count mixed-content violations from a headless browser?
  • What is the most common production cause of mixed content, and how do you find it in the codebase?

Quiz

Knowledge check · 8 questions

  1. Q1. What is mixed content?

  2. Q2. Active mixed content (scripts, stylesheets, iframes) is blocked by browsers by default; passive mixed content (images, video) was historically allowed but is now blocked in most browsers.

  3. Q3. What does the CSP directive upgrade-insecure-requests do?

  4. Q4. Which of these are valid detection mechanisms for mixed content in production?

  5. Q5. What is the most common production cause of mixed content, and what is the first thing to search the codebase for?

  6. Q6. The CSP report stream shows a violation for a third-party tracker that the marketing team added. What is the appropriate response?

  7. Q7. A reverse proxy terminating TLS and forwarding to an HTTP backend can produce mixed content even though the user only sees the HTTPS URL.

  8. Q8. Which of these are signs that the CSP header is configured but not enforced?

Passing score: 75%. Answers are checked in this browser.