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.
Prerequisite state
- You control the agent’s source or its packaging, so you can change what it returns and which port and protocol it binds.
- The page is already served over HTTPS. Serving it over plain HTTP would remove the mixed content objection and simultaneously make Chromium refuse the private network request outright, which is a worse trade.
- You have a domain you control, because the durable fix needs a DNS record.
- You can test in Chromium, Firefox and Safari. A fix verified in one engine tells you almost nothing about the other two.
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.
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.
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
curl -si -X OPTIONS https://agent.pixelforge.app:7331/render/queue \ -H 'Origin: https://studio.pixelforge.app' \ -H 'Access-Control-Request-Method: GET' \ -H 'Access-Control-Request-Private-Network: true'
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.