927 lines
46 KiB
Markdown
927 lines
46 KiB
Markdown
# Built-in Browser Local HTTPS and Certificate Trust
|
|
|
|
## Status
|
|
|
|
Implemented reference design for
|
|
[issue #8454](https://github.com/stablyai/orca/issues/8454).
|
|
|
|
The branch includes scheme-less local-dev URL classification, certificate-specific
|
|
failure copy, `Try HTTPS`, exact per-WebContents certificate grants,
|
|
`Proceed Anyway (Unsafe)`, local IPC, remote/headless runtime propagation, and
|
|
focused unit and real-Electron coverage. A session-level request gate contains
|
|
Chromium's cached certificate continuation so approval cannot leak to sibling
|
|
tabs, assets, fetches, iframes, or WebSockets. The release gates below remain
|
|
the acceptance contract for Electron upgrades and cross-platform validation.
|
|
|
|
## Summary
|
|
|
|
Orca's built-in browser cannot open a local HTTPS development server when the
|
|
server presents an untrusted certificate. A scheme-less local address also
|
|
defaults to HTTP, so entering `localhost:3000` does not discover an HTTPS-only
|
|
server.
|
|
|
|
This design keeps HTTP as the default for the scheme-less local forms that
|
|
already use it, adds a visible `Try HTTPS` recovery action after a failed local
|
|
HTTP navigation, and adds an explicit `Proceed Anyway (Unsafe)` path for an
|
|
untrusted local HTTPS certificate. Certificate approval is narrow and
|
|
temporary: it is bound to one browser WebContents, one secure endpoint, the
|
|
SHA-256 digest of the exact leaf certificate, and the specific certificate
|
|
error. A secure endpoint treats `https` and its companion `wss` scheme as one
|
|
TLS endpoint but still includes the canonical hostname and effective port. No
|
|
approval state is persisted.
|
|
|
|
The certificate decision runs in the process that owns the browser page. Local
|
|
desktop webviews use the desktop main process; SSH/headless pages use the remote
|
|
runtime process and expose the decision through runtime RPC.
|
|
|
|
## Problem
|
|
|
|
There are three independent behaviors behind the current failure.
|
|
|
|
1. `normalizeBrowserNavigationUrl` and the tab-create entry classifier prepend
|
|
`http://` to scheme-less loopback input. Explicit `https://` input is already
|
|
preserved.
|
|
2. Electron rejects an untrusted server certificate by default. Orca has no
|
|
`certificate-error` decision flow, so a self-signed development certificate
|
|
fails with a Chromium certificate error such as
|
|
`ERR_CERT_AUTHORITY_INVALID (-202)`.
|
|
3. Browser load errors are presented as generic connectivity failures. For a
|
|
loopback URL, Orca advises the user to check that the server is running even
|
|
when the server responded and only certificate verification failed.
|
|
|
|
The same trust limitation exists in the offscreen browser backend used by
|
|
headless and SSH-owned browser pages. A desktop-only handler would fix the
|
|
visible local webview while leaving remote browser ownership inconsistent.
|
|
|
|
## Goals
|
|
|
|
- Show certificate-specific failure copy for main-frame certificate errors.
|
|
- Let a user explicitly approve an untrusted local development certificate.
|
|
- Bind approval to the exact browser surface, secure endpoint, leaf certificate
|
|
digest, and certificate error.
|
|
- Keep approval in memory only and clear it with the owning browser surface.
|
|
- Support both desktop webviews and SSH/headless offscreen browser pages.
|
|
- Give scheme-less local input a discoverable path to HTTPS without silently
|
|
changing the existing HTTP default.
|
|
- Preserve the current browser sandbox, navigation allowlist, session profiles,
|
|
and mixed-content policy.
|
|
- Keep browser failure state accurate across event-order races, early webview
|
|
attachment, reload, stale user actions, and remote latency.
|
|
|
|
## Non-goals
|
|
|
|
- Do not automatically trust a self-signed certificate.
|
|
- Do not disable `webSecurity`, enable insecure mixed content, or add
|
|
`--ignore-certificate-errors`.
|
|
- Do not install a certificate into the operating-system trust store.
|
|
- Do not persist certificate exceptions across app restarts, browser surface
|
|
recreation, or session-profile changes.
|
|
- Do not default every scheme-less localhost URL to HTTPS.
|
|
- Do not automatically probe HTTPS and then downgrade to HTTP.
|
|
- Do not add a general-purpose exception flow for public websites in v1.
|
|
- Do not make every Chromium certificate error overridable in v1.
|
|
- Do not extend localhost worktree-label proxying to HTTPS targets.
|
|
|
|
## Product Decisions
|
|
|
|
### Scheme-less local input continues to use HTTP
|
|
|
|
`localhost:3000` remains `http://localhost:3000/`. HTTP is still the common
|
|
development-server default, and changing the inference globally would turn
|
|
working HTTP servers into certificate or TLS failures.
|
|
|
|
When an HTTP navigation to a loopback URL fails, the browser failure overlay
|
|
offers `Try HTTPS`. The action replaces only the URL scheme and preserves the
|
|
host, explicit non-default port, path, query, and fragment. An absent port, or
|
|
an explicit `:80` normalized away by the URL parser, becomes HTTPS's default
|
|
port 443. Orca does not automatically retry because an invisible
|
|
cross-protocol retry would add latency, send an unexpected second request, and
|
|
make downgrade behavior opaque.
|
|
|
|
Explicit input remains authoritative:
|
|
|
|
- `http://localhost:3000` stays HTTP.
|
|
- `https://localhost:3000` stays HTTPS.
|
|
- URLs advertised by a workspace process keep their advertised scheme.
|
|
|
|
The shared browser URL domain becomes the source of truth for the existing
|
|
scheme-less local-dev classification. The tab-create entry classifier calls
|
|
that shared logic instead of keeping a second regular expression and scheme
|
|
rule. This consolidation must not reuse the stricter certificate-eligibility
|
|
predicate: doing so would change current behavior for inputs such as
|
|
`0.0.0.0`, bracketed non-loopback IPv6 addresses, and `.localhost` subdomains.
|
|
|
|
### V1 approval covers untrusted local authorities only
|
|
|
|
The first version offers `Proceed Anyway (Unsafe)` only when all of these are
|
|
true:
|
|
|
|
- the failure is for the main frame;
|
|
- the attempted URL is HTTPS;
|
|
- Electron reports `net::ERR_CERT_AUTHORITY_INVALID`, corresponding to
|
|
Chromium load error `-202`;
|
|
- the host is an eligible loopback host;
|
|
- the WebContents is a browser surface managed by Orca; and
|
|
- Orca retained a live pending challenge for that browser surface.
|
|
|
|
Other certificate errors receive accurate copy but no bypass action. In
|
|
particular, v1 does not bypass revoked certificates, malformed certificates,
|
|
weak keys, date errors, or hostname/SAN mismatches. Expanding the allowlist
|
|
requires a separate security decision and dedicated tests.
|
|
|
|
### Eligible loopback hosts
|
|
|
|
The shared predicate accepts:
|
|
|
|
- `localhost`, case-insensitively, with an optional trailing dot;
|
|
- valid subdomains of `.localhost`, also with an optional trailing dot;
|
|
- valid IPv4 addresses in `127.0.0.0/8`; and
|
|
- IPv6 loopback `::1`.
|
|
|
|
The predicate does not treat `0.0.0.0`, `::`, arbitrary bracketed IPv6
|
|
addresses, private LAN addresses, or a DNS name that merely resolves to
|
|
loopback as certificate-bypass eligible. Wildcard bind addresses should already
|
|
be normalized to a connectable loopback address before navigation.
|
|
|
|
SSH local forwards are eligible when their browser-facing URL uses one of these
|
|
loopback hosts. Forwarding does not fix certificate names: a URL using
|
|
`127.0.0.1` still needs an IP SAN for `127.0.0.1`; a certificate containing only
|
|
`localhost` will fail with `ERR_CERT_COMMON_NAME_INVALID`, which v1 does not
|
|
bypass. Custom advertised DNS names remain subject to normal system trust in
|
|
v1, even if the name maps to loopback.
|
|
|
|
### Approval lifetime
|
|
|
|
An accepted grant is scoped to:
|
|
|
|
```text
|
|
browser WebContents ID + secure endpoint + leaf SHA-256 digest + error name
|
|
```
|
|
|
|
The secure endpoint canonicalizes `wss:` to its companion `https:` endpoint and
|
|
includes the normalized hostname and effective port. This lets an approved
|
|
local page load same-endpoint HTTPS assets and its WSS development socket. A
|
|
grant for `https://localhost:3000` does not apply to port 3001, to
|
|
`https://127.0.0.1:3000`, to another browser tab, or to a replacement
|
|
certificate.
|
|
|
|
Construct the secure endpoint from the parsed request URL: map `https:` and
|
|
`wss:` to `https:`, lowercase the hostname, remove one optional trailing dot,
|
|
use the URL parser's canonical IP representation, and use port 443 when the
|
|
parsed port is empty. Exclude credentials, path, query, and fragment. Reject
|
|
every other scheme.
|
|
|
|
Accepted grants survive reloads and same-surface navigation back to the exact
|
|
endpoint. They are cleared when the WebContents commits a main-frame navigation
|
|
whose canonical secure endpoint differs from the grant's endpoint (reusing the
|
|
per-WebContents navigation-sequence tracking), when the WebContents is destroyed
|
|
or replaced, the session profile changes, the browser-owning process exits, or
|
|
the app restarts. Binding grant lifetime to the granting main-frame document
|
|
prevents a later top-level document in the same tab — for example a navigation
|
|
to a public site — from silently reusing the loopback grant for a same-endpoint
|
|
iframe, fetch, or WSS subrequest.
|
|
Clearing a grant affects future requests; it cannot retroactively terminate a
|
|
document that already loaded. Grants are not written to settings, browser
|
|
history, workspace session state, or logs.
|
|
|
|
Chromium caches an accepted `certificate-error` continuation across the
|
|
Electron session partition. Orca therefore retains every endpoint/certificate
|
|
identity accepted in that partition until the browser-owning process exits and
|
|
gates all later HTTPS/WSS requests with `webRequest.onBeforeRequest`. Only the
|
|
WebContents holding the matching grant may reach a cached endpoint. A sibling
|
|
main-frame navigation receives a synthetic challenge for the known identity;
|
|
sibling subresources, fetches, iframes, workers, and WSS requests fail silently.
|
|
|
|
Because a preflight request gate cannot inspect the certificate that will be
|
|
presented after Chromium's cache is consulted, one endpoint is locked to the
|
|
first accepted untrusted leaf identity for that process lifetime. If that
|
|
endpoint later presents a different untrusted leaf, strict verification rejects
|
|
it and Orca does not offer another bypass until the browser-owning process
|
|
restarts. A replacement system-trusted certificate may load normally; the
|
|
conservative request gate still keeps sibling WebContents blocked until restart.
|
|
|
|
## User Experience
|
|
|
|
### Local HTTP failure
|
|
|
|
For a failed `http://` loopback navigation, keep the current connectivity
|
|
title and recovery hint. Add `Try HTTPS` when an HTTPS form can be constructed.
|
|
|
|
Example:
|
|
|
|
```text
|
|
Can't reach localhost:3000
|
|
We couldn't connect to your local server.
|
|
|
|
[Try HTTPS] [Retry] [Copy Address] [Open Externally]
|
|
```
|
|
|
|
`Try HTTPS` is the default button because it is the targeted recovery action
|
|
for a local HTTP failure. Keep the existing `Copy Address` recovery action;
|
|
`Retry` uses `outline`, while `Copy Address` and `Open Externally` stay quiet
|
|
secondary actions. `Try HTTPS` does not appear for an already-HTTPS URL or a
|
|
non-eligible host.
|
|
|
|
### Certificate failure
|
|
|
|
For `ERR_CERT_AUTHORITY_INVALID (-202)` on an eligible local endpoint, replace
|
|
the generic connectivity content with:
|
|
|
|
```text
|
|
Connection isn't secure
|
|
Orca doesn't trust the authority that issued the certificate for localhost:3000.
|
|
|
|
For local development, use a trusted local certificate when possible.
|
|
|
|
[Open Externally] [Retry] [Copy Address] [Proceed Anyway (Unsafe)]
|
|
```
|
|
|
|
- `Open Externally` is the default safe recovery action when the URL is
|
|
reachable from the desktop.
|
|
- When `Open Externally` is unavailable, `Retry` becomes the default action.
|
|
- `Proceed Anyway (Unsafe)` uses `outline`; `Copy Address` stays quiet.
|
|
- `Proceed Anyway (Unsafe)` is always visibly labeled; it is not hidden behind
|
|
a tooltip.
|
|
- The unsafe action does not use the destructive color. It does not delete or
|
|
irreversibly mutate user data, and the explicit label carries the warning.
|
|
- Use a muted `ShieldAlert` icon from `lucide-react`; do not add a warning color
|
|
or new token.
|
|
|
|
For a remote-owned page whose URL is remote localhost, `Open Externally` is
|
|
hidden because the desktop system browser cannot reach the remote loopback
|
|
address. It remains available for a desktop-owned page or a locally forwarded
|
|
URL.
|
|
|
|
For certificate errors that v1 does not permit bypassing, show the same
|
|
certificate-specific title with error-specific body text, `Retry`,
|
|
`Copy Address`, and an eligible `Open Externally` action. `Copy Address` remains
|
|
present as a quiet secondary action in every certificate-failure branch,
|
|
eligible or not, so only `Try HTTPS` and `Proceed Anyway (Unsafe)` toggle in and
|
|
out across cases. The same default-action rule applies as for `-202`:
|
|
`Open Externally` is the default when the URL is reachable from the desktop,
|
|
otherwise `Retry` becomes the default. Do not show the local-server-running
|
|
hint.
|
|
|
|
Use this presentation mapping, inserting the display host into the copy:
|
|
|
|
| Chromium code | Body copy | Proceed in v1 |
|
|
| ----------------------- | -------------------------------------------------------------------------- | ----------------------------- |
|
|
| `-200` | `The certificate doesn't match {host}.` | No |
|
|
| `-201` | `The certificate for {host} isn't valid at the current date and time.` | No |
|
|
| `-202` | `Orca doesn't trust the authority that issued the certificate for {host}.` | Eligible local endpoints only |
|
|
| Other certificate error | `Orca couldn't verify the certificate for {host}.` | No |
|
|
|
|
Keep the raw error name/code available to diagnostics and optional details, but
|
|
do not make users interpret it to understand the primary failure.
|
|
|
|
### Interaction behavior
|
|
|
|
- The overlay is persistent inline UI because the user must read and act on the
|
|
failure; do not use a toast.
|
|
- The overlay does not steal focus when it appears.
|
|
- Actions are reachable by Tab and have visible focus rings.
|
|
- Use `aria-live="polite"`; repeated background failures must not repeatedly
|
|
announce the same message.
|
|
- Clicking `Proceed Anyway (Unsafe)` disables the action immediately, and also
|
|
disables the other overlay actions (`Retry`, `Try HTTPS`, `Copy Address`,
|
|
`Open Externally`) for the duration of the approval round-trip so a stale
|
|
click cannot race the controller's own `loadURL`. Re-enable them once a
|
|
success or typed-failure response returns.
|
|
- Map every `proceedCertificate` failure reason to an overlay outcome:
|
|
`expired`, `changed`, `ineligible`, and `missing` keep the overlay open and
|
|
show the inline recovery message below; `navigated` shows no message because
|
|
the overlay for that navigation is already cleared. In every non-success case
|
|
`Proceed Anyway (Unsafe)` stays disabled until a fresh pending challenge is
|
|
announced rather than re-enabling on its own.
|
|
- Delay the spinner by 200 ms, matching the style guide's remote-latency rule.
|
|
After that delay, show the canonical `Loader2` with `Connecting…` and use a
|
|
fixed button width so the label swap cannot move adjacent actions.
|
|
- If approval expired or the certificate changed, keep the overlay open and
|
|
show `The certificate changed or the approval expired. Retry the page.`
|
|
- A successful main-frame navigation clears the visible certificate failure.
|
|
|
|
The UI must use existing `background`, `foreground`, `muted-foreground`,
|
|
`border`, and `ring` tokens plus the existing shadcn `Button` primitive. No new
|
|
color, radius, or shadow tier is required. Do not apply a blanket opacity to
|
|
the overlay's interactive content; use semantic muted text classes so button
|
|
and focus-ring contrast remains intact. Put every new user-visible string
|
|
through the existing localization catalog.
|
|
|
|
## Architecture
|
|
|
|
### Ownership
|
|
|
|
The browser-owning main process is the only authority that can accept a
|
|
certificate challenge.
|
|
|
|
- Desktop-owned `<webview>` pages are controlled by the desktop main process.
|
|
- Headless/SSH-owned pages are controlled by the remote `orca serve` main
|
|
process through `OffscreenBrowserBackend`.
|
|
- The renderer only presents a pending challenge and requests approval. It
|
|
never decides whether a certificate is trusted.
|
|
|
|
### New main-process controller
|
|
|
|
Add `src/main/browser/browser-certificate-trust-controller.ts` to manage pending
|
|
certificate challenges, and
|
|
`src/main/browser/browser-certificate-request-guard.ts` to manage grants plus
|
|
the session-level cached-certificate request boundary.
|
|
|
|
The controller owns:
|
|
|
|
```ts
|
|
type BrowserCertificateFailure = {
|
|
challengeId: string
|
|
browserPageId: string
|
|
errorCode: number | null
|
|
error: string
|
|
origin: string
|
|
displayHost: string
|
|
canProceed: boolean
|
|
observedAt: number
|
|
}
|
|
|
|
type PendingBrowserCertificateChallenge = {
|
|
challengeId: string
|
|
guestWebContentsId: number
|
|
browserPageId: string | null
|
|
navigationSequence: number
|
|
navigationUrl: string
|
|
origin: string
|
|
secureEndpoint: string
|
|
leafCertificateSha256: string
|
|
errorCode: number | null
|
|
error: string
|
|
expiresAt: number
|
|
}
|
|
|
|
type BrowserCertificateGrant = {
|
|
guestWebContentsId: number
|
|
secureEndpoint: string
|
|
leafCertificateSha256: string
|
|
error: string
|
|
}
|
|
```
|
|
|
|
Generate challenge IDs with `randomUUID()`. Compute the certificate identity in
|
|
the browser-owning main process as SHA-256 over the DER bytes of Electron's leaf
|
|
`certificate.data`; do not depend on the undocumented formatting or algorithm
|
|
of `certificate.fingerprint`. Renderer and runtime payloads expose no
|
|
certificate bytes or digest—only the challenge ID, display host, origin, error
|
|
code, error name, `canProceed`, and observation time.
|
|
|
|
Pending challenges expire after five minutes and are bounded to 32 entries.
|
|
Accepted grants are bounded to 32 entries per browser-owning process and are
|
|
also removed with their WebContents. Bounds evict the oldest entry and notify
|
|
the affected UI when a live challenge is evicted. Ordinary cleanup should keep
|
|
both collections much smaller. Accepted endpoint identities are not evicted:
|
|
Chromium's corresponding cache cannot be cleared narrowly, so forgetting one
|
|
would reopen cross-WebContents access until process exit.
|
|
|
|
### Certificate event handling
|
|
|
|
Register exactly one app-level `certificate-error` listener after Electron is
|
|
ready but before either a desktop window or the offscreen backend can create a
|
|
browser page. Every branch must call Electron's callback exactly once; an
|
|
unexpected exception fails closed with `callback(false)`.
|
|
|
|
Electron supplies the certificate error as a string on this event, while
|
|
`did-fail-load` supplies the Chromium number later. Normalize the string to one
|
|
canonical error name and map only known names to numbers for presentation. An
|
|
unknown name keeps `errorCode: null` and is never bypass-eligible.
|
|
|
|
For every event:
|
|
|
|
1. Resolve the WebContents through `BrowserManager` and reject unmanaged,
|
|
retired, or popup WebContents.
|
|
2. Parse the request URL, normalize its secure endpoint, and compute the leaf
|
|
certificate SHA-256 digest. Invalid data fails closed.
|
|
3. Compare the normalized error name, endpoint, and digest against an accepted
|
|
grant for this exact WebContents. On an exact match, first retain the accepted
|
|
identity in the owning Electron session, then call `event.preventDefault()`
|
|
and `callback(true)`.
|
|
4. If no grant matches, reject non-main-frame failures without creating a
|
|
challenge. Subresources and iframes can consume an existing grant but cannot
|
|
mint one.
|
|
5. For a main-frame failure, record a pending challenge only when the URL and
|
|
error are eligible. Notify the owning page when its page ID is known, then
|
|
call `callback(false)`.
|
|
|
|
Track a monotonically increasing main-frame navigation sequence per
|
|
WebContents. A new main-frame navigation invalidates its pending challenge in
|
|
both main and renderer state before the next certificate decision. Repeated
|
|
identical events within one sequence reuse the challenge ID; a retry or a
|
|
different navigation gets a new ID. This prevents an old button or compromised
|
|
renderer from approving a page the user has already left.
|
|
|
|
The first failure is always rejected. User approval records a grant and reloads
|
|
the page; the next certificate event is the one that succeeds.
|
|
|
|
Do not leave Electron's callback pending while waiting for user input. Rejecting
|
|
first gives Orca a normal load-failure lifecycle, avoids an unbounded blocked
|
|
navigation, and makes stale approval cleanup deterministic.
|
|
|
|
Do not use `session.setCertificateVerifyProc`. It applies to an entire session
|
|
partition, cannot bind a decision to one browser page, and verification results
|
|
can be cached by Chromium's network service.
|
|
|
|
### Session request gate and Electron upgrade guard
|
|
|
|
The pinned Electron 43 binary caches `callback(true)` broadly enough that a
|
|
second WebContents in the same partition can load the endpoint without another
|
|
`certificate-error` event. The certificate callback alone is therefore not an
|
|
isolation boundary.
|
|
|
|
Install exactly one `webRequest.onBeforeRequest` listener on every Orca browser
|
|
session before its first page loads. Electron permits only one listener for this
|
|
event, so the browser session registry owns installation and removal. Once an
|
|
endpoint identity has been accepted, the listener blocks every HTTPS/WSS
|
|
request to that endpoint unless `details.webContentsId` has the matching grant.
|
|
Requests without a WebContents ID, including background/worker traffic, fail
|
|
closed. A blocked main-frame request recreates the challenge and certificate
|
|
load error; other resource types are canceled without replacing the current
|
|
page's overlay.
|
|
|
|
The real-Electron integration suite must prove same-tab reload, same-endpoint
|
|
HTTPS assets and WSS, different-tab documents and subresources, and
|
|
different-port isolation against the exact Electron version in
|
|
`pnpm-lock.yaml`. Any Electron upgrade that breaks those tests blocks release
|
|
until the design is revised; unit mocks are insufficient.
|
|
|
|
### BrowserManager integration
|
|
|
|
Add a narrow BrowserManager query API instead of exposing its internal maps:
|
|
|
|
```ts
|
|
getManagedBrowserGuestContext(webContentsId: number): {
|
|
browserPageId: string | null
|
|
worktreeId: string | null
|
|
sessionProfileId: string | null
|
|
owner: 'desktop-webview' | 'offscreen'
|
|
} | null
|
|
```
|
|
|
|
For guest lifecycle, follow the existing extracted-controller precedent
|
|
(`browser-grab-session-controller.ts`, `browser-guest-ui.ts`,
|
|
`browser-download-destination.ts`), all of which BrowserManager drives by
|
|
calling a plain method on the controller directly from `registerGuest` /
|
|
`unregisterGuest`. Have BrowserManager call
|
|
`certificateTrustController.onGuestRegistered({ browserPageId, webContentsId })`
|
|
and `.onGuestRetired(webContentsId)` from those same bodies, rather than adding a
|
|
new listener/emitter registration API to BrowserManager — there is no such
|
|
pub-sub surface in `src/main/browser` today and this single consumer does not
|
|
warrant introducing one.
|
|
|
|
`getManagedBrowserGuestContext` must recognize an attached primary desktop
|
|
guest before renderer registration, returning a null page ID, while rejecting
|
|
popup descendants that merely inherited browser policies. It must also
|
|
recognize offscreen pages after `registerOffscreenGuest` and stop recognizing a
|
|
guest immediately when BrowserManager retires or unregisters it. The controller
|
|
re-checks this ownership on every certificate event even if it still has an
|
|
in-memory grant.
|
|
|
|
An attached desktop webview can encounter a certificate error before its
|
|
document reaches `dom-ready`, which is when BrowserPane currently registers the
|
|
guest. Keep pending challenges keyed by WebContents ID until registration
|
|
supplies the page ID, then flush the failure to the renderer.
|
|
|
|
BrowserPane should also register idempotently on the webview's `did-attach`
|
|
event, with `dom-ready` retained as a fallback. `registerGuest` should report
|
|
whether the main process accepted the registration so a rare attach-policy race
|
|
can retry at `dom-ready`; the renderer must not cache the WebContents ID as
|
|
registered until it receives `true`.
|
|
|
|
Offscreen pages already register before `loadURL`, so their page ID is normally
|
|
available when the certificate event fires. Bring their main-frame navigation
|
|
and `did-fail-load` observation under the same BrowserManager lifecycle used by
|
|
desktop guests; the current offscreen loader only logs a rejected `loadURL`, so
|
|
it cannot by itself populate runtime failure snapshots.
|
|
|
|
### Renderer state
|
|
|
|
Certificate challenges are transient runtime state and must not be added to
|
|
the persisted `BrowserPage` schema.
|
|
|
|
Add a transient map to the browser store:
|
|
|
|
```ts
|
|
browserCertificateFailuresByPageId: Record<string, BrowserCertificateFailure>
|
|
```
|
|
|
|
The workspace session serializer must omit this map. Clear an entry when:
|
|
|
|
- the page completes a successful main-frame navigation;
|
|
- the user starts a different navigation;
|
|
- the page or workspace closes;
|
|
- the owning browser WebContents is replaced; or
|
|
- the main process reports that the challenge expired.
|
|
|
|
The existing `BrowserLoadError` remains the persisted diagnostic source. Add a
|
|
pure Chromium-error classifier for Electron 43's certificate range (`-200`
|
|
through `-219`, excluding the unused values) so certificate failures still
|
|
render accurate copy after restore even when no live challenge exists. A live
|
|
certificate failure's error code takes presentation precedence over a
|
|
synthesized `-1` Chromium-error-page fallback. Independently, only a live,
|
|
matching challenge controls whether `Proceed Anyway (Unsafe)` is enabled.
|
|
|
|
Use both the page ID and attempted origin when combining the live challenge
|
|
with `BrowserLoadError`. A stale challenge from a prior navigation must never
|
|
add a proceed action to a different failure. Main-process navigation-sequence
|
|
invalidation is the security boundary; renderer cleanup is defense in depth and
|
|
keeps the UI honest.
|
|
|
|
### Local IPC
|
|
|
|
Extend the browser preload API with:
|
|
|
|
```ts
|
|
onCertificateFailureChanged(
|
|
callback: (event: {
|
|
browserPageId: string
|
|
failure: BrowserCertificateFailure | null
|
|
}) => void
|
|
): () => void
|
|
|
|
proceedCertificate(args: {
|
|
browserPageId: string
|
|
challengeId: string
|
|
}): Promise<
|
|
| { ok: true }
|
|
| {
|
|
ok: false
|
|
reason: 'expired' | 'changed' | 'ineligible' | 'missing' | 'navigated'
|
|
}
|
|
>
|
|
```
|
|
|
|
Register the handler with the existing browser IPC group and reuse
|
|
`isTrustedBrowserRenderer`. The main process resolves the page to its current
|
|
WebContents and then consumes the exact pending challenge. Renderer-provided
|
|
origin, error code, or certificate identity are never trusted.
|
|
|
|
On success, main consumes the challenge, records the grant, and calls
|
|
`loadURL` with the challenge's main-owned `navigationUrl`. It must not use
|
|
`webContents.getURL()`, which may be `chrome-error://chromewebdata/` or a
|
|
previously committed page. It also must not reload if the page's current
|
|
navigation sequence no longer matches the challenge. The renderer does not
|
|
assign `webview.src` as a second side effect.
|
|
|
|
### Remote runtime RPC
|
|
|
|
The remote browser owner exposes equivalent behavior through:
|
|
|
|
```text
|
|
browser.certificate.proceed
|
|
```
|
|
|
|
with the runtime worktree selector, remote browser page ID, and challenge ID.
|
|
Advertise support through a new `browser.certificate-trust.v1` capability only
|
|
when the runtime has both a browser backend and the certificate controller.
|
|
Keep the method out of the mobile-scope RPC allowlist; it is for an
|
|
authenticated runtime-scope Orca client presenting browser chrome, not a guest
|
|
page or unauthenticated caller.
|
|
|
|
Extend `RuntimeMobileSessionBrowserTab` compatibly with optional `loadError`
|
|
and `certificateFailure` fields. Both are emitted from live runtime state.
|
|
`loadError` keeps the existing `BrowserPage` last-known diagnostic semantics,
|
|
including workspace persistence; `certificateFailure` is transient and is
|
|
never written to workspace persistence. Controller changes, challenge expiry,
|
|
navigation, and offscreen `did-fail-load` must mark the runtime session snapshot
|
|
dirty so clients do not wait for unrelated state to change. Web clients and
|
|
desktop clients reconcile these fields through the existing remote page-handle
|
|
mapping. Each full snapshot replaces the prior transient certificate failure
|
|
for that page, including clearing it when the field is absent. An older runtime
|
|
without the capability always clears any previously mirrored proceed action.
|
|
|
|
When the active page has a remote owner, BrowserPane routes approval to that
|
|
environment. It must not call local desktop IPC. If the runtime lacks the new
|
|
capability, the UI still classifies the certificate failure accurately but does
|
|
not render `Proceed Anyway (Unsafe)`. `Try HTTPS`, retry, and other navigation
|
|
actions continue through the existing owner-aware navigation path rather than
|
|
touching a local webview for a remote-owned page.
|
|
|
|
### Scheme recovery
|
|
|
|
Add pure functions in the shared browser URL domain:
|
|
|
|
```ts
|
|
classifySchemeLessLocalDevAddress(rawInput: string): URL | null
|
|
isEligibleLocalCertificateHost(hostname: string): boolean
|
|
toHttpsRecoveryUrl(rawUrl: string): string | null
|
|
```
|
|
|
|
`classifySchemeLessLocalDevAddress` preserves the exact current HTTP-default
|
|
input set and is shared by address-bar normalization and tab-create entry. It
|
|
is deliberately broader than certificate eligibility.
|
|
|
|
`isEligibleLocalCertificateHost` accepts a parsed/canonical hostname; normalize
|
|
case and an optional trailing dot and handle the brackets returned by
|
|
`URL.hostname` for IPv6. Validate IPv4 octets and DNS label boundaries instead
|
|
of using a substring or loose suffix check.
|
|
|
|
`toHttpsRecoveryUrl` returns a value only for an HTTP URL with an eligible
|
|
loopback host. It changes `protocol` to `https:` on a parsed `URL` object and
|
|
preserves credentials, hostname, an explicit non-default port, path, query, and
|
|
fragment. When no port is present, including when the parsed HTTP URL normalized
|
|
`:80` away, the result uses HTTPS's default port 443. It never probes either
|
|
protocol.
|
|
|
|
Both address-bar submission and tab-create entry use the shared local-address
|
|
classifier. Existing advertised workspace-port URLs remain authoritative and
|
|
require no new probing.
|
|
|
|
## Security Model
|
|
|
|
### Invariants
|
|
|
|
- Default certificate verification remains strict.
|
|
- Only a trusted Orca renderer or authenticated runtime-scope Orca client can
|
|
request approval.
|
|
- The renderer cannot choose the origin, certificate digest, error,
|
|
WebContents, or eligibility result.
|
|
- A grant is exact-match only and never applies across tabs, ports,
|
|
certificates, or browser-owning processes.
|
|
- Session-cached accepted endpoints remain request-gated until process exit,
|
|
even after their original WebContents or bounded grant is removed.
|
|
- Certificate PEM/DER data and certificate digests do not enter workspace
|
|
state, history, telemetry, or ordinary logs.
|
|
- Subresource and iframe certificate failures cannot mint an approval action;
|
|
they can consume an already-approved exact-endpoint grant on the same
|
|
WebContents.
|
|
- The proceed affordance exists only in Orca chrome; guest content has no
|
|
direct approval channel. Main-process challenge validation remains the
|
|
security boundary and does not assume proof of a physical click.
|
|
- A remote client cannot approve a certificate in a different runtime
|
|
environment.
|
|
- Starting another main-frame navigation invalidates the pending approval in
|
|
the browser-owning process, not only in renderer state.
|
|
|
|
### Threat cases
|
|
|
|
| Threat | Required behavior |
|
|
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
| Compromised web page sends forged IPC | Guest WebContents has no preload API; trusted-renderer check rejects it. |
|
|
| Compromised renderer invents a challenge | Main consumes only a server-created pending challenge tied to the current page and WebContents. |
|
|
| Certificate changes after approval | Leaf-digest mismatch rejects; a second untrusted leaf for that endpoint cannot be bypassed until restart. |
|
|
| Same host uses a different port | Secure-endpoint mismatch rejects. |
|
|
| Another tab visits the approved endpoint | WebContents mismatch rejects. |
|
|
| Approved tab later loads a foreign top document | Grant is invalidated when the main frame navigates to a different endpoint; the new document cannot reuse it. |
|
|
| Challenge is replayed | Challenge ID is single-use and expires. |
|
|
| User leaves the failed page before clicking | Navigation-sequence mismatch rejects without reloading the stale URL. |
|
|
| Approved page opens same-endpoint WSS/HMR | Existing endpoint grant permits it; WSS cannot create a grant by itself. |
|
|
| Public site presents an untrusted certificate | Host eligibility fails; no proceed action is available. |
|
|
| Remote page approval is sent locally | Ownership routing and remote page handles select the browser-owning runtime. |
|
|
| App restarts after approval | No persisted grant exists. |
|
|
|
|
## Failure Handling
|
|
|
|
- If a certificate event arrives before guest registration, retain it by
|
|
WebContents ID and flush after registration.
|
|
- If guest registration never completes, expire the pending challenge without
|
|
notifying unrelated pages.
|
|
- If `Proceed` arrives after expiry, navigation, or WebContents replacement,
|
|
return a typed failure and leave strict verification in place.
|
|
- If reload fails for a different reason, clear the certificate challenge and
|
|
present the new load error.
|
|
- If the same rejected challenge fires repeatedly in one navigation, coalesce
|
|
it without rotating the challenge ID or stacking announcements.
|
|
- If the certificate-error listener cannot parse the URL or leaf certificate,
|
|
reject normally and do not offer approval.
|
|
- If the remote connection drops during approval, keep the local UI stable and
|
|
surface the runtime error inline after the latency threshold.
|
|
- If an unexpected controller error occurs, call Electron's callback once with
|
|
`false`; never leave the request suspended.
|
|
- If an accepted main-frame page later opens a new same-endpoint TLS connection,
|
|
apply the exact grant to that HTTPS/WSS subrequest. Reject unmatched
|
|
subrequests silently without replacing the main-frame overlay.
|
|
- If an accepted endpoint presents a different untrusted leaf, reject it without
|
|
replacing the process-lifetime accepted identity or offering a second bypass.
|
|
|
|
## Data and Privacy
|
|
|
|
No migration is required.
|
|
|
|
Add no new product analytics in v1. Do not record URLs, certificate digests,
|
|
challenge IDs, certificate subjects, or approval results. If product analytics
|
|
becomes a requirement later, define coarse actions in the existing typed
|
|
feature-interaction catalog as a separate reviewed change.
|
|
|
|
Existing diagnostics may retain the Chromium numerical error code, as they do
|
|
for other browser load failures. Debug logging must contain at most the error
|
|
name, page ID, owner kind, and normalized loopback host class; omit query
|
|
strings, full URLs, and certificate digests.
|
|
|
|
## Test Plan
|
|
|
|
### Shared URL tests
|
|
|
|
- Explicit HTTP and HTTPS local URLs remain unchanged.
|
|
- Scheme-less `localhost`, IPv4 loopback, and IPv6 loopback still normalize to
|
|
HTTP.
|
|
- Tab-create and address-bar local-address classification stay in parity for
|
|
the full legacy set, including wildcard and bracketed IPv6 inputs.
|
|
- The broader scheme-less classifier is not accidentally substituted for the
|
|
stricter certificate-eligibility predicate.
|
|
- `toHttpsRecoveryUrl` preserves host, an explicit non-default port, path,
|
|
query, and fragment; a URL with no parsed port changes from HTTP's default 80
|
|
to HTTPS's default 443.
|
|
- `Try HTTPS` is unavailable for HTTPS, file URLs, public hosts, wildcard bind
|
|
hosts, private LAN hosts, and invalid URLs.
|
|
- Loopback eligibility covers `localhost`, trailing-dot localhost,
|
|
`.localhost` subdomains with and without a trailing dot, the full valid IPv4
|
|
127/8 range, and bracketed or unbracketed `::1` after URL parsing.
|
|
- Reject malformed IPv4, arbitrary bracketed IPv6, `0.0.0.0`, `::`, and names
|
|
that only contain the word `localhost`.
|
|
|
|
### Main-process trust-controller tests
|
|
|
|
- Unmanaged, retired, and popup WebContents are rejected without pending
|
|
challenges.
|
|
- A managed `-202` loopback failure creates one pending challenge and calls
|
|
`callback(false)`.
|
|
- Every event path calls its callback exactly once; only an accepted grant calls
|
|
`preventDefault()` and `callback(true)`.
|
|
- The digest is SHA-256 over DER leaf bytes and is stable across PEM formatting.
|
|
- An exact accepted grant covers same-endpoint HTTPS assets, WSS, and iframes on
|
|
the same WebContents without allowing those requests to mint a challenge.
|
|
- Wrong page, WebContents, origin, port, certificate digest, error type, and
|
|
challenge ID fail closed.
|
|
- A token is single-use and expires after five minutes.
|
|
- Repeated identical failures in one navigation coalesce; a new navigation
|
|
invalidates the token and rotates the challenge ID.
|
|
- Approving after navigation returns `navigated` and never reloads the old URL.
|
|
- Approval loads the main-owned challenged URL, not the current Chromium error
|
|
URL or a renderer-supplied URL.
|
|
- Certificate rotation fails closed and cannot replace the process-lifetime
|
|
accepted endpoint identity.
|
|
- WebContents destruction removes pending challenges and grants.
|
|
- Bounds evict the oldest pending challenge/grant.
|
|
- Registering a page after an early certificate event flushes the event to the
|
|
correct renderer.
|
|
- Concurrent tabs and session profiles remain isolated.
|
|
|
|
### IPC and runtime tests
|
|
|
|
- Browser certificate IPC rejects untrusted senders and malformed arguments.
|
|
- Renderer-supplied origin or certificate-identity fields are neither accepted
|
|
nor used.
|
|
- Local approval reloads only the currently mapped guest.
|
|
- Remote approval calls the owning environment's RPC with the remote page ID.
|
|
- Local IPC is not called for a remote-owned page.
|
|
- Mobile-scope runtime clients cannot call the approval method.
|
|
- Older runtimes without `browser.certificate-trust.v1` never show an enabled
|
|
proceed action.
|
|
- Runtime snapshots carry live load errors and certificate failure state,
|
|
publish immediately on changes, and do not add certificate challenges to
|
|
persisted session schemas.
|
|
- A snapshot that omits or clears a challenge removes the mirrored proceed
|
|
action rather than leaving a stale one behind.
|
|
- Two runtime environments with identical page IDs cannot approve each other's
|
|
challenge.
|
|
|
|
### Renderer tests
|
|
|
|
- `-202` renders certificate-specific copy and no server-running hint.
|
|
- A live certificate challenge still wins when Chromium's error-page fallback
|
|
produced diagnostic code `-1`.
|
|
- A live eligible challenge renders `Proceed Anyway (Unsafe)`.
|
|
- A restored `-202` load error without a live challenge remains accurate but
|
|
cannot proceed.
|
|
- Other certificate errors render accurate copy without v1 bypass.
|
|
- Local HTTP failures render `Try HTTPS`; HTTPS and non-loopback failures do not.
|
|
- Starting a new navigation clears stale certificate state.
|
|
- Success clears the overlay.
|
|
- Proceed disables immediately, delays the spinner, and preserves its width.
|
|
- Expired/changed responses show the inline recovery message.
|
|
- Keyboard focus and `aria-live` behavior do not regress the address bar.
|
|
- `Copy Address` remains available, interactive content is not blanket-dimmed,
|
|
and all new strings are present in the localization catalog.
|
|
|
|
### Electron integration
|
|
|
|
Run a local HTTPS server with a generated certificate whose SAN includes
|
|
`localhost` but whose authority is not trusted.
|
|
|
|
1. `https://localhost:<port>` fails with `-202` and shows certificate copy.
|
|
2. `Proceed Anyway (Unsafe)` reloads and renders the page.
|
|
3. HTTPS assets and a WSS echo/HMR endpoint on the same host and port load after
|
|
approval; a different-port asset remains blocked.
|
|
4. Reload in the same tab remains allowed without broadening the grant.
|
|
5. A second tab using the same session profile, origin, and certificate remains
|
|
blocked. This is a release-blocking assertion against the pinned Electron
|
|
binary, not a mocked controller test.
|
|
6. Navigating away before clicking makes the old challenge unusable and does
|
|
not pull the tab back to the failed URL.
|
|
7. Replacing the certificate keeps the original tab blocked and offers no
|
|
second bypass until the browser-owning process restarts.
|
|
8. Closing and recreating the browser surface removes the grant.
|
|
9. Restarting Orca removes the grant.
|
|
10. A system-trusted local certificate loads without an interstitial.
|
|
11. A public/non-loopback untrusted origin has no proceed action.
|
|
12. A hostname/SAN mismatch shows certificate copy but no proceed action.
|
|
13. `localhost:<port>` first attempts HTTP and its failure offers `Try HTTPS`.
|
|
|
|
Repeat the ownership-sensitive cases for:
|
|
|
|
- a local desktop webview;
|
|
- an SSH local port forward opened in the desktop browser, using a certificate
|
|
whose SAN matches the browser-facing forwarded hostname; and
|
|
- a headless/offscreen browser page owned by `orca serve` under Linux/Xvfb.
|
|
|
|
## Implementation Plan
|
|
|
|
1. Add the shared scheme-less local classifier, strict certificate-host
|
|
predicate, secure-endpoint canonicalizer, and HTTPS-recovery function with
|
|
focused tests.
|
|
2. Consolidate address-bar and tab-create local classification without changing
|
|
current scheme defaults.
|
|
3. Add BrowserManager ownership/registration/retirement hooks, early
|
|
`did-attach` registration with a `dom-ready` fallback, and offscreen load
|
|
failure observation.
|
|
4. Add shared certificate failure/result types and the main-process trust
|
|
controller, including navigation-sequence invalidation and the app-level
|
|
listener. Its certificate-event handling resolves ownership through the
|
|
BrowserManager hooks from step 3, so those must land first.
|
|
5. Add local IPC, preload APIs, and transient renderer state.
|
|
6. Add the certificate-specific classification and copy as new exported
|
|
functions/branches in the existing `browser-notices.ts` (which already owns
|
|
load-failure copy via `formatLoadFailureDescription` /
|
|
`formatLoadFailureRecoveryHint`), and extract only the overlay JSX into a
|
|
focused `browser-load-failure-overlay.tsx` module instead of growing the
|
|
already grandfathered `BrowserPane.tsx`; do not add or extend a max-lines
|
|
disable.
|
|
7. Add certificate-specific and `Try HTTPS` UI, preserve existing recovery
|
|
actions, and update the localization catalog.
|
|
8. Add the offscreen/runtime capability, live snapshot fields and publication,
|
|
and approval RPC.
|
|
9. Add unit, IPC, renderer, runtime, and real-Electron integration coverage.
|
|
10. Run formatting, typecheck, lint, localization verification, targeted tests,
|
|
and cross-platform manual validation.
|
|
|
|
## Rollout
|
|
|
|
No feature flag is required for accurate error copy or `Try HTTPS` because both
|
|
paths preserve strict certificate verification.
|
|
|
|
The proceed path should ship only after desktop and offscreen ownership tests
|
|
pass. If remote runtime support cannot land in the same release, gate the
|
|
button on `browser.certificate-trust.v1`; local desktop support may ship while
|
|
older/remote owners retain accurate error copy and trusted-CA guidance.
|
|
|
|
Do not close issue #8454 until a packaged build has been validated against a
|
|
real self-signed local HTTPS server. Unit mocks alone do not establish Electron
|
|
certificate-event ordering.
|
|
|
|
## UI Quality Bar
|
|
|
|
- Follow `docs/STYLEGUIDE.md` and the adjacent browser failure overlay.
|
|
- Use only existing semantic tokens, shadcn buttons, and lucide icons.
|
|
- Keep the overlay quiet and monochrome; certificate failure must not introduce
|
|
an amber warning treatment.
|
|
- Copy must distinguish connection, DNS, and certificate failures without
|
|
claiming more than the Chromium error proves.
|
|
- Buttons must remain on one line at supported pane widths or wrap as one
|
|
deliberate group without overlap.
|
|
- Validate light/dark mode, macOS/Windows/Linux font metrics, and 200 ms remote
|
|
latency.
|
|
- Capture review evidence for local HTTP failure, eligible certificate failure,
|
|
ineligible certificate failure, accepted certificate, and the same browser
|
|
chrome after recovery.
|
|
|
|
## Release Gates and Open Risks
|
|
|
|
- The pinned Electron binary must prove that the session request gate blocks a
|
|
second tab, sibling HTTPS subresources/WSS, and a second port even when
|
|
Chromium does not re-emit `certificate-error`. If any isolation assertion
|
|
fails, omit the proceed path or redesign it around a dedicated ephemeral
|
|
partition; do not silently widen the grant.
|
|
- Same-endpoint WSS and fresh HTTPS subresource connections must work after
|
|
approval. A main document that renders while HMR/API traffic remains blocked
|
|
does not fix the local-development use case.
|
|
- A remote/headless certificate failure must reach the client as both accurate
|
|
load-error copy and a live challenge. Logging a rejected offscreen `loadURL`
|
|
is not sufficient.
|
|
- Stale approval must be rejected in the browser-owning process after any new
|
|
main-frame navigation, including when renderer cleanup or a remote snapshot
|
|
is delayed.
|
|
- SSH-forward validation must use a certificate whose SAN matches the
|
|
browser-facing hostname. Authority bypass must not mask a hostname mismatch.
|
|
- The app-level listener and real-certificate tests must pass on macOS, Windows,
|
|
and Linux/Xvfb. Platform trust-store differences do not justify enabling a
|
|
broader error allowlist.
|
|
|
|
## Review Decisions and Follow-ups
|
|
|
|
### From 2026-07-12 review
|
|
|
|
- The integrated browser is part of the development workflow (including
|
|
automation, annotations, and remote ownership), so `Open Externally` and trusted
|
|
local-certificate guidance remain safer alternatives but do not replace an
|
|
explicit in-browser decision.
|
|
- V1 intentionally limits approval to `ERR_CERT_AUTHORITY_INVALID (-202)`.
|
|
Hostname/SAN, date, revoked, and malformed-certificate failures remain blocked;
|
|
broadening that set requires a separate security review and real-certificate
|
|
tests.
|
|
- Raw error details remain available to existing diagnostics rather than adding a
|
|
new disclosure affordance to the quiet recovery overlay.
|