Fixing Blocked Requests From an HTTPS Page to localhost

A web editor at https://studio.pixelforge.app talks to a desktop render agent listening on http://localhost:7331. It works on one engineer’s machine and fails on another’s, and the two console messages do not even describe the same problem. On Safari:

[blocked] The page at https://studio.pixelforge.app/ was not allowed to run insecure content
from http://localhost:7331/render/queue.

On Chromium:

Access to fetch at 'http://localhost:7331/render/queue' from origin
'https://studio.pixelforge.app' has been blocked by CORS policy: Response to preflight
request doesn't pass access control check: The 'Access-Control-Allow-Private-Network'
header was not present on the requested resource.

Root cause

There are two independent gates between an HTTPS page and a loopback listener, and each engine enforces a different subset of them. The first gate is transport trust: an HTTPS document may not pull in http:// subresources. Chromium and Firefox carve out an exception because http://localhost and http://127.0.0.1 are classified as potentially trustworthy origins — traffic to them never leaves the machine, so there is nothing for a network attacker to tamper with. WebKit does not apply that carve-out to subresource loads, so Safari blocks on the first gate and never reaches the second.

The second gate is address space. The document was loaded from a routable address and is therefore public; 127.0.0.1 is loopback, the most private space there is. Chromium requires the target to opt in to that transition with a grant on the preflight, exactly as described in Private Network Access Controls. Firefox and Safari do not implement that check at all. The net effect: Chromium fails at gate two, Safari fails at gate one, Firefox passes both, and no single-sided fix satisfies all three.

The two gates between an HTTPS page and a loopback listener A left-to-right pipeline runs from the HTTPS page through a transport trust gate and then an address space gate before reaching the local agent. Two downward branches show Safari dropping out at the transport gate and Chromium dropping out at the address space gate. One request, two admission checks, three different verdicts Page at studio.pixelforge .app Gate 1 transport trust: may an HTTPS page load this http:// subresource? Gate 2 address space: public reaching loopback needs an explicit grant Render agent 127.0.0.1:7331 Safari stops here reported as insecure content Chromium stops here reported as a missing grant Firefox clears both

Prerequisite state

Step-by-step fix

Step 1 — Answer the private network preflight in the agent

This clears gate two and is the smaller change. A dependency-free Node listener is enough; the same shape applies to a Rust, Go, or Python agent.

const http = require('node:http');

const ALLOWED = new Set(['https://studio.pixelforge.app']);

const server = http.createServer((req, res) => {
  const origin = req.headers.origin;
  res.setHeader('Vary', 'Origin, Access-Control-Request-Private-Network');

  if (!origin || !ALLOWED.has(origin)) {
    res.writeHead(403).end();
    return;
  }
  res.setHeader('Access-Control-Allow-Origin', origin);

  if (req.method === 'OPTIONS') {
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Studio-Session');
    res.setHeader('Access-Control-Max-Age', '120');
    if (req.headers['access-control-request-private-network'] === 'true') {
      res.setHeader('Access-Control-Allow-Private-Network', 'true');
    }
    res.writeHead(204).end();
    return;
  }

  res.setHeader('Content-Type', 'application/json');
  res.writeHead(200).end(JSON.stringify({ queue: [], busy: false }));
});

server.listen(7331, '127.0.0.1');

Binding to 127.0.0.1 rather than 0.0.0.0 matters as much as the headers do: it keeps the agent off the LAN entirely, so the only browser that can reach it is one running on the same machine.

Step 2 — Publish a loopback DNS name

To clear gate one you need a real certificate, and a certificate needs a name. Publish an ordinary public A record that points at the loopback address:

agent.pixelforge.app.    300    IN    A       127.0.0.1
agent.pixelforge.app.    300    IN    AAAA    ::1

Every resolver on the internet will happily return 127.0.0.1, which means every visitor’s browser resolves the name to their own machine. Nothing about your infrastructure is exposed; the record is a redirection to the client itself.

Step 3 — Serve the agent over HTTPS on that name

Issue a certificate for agent.pixelforge.app from any public authority using a DNS-based challenge, ship it with the agent, and bind TLS to the same loopback address.

const https = require('node:https');
const fs = require('node:fs');

const tls = {
  key: fs.readFileSync('/opt/pixelforge/agent.key'),
  cert: fs.readFileSync('/opt/pixelforge/agent-fullchain.pem'),
};

https.createServer(tls, handler).listen(7331, '127.0.0.1');

The page then calls https://agent.pixelforge.app:7331/render/queue instead of http://localhost:7331/render/queue. Safari is satisfied: the subresource is now HTTPS with a valid chain.

What a loopback certificate does and does not clear Five stacked layers run from the HTTPS page down through public name resolution to 127.0.0.1, a TLS handshake against a publicly trusted certificate, the address space check that still applies, and the agent's grant. Chips on the right mark which of the two gates each layer clears. Page calls https://agent.pixelforge.app:7331/render/queue Public DNS answers with the visitor's own machine agent.pixelforge.app A 127.0.0.1 name resolution TLS handshake against a publicly trusted certificate no plaintext subresource remains, so the transport objection disappears clears gate 1 Target still resolves to 127.0.0.1, so it is still the loopback space Chromium inserts the preflight regardless of the scheme gate 2 still applies Access-Control-Allow-Private-Network: true emitted by the agent on the preflight response clears gate 2 HTTPS buys you Safari; it buys you nothing at all in Chromium, where the address space is unchanged

Step 4 — Keep both fixes, not one

The layer diagram above is the point of this page: the certificate clears gate one and leaves gate two untouched, while the grant clears gate two and leaves gate one untouched. Shipping only one of them produces a build that works in exactly one browser family, which is how this bug survives code review — the reviewer’s engine happens to be the one it works in.

Engine coverage of each fix strategy Three horizontal bars measure how many of Chromium, Firefox and Safari each approach satisfies. Answering the grant over plain HTTP reaches two engines, a loopback certificate without the grant reaches two, and doing both reaches all three. Engines satisfied by each approach Grant only, agent stays on http://localhost:7331 Chromium and Firefox Loopback certificate only, no grant emitted Firefox and Safari Loopback certificate plus the private network grant Chromium, Firefox and Safari 0 1 2 3 engines

Step 5 — Fall back deliberately when the agent is absent

A page that assumes the agent is installed will throw a network error for every visitor who does not have it. Wrap the probe in a short timeout, treat any failure as “agent unavailable”, and offer the browser-only path rather than surfacing a CORS message to a user who cannot act on it.

async function probeAgent() {
  const ctl = new AbortController();
  const timer = setTimeout(() => ctl.abort(), 1500);
  try {
    const res = await fetch('https://agent.pixelforge.app:7331/render/queue', {
      signal: ctl.signal,
    });
    return res.ok ? await res.json() : null;
  } catch {
    return null;   // not installed, blocked, or refused — all the same to the UI
  } finally {
    clearTimeout(timer);
  }
}

Step 6 — Rule out the alternatives before you adopt them

Two workarounds circulate for this problem, and both fail for reasons worth knowing.

The first is a WebSocket. ws://localhost:7331 from an HTTPS page is blocked by the same transport rule that blocks http://, and Chromium applies the address space check to the WebSocket handshake as well, so a socket buys you neither gate. Upgrading to wss:// on the loopback name works, but only because it is the same certificate fix described above — the socket itself changed nothing.

The second is telling users to launch the browser with a security flag, or to trust a self-signed certificate manually. Both trade a one-time engineering cost for a permanent support cost: the flag has to be re-applied at every launch, it disables the protection for every site the user visits, and a manually trusted certificate is exactly the artefact a corporate device management policy will strip. Neither survives contact with a non-technical user, and both look identical to an attack from a security team’s perspective.

There is a third option that is genuinely valid in some products: skip the local agent entirely and move the work into the page. If the render queue could live in a service worker or a WebAssembly module, you avoid both gates and the install step at the same time. Reach for the loopback certificate only when the agent must touch hardware, the filesystem, or a native SDK the browser cannot see.

Verification

Security boundary note

Binding the agent to 0.0.0.0 to “make testing easier” turns a machine-local service into a LAN service, and every other device on the same coffee-shop network can then reach it. Keep the bind address at 127.0.0.1, keep the origin allowlist exact, and remember that the certificate you ship has a private key on every customer’s disk — treat it as public, scope it to a hostname used for nothing else, and rotate it on a schedule. The private network grant says which pages may cross the boundary; it is not a substitute for the session token the agent should still require on every route.

Common mistakes

Issue Technical impact Mitigation
Assuming HTTPS on the agent removes the need for the grant Chromium still sees a loopback target and still demands the opt-in, so the fix works only in Safari and Firefox Ship the certificate and the grant together
Testing only in the engine you happen to use The bug is invisible in whichever engine your fix happened to satisfy Run the verification list in all three engines before release
Binding the listener to 0.0.0.0 Any device on the same network can drive the agent, well beyond the browser boundary this fix is about Bind to 127.0.0.1 and keep the allowlist exact
Downgrading the page to HTTP to dodge mixed content Chromium then refuses the private network request outright, because the document is no longer a secure context Keep the page on HTTPS and move the agent up instead

FAQ

Why does it work in Chrome but not Safari, or the other way round?

Because the two engines enforce different gates. Chromium treats http://localhost as potentially trustworthy, so it never raises a mixed content error, but it does apply the address space check and demands a grant on the preflight. Safari does not send that preflight at all, but it refuses to load an http:// subresource into an HTTPS document, so the request dies as mixed content. A build that only answers one of the two gates works in exactly one browser family.

Does serving the local agent over HTTPS remove the need for the grant?

No. Transport security and address space are independent. Once the agent is reachable at https://agent.pixelforge.app:7331 the mixed content objection disappears, but the target still resolves to 127.0.0.1, which is still the loopback address space, so Chromium still inserts the preflight and still requires Access-Control-Allow-Private-Network: true. A loopback certificate solves the Safari half of the problem and none of the Chromium half.

Is putting a public DNS record on 127.0.0.1 safe?

It is a well-established pattern and it leaks nothing by itself, since the record only tells the world that a name points at the visitor’s own machine. The real risk is the certificate: its private key ships to every install, so treat it as public, scope it to a name used for nothing else, keep the lifetime short, and be ready to rotate. Never reuse a certificate that also covers your production hostnames.