realorigin

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 10 Imported by: 0

README

realorigin

Render untrusted remote content at a real, isolated browser origin whose network layer is a service worker — while the credentials that fetch it stay on a different origin, behind a capability.

The content gets a genuine origin with a genuine URL, so storage, cookies, history, WebAssembly and streaming all behave natively. Nothing is faked, and nothing is re-implemented in JavaScript.

A  https://app.example                the app: holds the credential and the transport
B  https://<id>.browse.example        untrusted content: holds nothing

Why this is not just an iframe

The hard part is not the network. It is the certificate.

Giving B a real origin over HTTPS needs a certificate for B's hostname, and issuing one per site exhausts CA rate limits, so a single wildcard has to cover every site. But a wildcard matches exactly one label, and only the left-most one (RFC 6125 §6.4.3; CA/Browser Forum Baseline Requirements) — *.*.example is not a certificate any CA issues or any browser accepts. So the target cannot be encoded into the hostname: a clearnet subdomain has too many labels, and a long identifier crowds the 63-character DNS label limit on its own.

So the target is not named at all. The origin is a short, stable hash of it:

id = base32(sha256(canonical))[:20]
B  = https://<id>.<suffix>

One wildcard now covers every site. The hash is deterministic, so a site keeps its cookies and storage across sessions. And because the frame knows only its own id, it supplies a path and never a host — one browse origin cannot ask for another's content.

Use

cfg := realorigin.Config{
    Addr:      "127.0.0.1:7998",
    Suffix:    ".browse.example",       // a different registrable domain from the app's
    AppOrigin: "https://app.example",
}
go cfg.ListenAndServe(ctx)              // serves the worker and the shell, nothing else

Serve realorigin.ResponderJS() from the app origin, first-party, and give it a transport:

realOrigin.configure({
  suffix: '.browse.example',
  fetch: function (target, req) {
    // req: {url, method, headers, body(ArrayBuffer|null), path}
    return myTransport(target, req);   // → {status, headers, body}
  },
});

var id = await realOrigin.register('https://example.com');
frame.src = 'https://' + id + '.browse.example/';

That is the whole integration. The transport is the only part you write.

Telling the visitor what is happening

A transport that has to set up a route before it can fetch anything leaves the frame blank for a while, and a bare spinner wastes that time. Stream it:

realOrigin.progress('connecting through exit …', id);   // just that frame
realOrigin.progress('route established');               // every loading frame

Lines reach the frame until its first response lands — which is exactly the interstitial's lifetime, since the document is replaced after that.

If the built-in shell is too plain for what you have to say, replace it:

cfg := realorigin.Config{ /* … */ Shell: myShell }

Start from realorigin.BootstrapHTML(). A replacement must speak the same bridge protocol, and gets the same __APP_ORIGIN__ / __SUFFIX__ substitutions.

The wire protocol

{ type: 'realorigin-hello', shortid }   → a private port, bound to one target
{ type: 'realorigin-fetch', req: { url, method, headers, body } }
                                        → { status, headers, body } | { error }

error becomes 502, sixty seconds of silence becomes 504, and content-length, transfer-encoding and connection are stripped so the browser recomputes its own framing.

Try it

go run ./cmd/realorigin-demo

Then open http://localhost:7999. The demo's transport is plain HTTP through its own process, which makes it a real-origin proxy that sidesteps CORS. Swap that one function for a mesh, an onion route, a peer-to-peer fetch or a decrypted archive and nothing else changes.

The demo fetches server-side, and that is not the interesting case. Its transport is an HTTP client in the demo's own process, chosen because it needs no infrastructure to try. A transport that runs in the visitor's tab — a wasm client, a WebRTC peer, a local decrypted archive — is what gives this its unusual property: the server then serves the shell and the worker and nothing else, and carries, sees and stores none of the traffic. Nothing scales with how much anyone browses. That is how skywire uses it, and the substrate is identical either way; only the transport moves.

Things that will bite you

Each of these was paid for once already.

  • A wildcard spans one label. Everything about the naming follows from it, and it is why the origin is a hash.
  • Wildcards need DNS-01. HTTP-01 cannot issue them, so a hosted deployment needs a DNS provider credential.
  • A and B must be different names — locally too. Same host and port is one origin, and the browsed page's own scripts then read the app's localStorage, DOM and globals directly: whatever credential the app holds is one call away. Different origins, and the browser refuses all three, leaving postMessage as the only channel and the app deciding what to answer. The certificate does not enforce this — it only makes an HTTPS origin possible. The origin enforces it.
  • Hosted, go further: separate registrable domains. Different hostnames make the two cross-origin, which protects storage, DOM and globals. Only different registrable domains make them cross-site, which additionally stops B from setting Domain=-scoped cookies the app will receive.
  • The responder must be first-party on A. In a cross-origin helper iframe, Storage Partitioning lands it in a different partition from the app's own workers, where it cannot reach the client it exists to call.
  • B must be framed by A. The shell reaches the app through window.parent and refuses to run as a top-level document.
  • Navigations are not intercepted, deliberately: a worker that served the first page would have to be installed by a page it had not served yet. Every path but the worker serves the shell, and each navigation re-runs it.
  • Mixed content decides whether you need a local certificate. All-HTTP on *.localhost needs none, because browsers treat it as a secure context; an HTTPS app forces HTTPS browse origins and a *.<suffix> SAN.

Provenance

Extracted from the real-origin mesh browser in skywire's wasm hypervisor, where it was built to show mesh content without handing that content the visor's identity key. Nothing here is specific to that: the transport was always behind an interface, because the service worker runs on the untrusted origin and must not know what the credential is for.

Licence

MIT.

Documentation

Overview

Package realorigin serves untrusted remote content at a genuine, isolated browser origin whose network layer is a service worker, while the credentials that fetch it stay on a separate origin behind a capability.

The problem it solves is a certificate problem before it is a network one. Rendering content at a real origin over HTTPS needs a certificate for that origin's hostname, and issuing one per site exhausts CA rate limits, so one wildcard has to cover every site. But a wildcard certificate matches exactly one label and only the left-most one (RFC 6125 section 6.4.3, and the CA/Browser Forum Baseline Requirements): *.*.example is not a certificate any CA issues or any browser accepts. So the target cannot be encoded into the hostname — a subdomain, or a name long enough to crowd the 63-character label limit, has nowhere to go.

The way out is to stop naming the target at all. A browse origin is a short, stable hash of it:

id = base32(sha256(canonical))[:20]
B  = https://<id>.<suffix>

One wildcard then covers every site whatever the target looks like; the hash is deterministic, so a site keeps its cookies and storage across sessions; and because the frame knows only its own id, it supplies a path and never a host, which is what stops one browse origin asking for another's content.

Index

Constants

View Source
const IDLen = 20

IDLen is the number of base32 characters in a browse-origin label: 20 characters, so 100 bits of the digest, truncated to leave room under the 63-character DNS label limit.

Variables

View Source
var (
	// ErrNoSuffix is returned when Config.Suffix is empty.
	ErrNoSuffix = errors.New("realorigin: Suffix is required")
	// ErrNoAppOrigin is returned when Config.AppOrigin is empty.
	ErrNoAppOrigin = errors.New("realorigin: AppOrigin is required")
)

Functions

func BootstrapHTML

func BootstrapHTML() []byte

BootstrapHTML returns the built-in shell, as a starting point for a Config.Shell that wants a richer interstitial without reimplementing the bridge.

func Handler

func Handler(cfg Config) (http.Handler, error)

Handler returns the browse-origin handler.

Every path except the worker's serves the same shell, which is what makes navigation work: the worker deliberately does not intercept navigations, so each one lands here and the shell fetches that path and writes it into the origin. Serving the shell only at "/" would break every link on every page.

func Host

func Host(canonical, suffix string) string

Host returns the full browse-origin hostname for a canonical target under suffix, e.g. "k3f9….mesh.localhost". A missing leading dot on the suffix is supplied.

func ID

func ID(canonical string) string

ID returns the browse-origin label for a canonical target string.

The caller decides what canonical means for its own address space and is responsible for making equivalent addresses produce the same string — two spellings of one target that canonicalize differently get two origins, and so two separate cookie jars. The JavaScript half computes this identically.

func IDFromHost

func IDFromHost(host, suffix string) (string, bool)

IDFromHost recovers the label from a browse-origin hostname, and reports whether host was in fact a browse origin under suffix. A port is not accepted here: strip it first.

func ResponderJS

func ResponderJS() []byte

ResponderJS returns the script to serve from the APP origin, first-party.

It has to be first-party there. Put it in a cross-origin helper iframe and Storage Partitioning lands the helper in a different partition from the app's own workers, where it cannot reach the client it exists to call.

func ServiceWorkerJS added in v0.1.1

func ServiceWorkerJS() []byte

ServiceWorkerJS returns the transport worker, for an embedder that serves the browse origin itself rather than through Handler — a host-routed setup where B and the app share one listener, say. Serve it at Config.SWPath.

Types

type Config

type Config struct {
	// Addr is the listen address for ListenAndServe. Ignored by Handler.
	Addr string

	// Suffix is the browse-origin domain suffix, with or without a leading dot
	// (".mesh.localhost" locally, or a registrable domain you hold). Required.
	//
	// Hosted, this must be a DIFFERENT registrable domain from the app's, not a
	// subdomain of it: different hosts make the two cross-origin, but only
	// different registrable domains make untrusted content fully cross-site from
	// the app.
	Suffix string

	// AppOrigin is the origin of the app that holds the credentials and answers
	// the handshake, e.g. "https://app.example". Required.
	AppOrigin string

	// SWPath is where the service worker is served. Defaults to "/sw.js". It must
	// stay at the root of the origin, since the worker claims the whole scope.
	SWPath string

	// Shell replaces the bootstrap shell served for navigations. Empty uses the
	// built-in one, which is deliberately plain: it says what is happening and
	// prints whatever progress the transport reports, and no more.
	//
	// Override it when the wait is worth dressing — a slow transport that sets up
	// a route before it can fetch anything has a real story to tell, and a bare
	// spinner wastes it. A replacement must speak the same bridge protocol:
	// handshake to window.parent with {type:'realorigin-hello', shortid}, register
	// the worker, relay 'realorigin-fetch' between the worker and the app, and
	// write the response into the document. Start from web/bootstrap.html.
	//
	// __APP_ORIGIN__ and __SUFFIX__ are substituted here exactly as they are in
	// the built-in shell.
	Shell []byte

	// Worker replaces the transport service worker served at SWPath. Empty uses
	// the built-in one.
	//
	// The built-in worker is deliberately small and names no transport, which is
	// what makes it auditable on the untrusted origin. A replacement inherits that
	// responsibility: it runs on B, so whatever it can reach, untrusted content can
	// reach through it. It must speak the same bridge protocol — relay each
	// non-navigation fetch to a controlling client as {type:realorigin-fetch}
	// over a MessagePort and answer with the returned {status, headers, body}.
	//
	// A worker that needs companion files (a wasm module and its loader, say)
	// serves them through Assets.
	Worker []byte

	// Assets are extra paths served verbatim on the browse origin, e.g.
	// {"/sw.wasm": mod, "/wasm_exec.js": loader}. Keys are absolute paths and win
	// over the shell; SWPath still wins over both.
	//
	// Every byte here is served to the UNTRUSTED origin, so put nothing in it that
	// the browsed content should not have.
	Assets map[string][]byte
}

Config describes the browse-origin server: the static half of the substrate.

It never proxies content. It serves the service worker at one path and the bootstrap shell at every other, and that is all. Every fetch the rendered page makes is relayed to the visitor's own app tab and satisfied by the transport there — so a public deployment of this carries, sees and stores none of the traffic, and nothing about it scales with how much anyone browses.

func (Config) ListenAndServe

func (cfg Config) ListenAndServe(ctx context.Context) error

ListenAndServe runs the browse-origin server until ctx is canceled.

Directories

Path Synopsis
cmd
realorigin-demo command
Command realorigin-demo is a working example of the substrate with an ordinary HTTP transport.
Command realorigin-demo is a working example of the substrate with an ordinary HTTP transport.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL