Security

View on GitHub

What Ovenlight protects, how, and where its limits are.

Threat model

Ovenlight is built so that:

  • A guest gets one app, not your computer. A guest's phone joins your tailnet, but can reach only the nodes of the apps it was invited to, on port 443, and sees none of your devices unless one of your own rules lets a device reach guest devices.
  • Nobody else in your tailnet gets in. The connector admits the owner and guests who claimed an invite to that app, and answers 403 to everyone else, including people you share a node with through Tailscale.
  • Websites can't act through an admitted device. Identity comes from the device, not a cookie, so any page open on an admitted device could otherwise act as that device. The connector refuses such requests (see The proxy), except top-level link navigations, which is why an app's GETs must change nothing.
  • The app can trust who is calling, as long as it listens only on 127.0.0.1, reads each request body in full, and guards requests that reach it straight: it checks their Host, sends no CORS headers, and gives a request without identity headers the owner's rights only from its own pages (app-contract.md).
  • The connector changes your tailnet policy only on your yes. It edits the policy only when you make an app shareable, unpublish a shareable one or run setup-sharing, and only after it shows the exact change and you type yes in a terminal (The policy change).
  • Only a signed release updates the connector. It installs only a release that an index signed with one of its built-in release keys names and checksums, so the download server can hold updates back but can't choose what runs. Whoever can sign a release, or get code onto master and through the Mac that builds it, can still run code on your computer (The release keys). ovenlight update off opts out (Update has what that means).

Out of scope: anything running as you on the computer, a coding agent with a shell included, can do what you can. On Windows, that includes administrator rights (On the computer). A tailnet admin can change the policy, and an app that listens on a LAN address can be reached around the connector. The computer's other accounts can reach an app on 127.0.0.1 directly too, so they must belong to people you trust. And every device signed in to Tailscale as you, such as a server you keep on or a NAS, reaches your apps as you, along with everything on it: its other accounts and its containers included. Their other accounts must belong to people you trust too, and run only containers you trust on those devices.

The connector's nodes send logs to Tailscale and may open a port mapping on your router: What goes to Tailscale has the details.

Two locks

Every guest request passes two independent checks:

  1. The tailnet policy. Each shareable app has its own pair of tags. A guest device carries tag:ovenlight-guest-<slug> for each app it holds, and the policy lets that tag reach only tag:ovenlight-app-<slug> on port 443. So a guest's phone reaches only the apps it was invited to, whatever the connector does.
  2. The connector's WhoIs check. For every request, the connector asks the tailnet who the calling device is. The owner is an untagged device of the owner's login: the one recorded by publish --shareable, or else the user who owns the app's node. A guest is a device tagged as a guest of this app, with an active claim on it. Anything else, unknown included, fails closed.

The admin API on port 8443 is owner only by the same WhoIs check, and the policy closes it to guests as well.

The policy change

ovenlight publish --shareable edits your tailnet policy file. It first names the local port guests will reach. While no owner is recorded, it then asks one line:

Owner: you@example.com, guests see you as "You". Type yes to record:

The owner is the login behind your app nodes, taken from the running connector; --owner and --owner-label set it instead, and --owner is needed when the connector isn't running. The owner is recorded only once the policy change is applied.

It then reads your policy file and devices, prints the exact diff and a safety report, and changes nothing until you type yes. It asks in a terminal only; there is no --yes. The safety report names every rule that lets one of your devices reach guest devices. After your yes it reads the device list again, and changes nothing if the plan would now be different.

Only one connector per tailnet can share apps. Each keeps its own guest records, while tags and a guest's phone reach across the tailnet, so two would mix up each other's guests. publish --shareable refuses while a device in the tailnet carries an app tag and isn't one of this connector's own app nodes, which it knows by the node ID in its saved state. That includes another computer's node for an app with the same slug, and a node this computer left behind when its saved state was lost. The refusal names the device and says what to do: delete it in the admin console (Machines) if nothing runs it any more, or make apps shareable on the connector it belongs to. The list of unclaimed phones shows only guest devices of this connector's apps.

What it adds

  • tagOwners for tag:ovenlight-app-<slug> (the app's node) and tag:ovenlight-guest-<slug> (guests invited to it), owned by autogroup:admin.
  • One rule letting tag:ovenlight-guest-<slug> reach only tag:ovenlight-app-<slug> on 443.
  • The app tag in your own rule letting you reach the app nodes on 443 and 8443 (a tagged node no longer counts as your own device). The first shareable app adds that rule.

Ovenlight writes new rules only to grants, next to your acls if your file uses those (Tailscale applies both). It never deletes a guest rule you added a key to (such as srcPosture).

Tag names are compared without regard to case, as Tailscale reads them in lowercase, so a tag spelled in other case counts as the same tag.

What it narrows

On a tailnet that still allows everything ("*" to "*"), it narrows that rule to autogroup:member reaching autogroup:member, so guests neither inherit it nor see your devices (a device you can reach shows up as a peer to it). It narrows no other rule on its own.

What it refuses

It stops, and prints the edit to make by hand, when:

  • narrowing the allow-all rule would cut off something you have: tagged devices, rules with tag sources, subnet routes or exit nodes;
  • any other rule has a "*" source;
  • a rule would let guest devices reach more than their apps;
  • a source can't be classified (IP sets, unknown autogroups);
  • nodeAttrs could give guest devices more than client-side settings (NextDNS, App Connector domains and the like);
  • autoApprovers could approve a guest device, or App Connectors could run on one;
  • a guest device could take on other tags;
  • anyone but admins may apply an Ovenlight tag;
  • a key in the file isn't plain ASCII, since a JSON reader may take it for another key.

A destination it can't classify counts as reaching guests in the report.

How it writes

It edits the file in place, so comments and formatting stay, and running it again changes nothing. It validates the new policy with Tailscale, writes with If-Match (a concurrent edit makes it stop), keeps the old policy as a backup in the state directory (policy-backups/), reads the policy back, checks that your app nodes still see your devices, and rolls back to the backup if any of that fails. Without the connector running, only the read-back is checked. A write whose answer is lost counts as applied only if the policy reads back as written. The rollback is conditional on its own write, so if someone edited the policy in between, it stops and leaves their edit alone.

Later changes

  • setup-sharing --rollback restores the last backup, unless the backup would let guest devices reach more than their apps while a guest device or an open invite exists.
  • setup-sharing --owner <login> changes the recorded owner. The new owner gets a rule of their own letting them reach the app nodes, and the earlier owner's rule stays as it is, with a note in the plan; remove it in the admin console when they no longer need it. The connector's identity check turns away anyone but the recorded owner and guests, whatever such a rule allows.
  • setup-sharing --remove <slug> takes an unshared app's tags and rule out of the policy. Unpublishing a shareable app offers (after yes) to do the same, unless the connector didn't confirm the unpublish in time; it then says to run --remove later. That policy change is refused while a rule Ovenlight doesn't manage, such as an earlier owner's or a guest rule you added a key to, still uses the app's tags.
  • Whenever the connector loads its config or syncs, it removes the guests of any app the config no longer lists (giving "the app was unpublished" as the reason) and cancels its open invites. An empty config removes them all, while a missing config.json serves no apps and keeps every guest until a config exists. So an app unpublished while the connector was down loses its guests when the connector next starts. unpublish says whether its guests are gone already or go later.
  • Until the connector has removed an unpublished app's guests and open invites, publishing the same slug again is refused, from the terminal and the MCP server alike, and publish --shareable refuses before it asks anything: start the connector (or wait for it) and run the command again.
  • Unpublishing a shareable app also deletes its node from the tailnet with the stored API credential, and its node state on the computer, even when the connector didn't confirm the unpublish. Without a working credential the node stays, offline, and unpublish says to delete it in the admin console (Machines): until then, no other app can be made shareable.

Tagging the app's node

After the policy change, publish --shareable turns the app's node into a tag:ovenlight-app-<slug> node: it mints a short-lived tagged key, deletes the old node from the tailnet (freeing its name) and logs a new one in under the same name. A new app published with --shareable starts tagged. There is no command to undo it.

Until the node carries the tag, share refuses the app; if tagging failed, run publish --slug <slug> --shareable again. The same goes for a shareable app whose node loses its login: status, doctor and the log say to run it, rather than showing a login link, which would sign the node in untagged.

The API credential

ovenlight auth set reads the secret from standard input, never the command line. It takes a Tailscale API access token, or an OAuth client (--oauth-client-id <id>, then paste its secret) with the scopes policy_file, devices:core and auth_keys. A client that also has feature_settings:read, which Ovenlight no longer uses, works the same. An OAuth client can only apply the tags it was given, and every shareable app adds two, so a token is simpler.

The credential is checked against the API, and refused if its tailnet has none of the running connector's shareable apps (with none shareable, none of its apps), then stored in credentials.json beside the app list, readable only by you (On the computer); the connector refuses to use one anyone else can read. auth status shows its type (with the client ID for an OAuth client), a fingerprint, the tailnet and the API URL, never the secret; auth status --check also tries it against the API. auth remove deletes it.

With an API access token whose user is the owner (not an OAuth client), a new app that isn't shareable logs in as you with a single-use, 10-minute key instead of the browser (the first app on a fresh install still signs in through the browser). Its key expiry is then turned off; a failed attempt is logged and tried again every hour until it works, so a new credential takes effect without a restart. A node of yours that signed in through the browser gets the same once it runs, while a credential is stored. doctor warns when a node's key expires within 30 days.

Invites

  • share mints a single-use key tagged tag:ovenlight-guest-<slug> that expires in 7 days. The key is preauthorized, whatever the tailnet's device approval setting, so a guest's phone never waits for you.
  • A review invite, for an app store's review (share --review, terminal only; development.md), has a reusable key instead, valid for 90 days, and up to 10 devices can claim it. Its key is deleted once the tenth has, so a leaked review link admits up to 10 devices to that app until it expires or you cancel it. While the key works, any number of devices can join your tailnet with it, but the connector refuses one that hasn't claimed, and guests lists it for you to remove. Removing one of its devices doesn't keep that phone out while places remain: it can join again and claim. To end it, revoke the person or the invite's ID.
  • The connector records the invite with the key's SHA-256, never the key.
  • The link carries the key in its fragment, so it never reaches the website that hosts the join page (protocol.md).
  • A device belongs to one person. A device already admitted to any of your apps can only claim that person's invites; any other invite stays unused. The exception is an invite for someone who has never had a device, opened on a phone that holds another of your apps: the invite becomes the phone's person's, and the phone gets the app. Ovenlight claims it through the other app's node when the phone can't reach this one yet. That covers inviting someone you already share with under a new name, and two people sharing one phone. Only that invite moves, and each key still opens only its own app, so holding one invite never grants another's app. A phone that already has the app gains nothing, so the invite stays open for whoever else it was meant for. Review invites never move.
  • Making an invite with share takes a terminal. The MCP server can't share at all.
  • share --cancel <id> withdraws an unused invite, or a review invite with places left (devices that joined with it keep the app), and deletes its key. If the delete fails (an expired API token, say), the connector refuses the invite at once, retries the delete on each sync, and --cancel says the key still works until then.
  • When the person already has a phone in your tailnet for another of your apps (--existing or --person, or chosen in Ovenlight), Ovenlight reuses it rather than joining again: share adds the new app's guest tag to that device, as does a claim from a phone that joins after the invite was made. Canceling the invite, or letting it expire, takes the tag back.

Ovenlight never asks you to approve a guest's phone. Guests join under random device names, so you couldn't tell whose phone was waiting, and approving it would be a rubber stamp. What protects you is the invite: it works once, for 7 days (a review invite, on up to 10 devices for 90 days), and only for one app, since the policy lets its guest tag reach that app alone. You can remove anyone at any time. If a stranger uses a forwarded link first, a single-use invite doesn't work for the guest you invited, and your guest list shows who has it, so you can remove them.

Revocation

ovenlight revoke stops serving the guest at once, open requests and streams included. It then takes this app's tag off the device if it still holds another of your apps, and otherwise deletes it from the tailnet, even when an open invite to that person for another app lists it. That invite still works: the phone joins again to claim it.

Every 3 minutes, and on guests --sync, the connector compares its guests with the tailnet's devices:

  • a device deleted in the admin console removes that guest;
  • the guests of an app no longer published are removed, and its open invites canceled;
  • unused invites expire;
  • a guest device whose tags don't match the apps it holds is put right.

A guest device that never claimed an invite is listed as unclaimed (unclaimed in GET /v1/guests, and in guests --json, which prints the same view). The connector refuses it; remove it in the admin console if you don't expect it. Keys of used, canceled and expired invites are deleted, retried until the tailnet confirms it. When a sync fails, status and guests say why, and suggest ovenlight auth set when the API turned the credential down (401). status --json has the reason as sharing.syncError, and sharing.stateError when the guest records can't be read.

A guest who leaves in Ovenlight stays in your guest list until you remove them (development.md).

The proxy

  • HTTPS on port 443 with the node's real ts.net certificate, to 127.0.0.1:<port> only.
  • Host. The request must carry the node's own MagicDNS name; any other Host gets 421. This blocks DNS rebinding.
  • Cross-site requests get 403, except top-level link navigations (app-contract.md has the exact rule).
  • Headers. Every incoming Tailscale-*, Ovenlight-*, X-Ovenlight-*, X-Forwarded-*, X-Real-IP and Forwarded header is dropped, underscore spellings too, because some frameworks map both to the same variable. The connector then sets identity and forwarding headers itself.
  • Upgrades. Only WebSocket upgrades pass; any other gets 400, since a protocol such as h2c would carry requests past these checks.
  • Bodies. A request with a body goes to the app with Connection: close, so an app that answers without reading it can't take it for a request of its own, as long as the app honors that header (app-contract.md).
  • /__ovenlight/ never reaches the app.
  • Admin API. Owner only, and requests that change something need X-Ovenlight-Request: 1 (protocol.md).

Feedback

Only Ovenlight itself posts feedback: the connector refuses a post with Origin or Sec-Fetch-Site, which a web page's requests carry, so an app's own page can't post in its viewer's name. Owner and guests can post to /__ovenlight/feedback:

  • a note of up to 4000 characters, with control characters other than newlines and tabs stripped;
  • a whole PNG screenshot up to 5 MB, at most 8192 pixels a side and as many pixels as 4096 by 4096;
  • 30 an hour and one at a time per person. An upload that fails or is rejected still counts toward the 30; one refused because the person's last upload is still running doesn't.

When the inbox is full (1000 items or 500 MB), a new item replaces its sender's oldest, or the oldest overall if the sender has none there. ovenlight feedback lists it with the screenshot paths, and status shows the count and who sent the latest three.

On the computer

The connector keeps everything as you, in the places connector.md's table lists for each system: the app list with the API credential beside it, and the state directory, with each node's keys and certificates, the guest and invite records, the feedback inbox, the policy backups and the control socket. Only you can read them:

  • macOS and Linux: the directories are mode 700 and the files 600, and the connector runs with umask 077.
  • Windows: the state folder is in %LOCALAPPDATA%, which only you, SYSTEM and the Administrators group can open, and what the connector writes there inherits that. A state folder the connector makes itself grants only you, and each file it saves atomically (the app list, the credential, the guest records) gets its own. Windows ignores Unix modes, so the check before using the credential reads its access list instead, and refuses one that grants reading to anyone but you, SYSTEM or the Administrators group, the counterparts of root.
  • The control socket, ovenlight.sock in the state directory, takes the commands publish, share, revoke and the rest. It is a Unix socket on every system, Windows included, and only you can use it. On macOS and Linux its mode is 600. Windows can't be relied on to check a socket file's access list when a client connects (in testing it didn't), so there the connector asks Windows which process is calling, and refuses any that doesn't run as you.

On Windows the connector runs as a scheduled task, as you, with logon type S4U: it runs with nobody signed in, Windows stores no password for it, and it gets no network credentials of yours (it needs none). Registering such a task takes an administrator, so install.ps1 must run elevated.

An S4U task gets the account's full administrator rights whatever its run level says, so the connector has them. Your apps don't. The connector starts them with a standard token, as a PowerShell window not run as administrator has: the administrator groups in it can only deny access, and it has medium integrity and no administrator privileges. If Windows won't make that token, the connector starts no app. This keeps an app from using administrator rights by mistake, not one written to take them: anything running as you can replace the connector's binary in %LOCALAPPDATA% without administrator rights, and the task's next start runs the replacement with them.

On a work or school account (Microsoft Entra ID) or a domain account, the task runs in your session from sign-in instead, so the connector runs only while you're signed in. It has the same rights there, and your sign-in's network credentials, which your apps can use too.

Logging

  • The connector's log is readable only by you. At 20 MB it moves to ovenlight.log.1, replacing the one before, so there are two files at most. A crash lands in ovenlight.stderr.log beside it on macOS and Windows, and in the user journal on Linux. The table in connector.md has where the log is.
  • It keeps each request's path, cut at 200 bytes, and never its query, which can carry OAuth codes and tokens.
  • With the guest records, it keeps the day each guest's device last opened each app: a request from the app's own pages in Ovenlight, on screen or kept open in the background, which send Fetch Metadata headers. Ovenlight's own checks and icon fetches send none and don't count. ovenlight guests and People & Sharing in Ovenlight show it to you.
  • Each node's backend log is nodes/<slug>/backend.log in the state directory. At 10 MB it moves to backend.log.1, replacing the one before.
  • An app the connector runs (publish --run) writes its output to <slug>.log, readable only by you, in ~/Library/Logs/ovenlight/ on macOS and in the state directory's logs/ elsewhere. It moves to <slug>.log.1 at 5 MB. What it holds is up to the app, and often includes what requests sent it, such as their paths. On macOS and Linux the app runs in your login shell's environment, so what your shell profile exports, API keys included, reaches it, and lands in this log if the app prints it. To read that environment the connector runs your login shell, and so your profile, each time it starts an app's command. When a command restarts on its own, it runs it only while no read has worked, at most once every five minutes. Each read ends whatever the profile left running in the shell's process group once it is done, and a profile that takes over 10 seconds is ended then. publish runs it too, to check a new command's programs are found. On Windows there is no login shell, and the app gets the connector's own environment, with your account's environment variables. Beside the log, <slug>.pid names the app's running process group, so a connector restarted after a crash can end what the last one left running.
  • The apps' commands are kept in commands.json beside the config, readable only by you. They run as the connector's children. On macOS they have whatever access to a folder macOS protects (Desktop, Documents, iCloud Drive) you allowed the connector, wherever they run.
  • The logs name guests, devices and your login. uninstall.sh --purge (or uninstall.ps1 --purge) deletes them, with node state, the config, the stored API credential, guest records, the feedback inbox and the policy backups.

What goes to Tailscale

  • The connector's nodes name the app as ovenlight in the device information they send, and otherwise keep tsnet's defaults. They send backend logs to Tailscale's log service, as the Tailscale app does. tsnet may open a NAT-PMP or UPnP port mapping on your router for direct connections, and its peer and UDP listeners bind to all interfaces.
  • Ovenlight on the phone doesn't upload logs: its embedded Tailscale library is built with log upload turned off, for the owner's node and every guest's, on iPhone (development.md) and on Android (development.md).
  • ovenlight run clears the environment variables that would set tsnet's login, control server or log target, unless OVENLIGHT_DEV=1. The apps it runs never get them from your login shell.
  • App addresses are publicly listed. Each app node's HTTPS certificate is recorded in public Certificate Transparency logs, so anyone can look up its <slug>.<tailnet>.ts.net name. Every invite link carries that name too. Choose app names and slugs you don't mind people seeing.

Updates

The installed connector updates itself (connector.md has when, and how to turn it off). Only a release build started by its service (run --service), from the connector's bin folder, does. A build from source, a pre-release and an ovenlight run in a terminal never update themselves.

What it trusts

One thing: a signature by one of the release keys built into the binary (connector/release_keys). Not TLS, and not the download server. Whoever took over downloads.ovenlight.app could hold updates and pulls back, but could install only a release we signed as the latest, and couldn't skip the two-day wait or bring a pulled release back to a computer that saw the pull. An index doesn't expire, so while the server holds one back, status and doctor still show recent checks. The verified steps for installing by hand check the same signed index, so they too take only a release once signed as the latest, though not that it still is: an older index, with its release, passes them too. So status shows the serial of the index your connector holds, doctor names it when it sends you to them, and the steps take no index with a lower one. They trust this repository's allowed_signers, so a change pushed to master changes what they accept: the connector itself trusts only the keys built into it.

The one-line installers, install.sh and install.ps1, take those steps for you, with the keys in allowed_signers written into them (a test keeps them the same). They compare no serial, so on a computer the connector already runs on, they also take an older index and its release. The verified steps, with the serial status shows, rule that out. Where ssh-keygen can't read the release keys, they say so and check the download against an index whose signature went unchecked. There install.sh trusts the HTTPS download, as the steps by hand do without the check. install.ps1 trusts our build computer's code signature in place of the release keys, so it takes any release from 1.1.0 on that Snowy Ghost LLC signed, even a build never released. Releases signed within the same few days share a certificate, so it may also take one's binary with another's scripts. It checks that the release's ovenlight.exe, install.ps1 and uninstall.ps1 carry valid code signatures under one certificate (one signing run leaves them that way) and that the certificate carries the EKU naming our Microsoft Artifact Signing profile. It also checks that the release holds nothing else but its notices, and that the binary names 1.1.0 or later, the first release that updates itself. And a script you pipe into a shell does whatever the site serving it says: whoever took over ovenlight.app could change it. To check before you run anything, save a copy, read it and run that copy (connector.md shows how), or use the verified steps.

Once a day the connector fetches the signed index, https://downloads.ovenlight.app/connector/latest.json, and its signature, latest.json.sig. The index names the latest release, the oldest without a known security problem (secure), the oldest that may update by binary alone (reinstallBelow), when it was signed (serial), and the SHA-256 of each of the latest release's archives. The connector accepts it only when it is signed in the namespace ovenlight-latest by a built-in key, and it is either the index the connector already holds or one with a higher serial that lowers neither secure nor reinstallBelow. allowed_signers has the command to check it yourself.

It installs only the release the index names, never an older one. That release is due two days after this computer first saw it named (or after the index was signed, if later), or at once when the running release is below secure. Below reinstallBelow it installs nothing, and ovenlight status and ovenlight doctor say to install that release with its install script, since an update replaces only the binary: never the install scripts, the service, the config or the state.

How it installs

  1. Right after a check that works, it downloads this system's archive from connector/<version>/, within 30 minutes and 128 MB, and opens it only once its SHA-256 matches the index.
  2. It takes out one file, ovenlight-connector-<version>/ovenlight (ovenlight.exe on Windows), by its exact name, and nothing else.
  3. When the update's moment comes, it fetches the index again, and goes on only if it still names that release. So a computer that gets the pull at any point in the two days doesn't install the pulled release.
  4. It runs the new binary's version and requires the version the index names.
  5. It records a trial, keeps the running binary as ovenlight.prev (ovenlight.prev.exe on Windows), puts the new one in its place and exits. The service starts the new one.
  6. The new release has 10 minutes of time awake to serve every app that was serving before, with its node logged in, to verify the signed index that brought it with its own built-in keys, and to have its own updater running. If it crashes, or misses any of these in that time, the previous binary goes back and the update is tried again at the start of the next night, busy or not. One that hangs so that its trial can't end goes back after 15 minutes awake. A restart starts the 10 minutes again, though on Windows some stops count as a crash (connector.md).

A failure before the swap leaves the running connector as it was. ovenlight status shows a failed update of the release it installs next, and doctor warns once one release has failed three times in a row.

ovenlight update now --index <file> installs the release a canary index names, at once. That is how we try a release on our own computer before publishing it (development.md). A canary index is signed in its own namespace, ovenlight-canary, by a built-in key, and can't lower secure or reinstallBelow either. The connector never accepts one from latest.json, takes one only over its control socket, and applies it once without keeping it as the index it holds.

The release keys

Two keys sign releases: one in a Mac's Secure Enclave, which signs only with Touch ID, and a backup printed on paper, kept in two places and off every computer until it signs the release that replaces it. Their public halves are built into the connector and listed in allowed_signers, and the connector trusts no other key: not our Apple Developer ID, and nothing fetched at update time.

A release is built on one Mac from a commit on this repository's master branch, and signed on another. Touch ID approves a signature but doesn't show what is signed, so the signing script prints the commit, the index and the checksums first. It signs only a commit whose connector/release_keys lists exactly the keys that Mac keeps plus the key signing it, so the keys a release trusts never come from the commit alone. It keeps its own record of what it signed and which index went live, and checks the live index against it before every signature, so the files it shares with the Mac that builds can't change what it signs. Even so, a signature vouches for what is on master and for the Mac that built it, and no more. Whoever can sign a release, or get code onto master and through that Mac, can run code on your computer within a day (an index that raises secure skips the two-day wait), as you, beside your Tailscale API credential, and on Windows with administrator rights. ovenlight update off opts out.

When one key is lost, the next release, signed with the other, replaces it. The signing script then waits six weeks, and two weeks after the last change to the index, before signing with only the new key. A computer that stays off, or has updates off, for four weeks after that release comes out can't verify the releases signed with only the new key: its checks fail, doctor warns after seven days, and it needs one install by hand. If both keys were lost, every computer would be in that position once we publish a release signed with a new key.

The files it keeps

In the state directory (connector.md's table has it for each system):

  • update.json: the index accepted last, the minute of the daily check, when this computer first saw its latest release named, the last check, the last update and the last failure. One that can't be read is set aside as update.json.bad, and the updater starts afresh.
  • updates-off: there while updates are off.
  • update-trial.json: an update's trial, while the new release proves itself.

In its bin folder, beside the connector's binary:

  • ovenlight.download: an archive while it downloads, deleted once the binary is out.
  • ovenlight.next (ovenlight.next.exe on Windows): the new binary, waiting for its moment.
  • ovenlight.prev (ovenlight.prev.exe): the binary before the last update, for a rollback.
  • On Windows, ovenlight.old-<number>.exe: older binaries moved aside, deleted at the next update or install.

Only you can read them, as with the rest of the state directory. The install scripts remove a trial and ovenlight.prev before they install, so the connector they install is never rolled back. The uninstall scripts remove all but update.json, update.json.bad and updates-off, which --purge deletes too.

What it sends

Only the installed service of a release checks: a build from source never contacts downloads.ovenlight.app. The daily check is two GETs with no cookies, query or identifiers. It comes at the same minute each day, picked at random when the updater first runs. A failed check is tried again within the hour, and a new release, or one a failed update went back to, checks as soon as it starts. The check runs with updates off too, so the connector still knows when its release has a security problem. An update fetches the archive, whose name and folder show this computer's system (on Linux and Windows, its processor type too) and the version it installs. It fetches the index again just before the download, unless it has just checked, and once more just before it installs. Like any request, each shows downloads.ovenlight.app the computer's IP address. Nothing else in the connector contacts ovenlight.app or its subdomains, and ovenlight doctor fetches nothing.

The MCP server

ovenlight mcp serves guide, status, doctor, check_app, publish, unpublish, feedback_list, logs and restart_app on stdio for coding agents. It can't run commands, share an app or grant anyone access. A command runs as you, with your login shell's environment, so one set through MCP would hand a client with no shell of its own, such as a desktop chat app, everything you can do, sharing included. So publish keeps the command the connector runs for an app and can't set one: you set it in a terminal, ovenlight publish --slug <slug> --run '<command>', or an agent does through its shell, where you approve the command. Every tool refuses an argument it doesn't take, such as a run or command passed to publish, rather than ignoring it. Invites are made in a terminal or in Ovenlight. Unpublishing a shareable app, moving it to another port, or changing its command or folder needs a terminal too, since what listens on its port is what guests reach. These terminal-only steps are what keep the MCP server from sharing. An agent with a shell can do what you can, as the threat model says. restart_app works for a shareable app too, since it restarts the command you set and changes neither it nor the port. When the connector doesn't confirm an unpublish, the tool still succeeds, since the app is out of the config, and adds a note that the connector stops serving it once it confirms, or when it next starts. check_app (like check, doctor and status) asks the app with its tailnet name as Host and your identity headers, as the connector does for you, or with none while your login isn't known. doctor and status ask only GET /. restart and restart_app, once the app answers, also send the files and CORS requests below, to 127.0.0.1 without identity headers. check_app and check ask only GET and OPTIONS, to /, the pages, stylesheets, web manifest and icon the app itself names, a few fixed paths (/api/ovenlight-check, /ovenlight-check-missing, /console), one random path under /ovenlight-check-, which tells a route that answers every path from a served file, and the app's own files, at most 40 requests (and one more each for the files and the debug pages when the app refuses its tailnet name, which they then ask without): the files at the top of the folder the connector runs the app in and the database files (such as *.db and *.sqlite) a folder down, by their paths there under /, under the folders of the assets the home page links, and under a few common prefixes such as /static/. To tell a file served from a page, they read each file's first 16 bytes, and count a shorter file only when the answer is all of it; a file of blank space is left out. They read nothing in a folder that holds more than an app's, such as the home folder, or one macOS protects, and for an app without a folder they ask only /.env and /package.json. The CORS and /console requests carry no identity headers, going to 127.0.0.1 as a page in this computer's browser would. They read / a second time, and the web manifest, from 127.0.0.1 without identity headers too, as the connector reads them for the app's icon and color. The agent chooses none of them. What guests wrote in feedback, and every feedback item's page address, which the page chose, reach the agent fenced as untrusted data, over MCP and in the CLI alike. The MCP logs tool and ovenlight logs fence the output of the apps the connector runs too, since it holds what requests sent the app, which a guest, or any website open in this computer's browser, can choose. A screenshot can't be fenced, so the agent is told that it is an image its sender chose, and any text in it is data.

On the phone

  • The whole app locks behind the phone's own unlock (Face ID or the passcode on iPhone, a fingerprint, face or the screen lock on Android) once it keeps anything: an app, an invite or a node. It locks again after a set time in the background (a minute by default), or never once it's turned off in Settings, which asks for the unlock first. The unlock is asked for again before creating an invite and removing a guest. On a phone with no passcode or screen lock there is nothing to ask for: Ovenlight stays open, says so in Settings, and those confirmations pass.
  • Add App takes only an address on the owner's own tailnet, and none before the owner has signed in, so a new app comes from the owner's own computers or from an invite. An app of your own goes through the owner's node only when its host is on the owner's own tailnet (its MagicDNS suffix, remembered from the node's status). Any other address, such as an app from a tailnet the owner was signed in to before, is refused: it shows Can't Reach and sends nothing, not even a probe, and Ovenlight never looks its name up in public DNS or loads it outside the tailnet. Only the iPhone's debug builds load an app at localhost directly, for screenshots of a local demo. The owner's node only ever carries names on that known tailnet. Until the tailnet is known, a *.ts.net app is routed to the owner's node and waits behind Sign In, loading nothing. The route is checked before every probe and load, and a view whose route changed is made again through the right node, or refused. A change of the remembered tailnet closes every view whose route it changed. Sign Out and Stop Using My Own Computers forget it, close every view that went through the owner's node, kept in the background or not, and remove the apps found on the owner's computers with their data. A Sign Out cut short by a quit finishes at the next launch. A closed view can't connect again, and neither can a refused app's (on iPhone, its data store is set to a proxy that refuses every connection, and on Android, its route is cleared). The closed view's app loads again through its route, at once if it's on screen. A shared app always goes through its owner's guest node.
  • Through Ovenlight's proxy, pages reach only a node's peers and the internet. A name in a tailnet resolves only among that node's peers, never in public DNS. Any other address must be on the internet: the proxy refuses the phone itself, its local networks, the network's public address, other tailnets and the ranges that aren't globally reachable, NAT64 and IPv4-mapped forms included (on iPhone and on Android 10, NAT64 under the well-known prefix only, since neither gives Ovenlight the network's own), unless the address is one of the node's peers. So no page reaches the phone's own network, or the router in front of it, through Ovenlight. On iPhone, WebKit sends a load to one of the phone's own addresses straight out, past the proxy. Content rules block loopback, private and link-local IP literals (outside Tailscale's fd7a:115c:a1e0::/48), but not the phone's other addresses, so a page can still reach the phone itself at one of those, such as a public address, a carrier's in 100.64.0.0/10 or another VPN's.
  • Ovenlight takes WebRTC away from pages. Its traffic wouldn't go through Ovenlight's proxy: through a TURN relay a page could reach any address it names, the phone's own network included. A script at the start of every document, in every frame, removes it. On iPhone, WebKit runs it in a frame's first about:blank document too, as the frame is made. On Android it doesn't run there: it strips a new about:blank frame as it's made, but a page that sets out to can still find WebRTC in a frame's first, empty document, before the frame's own page loads.
  • From iOS 27.1, pages on iPhone can use WebTransport. The content rules don't cover it, but the data store's SOCKS5 proxy does, and every release route sets that proxy (its node's, or one that refuses every connection). A route without one would need WebTransport removed, as WebRTC is.
  • While Ovenlight is locked, no page gets the microphone or camera, and an app that an ovenlight://open link asks for waits for the unlock. Locking also stops any microphone or camera stream already running in an open app, on screen or kept in the background. Nor does a page kept in the background get them: closing an app to the launcher stops its streams, and it can't start one until it's on screen again.
  • An app's downloads come only from its own host (or a blob or data URL its pages made), none while Ovenlight is locked, and leave Ovenlight only through the share sheet (on Android, Ovenlight's Save, Share and Open). The files stay in Ovenlight's temporary folder until the next launch, since a share may still be sending. Files not yet offered go at once with their app or its data.
  • The owner's admin calls go through the owner's own node to each connector's port 8443. The phone holds no API credentials. It asks only the owner's own devices: peers of the owner's own user, or tagged tag:ovenlight-app-*, on the owner's own tailnet (not devices shared in), and trusts a device's /v1/apps answer only for hosts that are themselves such peers.
  • Each network the phone joins gets its own node, state and random device name (ovenlight- and six random characters), so no name or key ties its guest devices together. Two owners comparing notes could still match them: each sees when Ovenlight is open on the phone (its guest node comes online and checks in with their apps), and its public IP address and operating system (on iPhone, its iOS version too).
  • Ovenlight builds Tailscale without its peer API server. Through it, a device of the same user, which an invite's sender can arrange, could ask the phone to look up names on the phone's own networks: its router, Private DNS or a company's DNS.
  • Node state and the membership list are excluded from backups, so node keys never come back on another phone. On Android, nothing of Ovenlight's is backed up or moved to a new phone.

On Android

  • One proxy. Android has one proxy setting for every web view in an app, so the embedded Tailscale library runs one HTTP CONNECT proxy on 127.0.0.1, and every request of every app's pages, and of Ovenlight itself, goes through it, with no fallback to a direct connection. Other apps on the phone can reach its port, so it answers only a CONNECT, and only with the secret Ovenlight makes at each launch. A page's web view answers the proxy's challenge with its app's own route key, which names the node that carries it, and a key with no route is refused. The page itself never sees the secret. If the proxy ever stops, Ovenlight stops too, so no other app can take the port.
  • Each app apart. Each app has its own WebView profile, with its own cookies, storage and cache. Data that ends (Leave, Remove, a revoke) is wiped at once, and its profile deleted by the next launch. Pages run in WebView's sandboxed renderer process, apart from Ovenlight's own, and a renderer that dies doesn't take Ovenlight with it: its pages are made again. WebView hands Ovenlight every message a page writes to its console, whole, so a page can run Ovenlight out of memory: a denial of service, nothing more. Safe Browsing is off in app views. Their pages come only from apps on tailnets Ovenlight knows, but a page can still embed another site's frames, scripts and images, which load unchecked. A page that navigates to another site opens it in the browser, which keeps its own Safe Browsing.
  • The lock is a window of its own above everything Ovenlight shows, pickers and menus a page opened included, and under it no page is in the window at all. Its clock runs from the moment Ovenlight's screen goes, even for a file picker or Tailscale's sign-in tab, unlike on iPhone, since Android can't tell someone still in a picker from someone who left. A file picked meanwhile reaches the page only after the unlock. Recents shows no picture of Ovenlight.
  • Microphone and camera. WebView can't stop a stream it handed out. On lock and when an app closes to the launcher, Ovenlight's page script stops the tracks it saw, and if the phone still records or holds a camera two seconds later, Ovenlight closes any page granted either since it last loaded.
  • Downloads. Android doesn't say which frame started a download, so one from an http or https address is checked by that address alone: a frame from another site can start a download from the app's own host. Ovenlight fetches it again through the proxy with the app's cookies, following no redirect, and it ends up in that app's own Save, Share and Open sheet. Open hands another app only PDFs, images, audio, video and plain text, never HTML, SVG, XML or an app package.
  • Home screen shortcuts. Add to Home screen, in an app's touch-and-hold menu, pins a shortcut that carries only the app's ID and opens it through the lock. Removing the app disables its shortcut, which keeps its name and icon on the home screen until the person removes it, since Android gives apps no way to unpin one. Ovenlight publishes no dynamic shortcuts, which would list apps on its icon outside the lock.
  • Links. On Android 12 and later, an invite link opens Ovenlight only when the site's assetlinks.json lists the key that signed it. On Android 10 and 11, an app that isn't listed can still be offered in the system's chooser. The join page's Open in Ovenlight names Ovenlight's package, since another app can claim the ovenlight:// scheme. On iPhone the page never puts the key in a link another app can claim: the guest copies the invite and pastes it into Ovenlight. A page can't hand Ovenlight an invite directly: an invite link opens the join page in the browser, where the person decides, and an ovenlight:// link is refused. Nor can a page open android-app:, file: or content: links, and an intent: link is stripped of whatever could aim it elsewhere and, like any link to another app, asked about first.

If something goes wrong

  • An invite link leaked. If it's still open, ovenlight share --cancel <id> withdraws it (ovenlight guests lists open invites). If someone else used it, ovenlight guests lists who has the app now, which may be someone you already share with: ovenlight revoke <device ID> --app <slug> takes back only that app. Then send a new invite. A review link keeps admitting devices after one is removed (Invites), so for a leaked one, open or not, run ovenlight revoke <invite ID> (ovenlight guests lists the ID while the invite is open, and --json shows it on each device that claimed it). That removes every device that claimed it and cancels it if it's still open. Delete any device that joined without claiming, which ovenlight guests --sync lists, in the admin console (Machines), then make a new review invite.
  • An app's login link leaked. Whoever opens a pending login link first and signs in gets that app's node, in their own tailnet. If it's still pending, sign in with it yourself at once. If someone beat you to it, ovenlight status shows the app at an address in another tailnet, or with another owner: run ovenlight unpublish <slug>, delete nodes/<slug> in the state directory, delete the app's old node in the admin console (Machines) if it's still there, and publish the app again, which starts a new node. The node they got never comes back online, since its keys are gone.
  • An API token or OAuth client secret leaked. Revoke it in the Tailscale admin console (Settings, Keys; for an OAuth client, Trust credentials) and store a new one with ovenlight auth set. Then check your tailnet policy, devices and keys for changes you didn't make.
  • A lost or stolen phone. Your own: delete its node, an untagged ovenlight- device of yours, in the admin console (Machines), then look in ovenlight guests for invites and guests you didn't make. A guest's: ovenlight revoke <device ID>, then invite them again on their new phone.
  • A stolen computer. Revoke the API credential and delete the computer's app nodes in the admin console (Machines), then set up again on another computer, as for a move without a backup. FileVault keeps the node keys and the credential unreadable on a Mac that's off, but the automatic login that connector.md suggests for a Mac without a display needs FileVault off.
  • An app that may be compromised. ovenlight unpublish <slug>, which also stops the command the connector runs for it. It ran as you, with your login shell's environment (or your terminal's, if you started it): rotate the secrets there, and the API credential, which it could read too.

Reporting a vulnerability

See SECURITY.md.