Security

What's protected: users' accounts and tunnels on the shared relay, visitors from each other, and the user's machine from the relay and from visitors. Only the port a user exposes is reachable.

What a tunnel is: HTTP and WebSockets only, no raw TCP, and always under the relay's domain, with no custom domains. The relay logs connections and logins, never anything about a public request.

Accounts and tokens

Tokens

  • A token is a 192-bit random value (48 hex characters).
  • The relay keeps only its SHA-256 hash, in memory and on disk (sha256:<hex> in the users files), and looks a token up by its hash. A leaked users file or backup doesn't let anyone connect, and timing reveals nothing about a token. A plain hash is enough because the tokens are random: there is nothing to guess.
  • The operator's users file may still list plain tokens. The relay hashes them as it reads the file, and on start rewrites a logins file from before hashing.
  • Each user has one token per device, at most 10. Logging in again from the same device replaces its token.
  • A login is saved before its token is given out. One that can't be saved is taken back: the device keeps its old token, and a new account doesn't count toward the day's signups.
  • POST /_link/logout with a login token revokes it: the relay drops its hash and saves the logins before answering 204. A token from the operator's users file is refused (403); only the operator can remove it. Connections already open with the token stay up until they close.

GitHub login

  • lnk auth login runs GitHub's login with the relay's own OAuth app.
  • The relay never accepts a GitHub token from a client, since that token could come from any app the person once approved.
  • It uses the GitHub token once, to learn who approved, and never stores it or passes it on.

Approving a login

With the app's client secret set, the link lnk auth login opens goes to the relay, then GitHub's web flow, then back to the relay. The relay issues nothing until the person approves on its page.

  • The page shows the GitHub account, the device name, the address that started the login, how long ago, and the code lnk printed.
  • When that address isn't on the approver's network (the same IPv4 address or IPv6 /64), the page warns that someone may have sent them the link, and the button reads "Approve anyway".
  • Only the browser that opened the link can finish. A __Host- cookie (HttpOnly, SameSite=Lax) is checked at GitHub's callback and at the answer, along with a per-page token.
  • Opening the link again starts over. The pages can't be framed.
  • Without the secret, the relay runs GitHub's device flow, where whoever sees the code can pass it on. So * (open signups) requires the secret.

Confirming a login

Once an account has a machine signed in (a login token), a login approved in the browser, on the relay's page or GitHub's, gets no token until one of those machines confirms it. A person tricked into approving gives nothing away by that alone.

  • lnk auth approve, on such a machine, shows each login waiting for its account with what the approval page shows: the device name, the address that started it, how long ago, and the code lnk printed. It warns when that address isn't on the machine's network. Confirming issues the token at the new machine's next poll; denying ends the login there, saying on which machine.
  • Only the account's own tokens see and answer its logins (a login's, or one from the operator's users file), never a short token. Each login is answered once, within 10 minutes of its approval.
  • A machine logging in again while it still holds its token confirms its own login.
  • A confirmation given ahead. A machine signed in can get a ticket (lnk auth ticket --json), which lnk box start hands to the box's lnk auth login. A login started with it, once approved in the browser as the same account, needs no confirmation. A ticket lasts 10 minutes and works once, an account holds at most 5, and by itself it gets no token.
  • An account with no login token is never asked: its first login, and one whose only tokens are from the operator's users file.
  • An older lnk can't wait for a confirmation. Where one is needed, the relay refuses it at its poll, and the approval page says the same: to upgrade, log in again and confirm with lnk auth approve. It still logs in where no machine is signed in.
  • Someone who lost every machine signed in asks the operator: link-relay admin logout <user> revokes all of its login tokens, and the next login needs no confirmation.

Invite list

  • Only GitHub users on LINK_GITHUB_ALLOW can log in, and a login token works only while its user is still on it: every connection checks.
  • A name in the operator's users file (/etc/link/users) can be claimed through login only if the invite list names it explicitly. * isn't enough.
  • Login tokens are bound to the GitHub account id, so a renamed or recreated GitHub account can't take over an old name. The relay tells lnk that id at login (user_id, public on GitHub anyway); the accounts plugin keeps it in config.toml, and lnk box tags your boxes with it.
  • Tokens from the operator's users file are valid while listed.
  • local.link runs invite-only: * isn't enabled there.

Blocking. link-relay admin block <user> disconnects a user's tunnels and refuses their connections and logins at once, without a restart. The block follows the GitHub account: renaming it and logging in under the new name is refused too.

Namespaces. Tunnels live at <app>.<user>.<domain>. The user comes from the token, never from the request, so a connection can only claim names in its own namespace. A second connection claiming a name replaces the first only for the same user.

Operator endpoints. /_link/admin/* and /_link/tls-ask answer only callers on the relay machine itself that aren't Caddy forwarding a public request: a loopback peer without X-Forwarded-For. Caddy also answers /_link/admin/* with 404 before it reaches the relay.

Slow requests before a login. The relay reads a few request bodies before anyone has logged in: a login's start and poll, and the forms of the approval and warning pages (16 KiB at most). Caddy streams bodies through, so each must arrive within 10 s in all (408). Each kind has its own slots: login starts and the approval form, login polls, and the warning page's Continue. Each kind reads at most 1,024 at once, 4 per visitor and 16 per network, an IPv4 /24 or IPv6 /48 (503). The relay may hold 65,536 open files (LimitNOFILE in its unit), so a few machines sending slowly can't use up its connections. Someone with 64 networks can still fill one kind's slots by reopening slow bodies every 10 s: then new logins, or logins under way, or the warning page's Continue get 503 until they stop. A share API call's body, after its token is checked, must also arrive within 10 s.

Login abuse. Each visitor (an IPv4 address or an IPv6 /64) may start 10 logins per 10 minutes and have 3 waiting for approval, and one network (an IPv4 /24 or IPv6 /48) 10, so a tunnel broker's many /64s count as few. The relay holds at most 500 pending logins: someone with 50 networks can still fill them, and new logins wait until theirs expire (10 minutes).

Open signups

LINK_GITHUB_ALLOW=* lets anyone with a GitHub account log in. Someone who comes in that way, rather than invited by name or listed in the users file, gets these on top of everything else.

  • Account age. The GitHub account must be at least 30 days old (LINK_GITHUB_MIN_AGE_DAYS, from GitHub's created_at), so throwaway accounts made for one phishing run can't sign up.
  • Reserved names. Names that would read as Local Link's own or a well-known service's (www, admin, support, security, login, paypal, ...) can't be claimed. The operator can invite one by name.
  • A daily cap. At most 100 new accounts per 24 hours across everyone (LINK_SIGNUPS_PER_DAY). Returning users always get in.
  • The visitor warning. A browser opening one of their tunnels, or one of their share links, first gets a page from the relay: which host, whose computer it is, not to enter passwords or payment details unless they know who runs it, and where to report it.
    • Continuing posts the page's form to the relay (/_link/visit on the tunnel's host), which sets a cookie on that host only: __Host-link_visit, HttpOnly, holding a random value of its own and an HMAC of the host and that value, under a key made at each relay start, so it can't be made up or carried to another tunnel. It lasts a week, or until the relay restarts.
    • The form counts only with a nonce the page set in a SameSite=Strict cookie, and not from another site, so a page elsewhere can't continue for a visitor. The pass is never in a page, the relay takes it out of requests to the app, and drops any of the app's Set-Cookies that has no name or holds one of its names, even in the value (a browser sends =__Host-link_visit=... back as that cookie), so a tunnel's owner can't hand visitors past the warning. A pass copied out of one browser still works in another.
    • Only a page load is stopped: a request accepting text/html (any method, so a form posted from another site counts) that the browser doesn't mark as a page's own request (Sec-Fetch-Mode other than navigate), without a link-skip-warning header. A link or a form can't set that header.
    • APIs, webhooks, WebSockets and programs pass, and so do tunnels for some GitHub accounts. A password-protected tunnel shows it once the password is right, since a password written into the link (https://user:password@...) opens the tunnel to anyone who has the link. The page can't be framed.
    • It keeps one phishing page from getting the whole domain blocklisted, and crawlers see it instead of the page behind it.
    • lnk tells the user when their tunnel has it.

Keeping tunnels apart

All tunnels share one parent domain, and TLS ends at the relay, so the relay is what keeps users apart.

  • Routing. The Host header decides the tunnel. Hosts are lowercased and a port is ignored. Anything that isn't exactly <app>.<user>.<domain> with valid labels gets no tunnel. Tunnel hosts are checked before the relay's own paths, so a tunnel can't reach /_link/*.
  • Cookies. When a response sets a cookie with a Domain other than the tunnel's own host, the relay removes that attribute, making the cookie host-only. Otherwise one user's app could set cookies for every tunnel and for the website. The check works on raw bytes, non-UTF-8 values included, and a cookie it can't rebuild is dropped. You can't reach or take over another user's tunnels.
  • Headers. Hop-by-hop headers, and headers named in Connection, are removed in both directions. Host and Content-Length are set per hop. The relay sets X-Forwarded-For, -Host and -Proto itself and drops visitors' own Forwarded, X-Real-IP and X-Forwarded-*. It trusts an incoming X-Forwarded-For only from Caddy on the same machine.
  • Request targets. Only origin-form targets (/path?query) reach an agent. The relay answers others, such as GET *, with 400, and the port service refuses them too, so a target can't point it at another port.
  • Public Suffix List. local.link isn't on it, so browsers treat all tunnels as one site: SameSite cookies don't separate users, and CSRF from one tunnel to another counts as same-site. Host-only cookies cover the most direct abuse. The Public Suffix List page has the prerequisites for listing *.local.link.

Password-protected tunnels

lnk tunnel open 3000 --auth user:password, or LNK_AUTH.

  • The relay checks HTTP Basic credentials, in constant time, before anything reaches the user's machine. It strips Authorization before forwarding, so the app never sees the password.
  • Fail closed. The relay confirms enforcement in Welcome.auth_enforced. lnk refuses to serve a tunnel that should be protected if the relay didn't confirm it.
  • Guessing. Each visitor gets 10 wrong passwords per tunnel, then one a minute. Each tunnel allows 100 across all visitors, then one every 6 s; visitors who logged in during the last day are exempt from that one. While over a limit, the relay answers 429 without checking the password. Requests without credentials, like a browser's first, don't count.
  • The password travels in Hello over TLS. It stays in the relay's memory, in plaintext, only while the tunnel is connected, and is never written to disk or logs.
  • It is one shared password, not per-person access. Use a long random one, or --github for per-person access.
  • Other sites. A browser sends the password it remembers with other sites' requests too, and WebSockets have no CORS. So the relay refuses (403) a WebSocket, or any request but GET and HEAD, whose Origin is there and isn't the tunnel's own, unless the browser marks it Sec-Fetch-Site: same-origin. Another site's page can still load pages (GET) with the password; it can't read them. Programs without Origin pass. An app that takes posts from other sites' pages, such as an OAuth sign-in with form_post, doesn't work behind --auth or --github.

Tunnels for some GitHub accounts

lnk tunnel open 3000 --github alice,bob: each visitor signs in with GitHub, and only those accounts get in.

  • Nothing reaches the machine first. Without a valid sign-in, a browser opening a page is sent to sign in, and anything else gets 401. The relay checks the sign-in on every request, WebSockets included, against the tunnel's list at that moment: reopening the tunnel without someone cuts them off.
  • Fail closed. The relay confirms enforcement in Welcome.github_enforced, and lnk refuses to serve otherwise. A relay without the GitHub login's client secret refuses such tunnels.
  • The sign-in is GitHub's web flow with the relay's own OAuth app, on the bare domain (/_link/github/start, then the login callback).
    • The relay uses the GitHub token once, to learn the username, and never stores it.
    • It then hands a one-time code, valid 60 s, back to the tunnel's host at /_link/github/done, which the relay answers and the app never sees.
    • The code works only in the browser that started. The tunnel's host gave that browser a nonce cookie (HttpOnly, 10 min) before sending it to sign in. So someone can't sign in as themselves and pass the link on to sign another person in as them.
    • It comes back to a path on the tunnel's host, never another site.
  • The sign-in cookie is __Host-link_github: host-only, Secure, HttpOnly, SameSite=Lax. It holds the GitHub username, when it ends (a day), and an HMAC of both with the tunnel's host, under a key made at each relay start, so a restart signs everyone out. Only the relay can make one, it opens only that tunnel, and __Host- means no other tunnel can set it. The relay strips its cookies before forwarding, so the app never sees them.
  • Names, not accounts. The list is GitHub usernames, matched when the visitor signs in. Someone who takes over a username after its owner renames it gets in until the tunnel's owner drops the name.
  • No allowance to use up. Visitors who aren't on the list are refused at GitHub's callback. Sign-ins waiting on GitHub are limited to 1,000 at once, 10 per visitor and 30 per network (an IPv4 /24 or IPv6 /48); someone with 34 networks can still fill them for 10 minutes at a time.
  • Other sites can't open WebSockets or send requests but GET and HEAD with a visitor's sign-in, as for password-protected tunnels.
  • The relay logs no sign-ins, and holds the list only in memory while the tunnel is connected.
  • The tunnel's own app can't set the relay's cookies with Set-Cookie (those of their names are dropped), but a script on its pages can set the nonce. That lets its owner sign a visitor in to that tunnel as the owner, and no more.

Limits

Every limit applies per visitor (an IPv4 address or IPv6 /64), per user (across all their tunnels) and/or relay-wide, so one party can't use up what everyone shares.

Requests

WhatLimitOver it
Requests to one user's tunnels50/s, bursts of 200429
...from one visitor12.5/s, bursts of 50429
In flight, per user256503
In flight, per visitor64503
Tunnels per user10refused at connect: "<user> already has 10 tunnels open (the limit is 10); close one first"
App name (--name)1-32 of a-z, 0-9, -, no dash at either endrefused at connect
GitHub accounts per tunnel (--github)100refused at connect
--auth user:password256 bytes, a non-empty password, no control charactersrefused at connect
New tunnel hosts given a certificate, per user7 a dayTLS error
Waiting for the response head60 s504
Slow request headers10 s (Caddy read_header)dropped
Requests one lnk serves at once512refused
WebSocket message through a tunnel512 KiBthat WebSocket closed

Request bodies

  • Buffered, for agents without flow control: 8 MiB each (413). Buffered at once: 64 MiB relay-wide, 16 MiB per user, 9 MiB per visitor (one body, and room for the heads below), counted as bytes arrive (503).
  • Streamed, with flow control: no size limit, and never buffered beyond one window. Pieces waiting to be written to the agent count in the buffered budget above (503), and so do a visitor's WebSocket messages (the WebSocket closes).
  • Timeouts (408): 10 s without data when buffered, 30 s when streamed. In total, 60 s when buffered, 30 min when streamed.
  • A request's head (its method, path and headers) counts in the same budget while it waits to be written to the agent (503), so requests queued for an agent that stopped reading hold no more than it allows. The relay keeps only the copy it sends the agent.

Responses and streams

  • Response data waiting for visitors: 128 MiB relay-wide, 32 MiB per user, 16 MiB per visitor, and one window per stream, or 1 MiB for agents without flow control. The relay grants credit only when it has room for what the credit lets in, so with flow control a full budget slows streams down. Credit fills at most three quarters of each of the three caps, so the last quarter stays for what comes without credit, each new stream's first window; a window grows only while a cap is less than half full, and past half a grown window shrinks back to its first as the visitor reads. Data with no room (more first windows at once than that quarter holds, or an agent without flow control) drops its stream, and the agent logs that response as cut short.
  • A connection whose writes make no progress for 60 s is closed, so a visitor who stops reading frees what waits for them.
  • Waiting for credit on a stream, in the relay or lnk: 5 min (CREDIT_TIMEOUT), then the stream is dropped.

Agent connections

WhatLimitOver it
Agent message to the relay1 MiB, before and after authenticationconnection closed
Pending handshakes, relay-wide64503
Pending handshakes, per visitor4503
Pending handshakes, per IPv4 /24 or IPv6 /488503
Pending handshakes with a valid token, per user8, apart from the abovewait with the rest
Time to send Hello2 sconnection closed
Idle connection45 s without receiving anything; pings every 15 sdropped

lnk sends its token with the upgrade request as well as in Hello, but only over TLS (wss://) or to a relay on this machine: the request goes before anything has answered as a relay, so to a relay given as http:// or ws:// the token goes only in Hello, once the upgrade is answered, and waits with strangers. A valid one takes a slot of its user's own, so strangers holding the 64 can't keep agents out. Eight networks (/24s or /48s) reopening a handshake every 2 s still fill the 64, and an lnk too old to send the token, or a relay too old to read it, waits there: it gets 503 and retries. The relay checks the header's token the way it checks Hello's, and logs neither. A Hello that doesn't decode is refused and logged as bad Hello, never with what it held, and so is a message from a connected agent.

Share links

WhatLimitOver it
Live links, per user100429
Live links, relay-wide100,000503
Their files' names and URLs, per user4 MiB429
Their files' names and URLs, relay-wide256 MiB503
Links made or removed, per user120 in 10 minutes, counted before the request is read429
Share API calls at once, per user4429
Files in a link1 to 1,000400
Request size2 MiB413
Name / URL length1 KiB / 4 KiB400
user:password256 bytes400
Lifetimeat most 7 days400
Wrong passwordsas for tunnels, per link429
Visitsas for tunnel requests, per user and per visitor429
Passwords checked at once, relay-wide2, each waiting at most 5 s503
Short tokens for the share API and /_link/whoami10 minutes, and until the relay restarts401

The relay saves share links whole, in its state folder, with one writer that works outside the lock visits read. A change is answered once a copy holding it is on disk. Changes that come while a copy is written wait as one copy, the newest, so a burst of them holds at most two copies of the links in memory.

Flow control

Each stream has a window in each direction: 2 MiB when lnk and the relay both support it, else 640 KiB. The sender may send that much, counting 64 bytes per message on top of the payload, before the receiver grants more with Credit. The receiver grants it as data is actually read. A download's window grows, up to 16 MiB, while its visitor keeps up and the relay has room for it. The messages are in docs/en/dev. As a result:

  • A hostile agent can't make the relay hold more than the credit it was granted per stream, which the response budget bounds. The relay tracks exactly what it granted and cancels a stream that goes past it. What isn't counted comes at most once per stream: a second response head cancels the stream, and nothing is accepted after its end.
  • A hostile relay can't make lnk hold more than a window per stream either, for request bodies or WebSocket messages, nor more than 64 MiB of them unread across a connection. An upload's window grows while the local app keeps up, as the relay grows a download's, and the growth of all of one connection's uploads together stays within 32 MiB of those 64. A stream that goes past either is aborted. What lnk has handed to the local side stays counted in those 64 MiB until it has left lnk:
    • A request body goes to the local app in pieces of at most 64 KiB. A piece leaves the window, and its credit goes out, when the local app's connection takes it. It stays counted until 512 KiB more has been taken after it, more than that connection buffers.
    • A body sent inside Request rather than after it counts until its request ends, and with flow control one larger than the window is refused. Without flow control such a body may be up to 9 MiB (the largest message the relay may send), within the same 64 MiB.
    • The local side takes a WebSocket's messages one at a time, each once it has written the one before, and a message stays counted until then. A local WebSocket that reads nothing for 60 s is closed.
    • A request or WebSocket that ends, or that the relay cancels, closes its connection to the local app, so nothing it holds outlives it, and the 512-request cap bounds them all. The kernel's buffers for those connections are outside the count.
  • Cancel is always delivered: a relay whose agent stops reading drops that connection instead. Either side gives up on a stream after waiting 5 minutes for credit, so a lost message can't wedge a stream.
  • A visitor who stops reading pauses only their own download, not the tunnel. Visitors who stop reading from several addresses at once can fill the user's share of the budget, though: other visitors' downloads on that user's tunnels then get their first window and wait for credit until the relay closes the stalled connections, 60 s after their writes stop.
  • Uploads stream through at the local app's pace instead of being buffered by the relay.

Old agents without flow control still work. The relay buffers their request bodies (8 MiB cap), caps what waits for each of their visitors at 1 MiB, and stops reading their connection, for up to 5 s per stream, while a visitor is behind.

The tunnel on the user's machine

  • Outbound only. lnk only dials out. Nothing listens on the user's machine for the relay, which reaches it only through the connection lnk opened.
  • The relay can reach only the exposed port. The port service builds every URL from localhost:<port> itself, follows no redirects, ignores proxy settings, and refuses request targets that aren't origin-form.
  • Against a hostile relay, lnk writes control and bidi characters in relay-supplied text as escapes, such as \u{1b}, in its log and errors, error bodies included (at most 512 bytes of each), and in visitors' paths in its request log, serves at most 512 requests at once (a relay that reuses the id of a request still running has its connection closed), drops oversized WebSocket messages, and holds at most a window per stream (see Flow control).
  • A relay on this machine is known by name. localhost, 127.0.0.1 and any *.localhost name count as this machine, so lnk reaches them over plain ws:// or http:// unless told otherwise, and lnk bucket pull sends a share's password to such a link over http://. That relies on the system's resolver keeping *.localhost on loopback, as most do but not all.
  • Secrets. The login token is the accounts plugin's (Accounts: security); the tunnel asks it for the token (lnk auth relay --json) each time it connects, and never saves it. Command-line flags are visible to other local users, so prefer LNK_AUTH to --auth, and LNK_TOKEN to --token (why).
  • Exposing is publishing. Anyone with a tunnel's URL can reach it, and tunnel hostnames appear in public Certificate Transparency logs as soon as their certificate is issued. An unusual name is not a secret: use --github or --auth for anything private. lnk tunnel open refuses to start without --github, --auth or an explicit --public.

Gaps

The operator can read tunnel traffic. There is no Public Suffix List entry, a shared password has one allowance for everyone, an account's first login can be phished, and there is one relay. See Known limitations.