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/logoutwith 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 loginruns 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
lnkprinted. - 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 codelnkprinted. 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), whichlnk box starthands to the box'slnk 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
lnkcan'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 withlnk 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_ALLOWcan 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
lnkthat id at login (user_id, public on GitHub anyway); the accounts plugin keeps it inconfig.toml, andlnk boxtags 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'screated_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/visiton 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=Strictcookie, 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'sSet-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-Modeother thannavigate), without alink-skip-warningheader. 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.
lnktells the user when their tunnel has it.
- Continuing posts the page's form to the relay (
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
Domainother 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.HostandContent-Lengthare set per hop. The relay setsX-Forwarded-For,-Hostand-Protoitself and drops visitors' ownForwarded,X-Real-IPandX-Forwarded-*. It trusts an incomingX-Forwarded-Foronly from Caddy on the same machine. - Request targets. Only origin-form targets (
/path?query) reach an agent. The relay answers others, such asGET *, with 400, and the port service refuses them too, so a target can't point it at another port. - Public Suffix List.
local.linkisn't on it, so browsers treat all tunnels as one site:SameSitecookies 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
Authorizationbefore forwarding, so the app never sees the password. - Fail closed. The relay confirms enforcement in
Welcome.auth_enforced.lnkrefuses 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
Helloover 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
--githubfor 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
Originis there and isn't the tunnel's own, unless the browser marks itSec-Fetch-Site: same-origin. Another site's page can still load pages (GET) with the password; it can't read them. Programs withoutOriginpass. An app that takes posts from other sites' pages, such as an OAuth sign-in withform_post, doesn't work behind--author--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, andlnkrefuses 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
| What | Limit | Over it |
|---|---|---|
| Requests to one user's tunnels | 50/s, bursts of 200 | 429 |
| ...from one visitor | 12.5/s, bursts of 50 | 429 |
| In flight, per user | 256 | 503 |
| In flight, per visitor | 64 | 503 |
| Tunnels per user | 10 | refused 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 end | refused at connect |
GitHub accounts per tunnel (--github) | 100 | refused at connect |
--auth user:password | 256 bytes, a non-empty password, no control characters | refused at connect |
| New tunnel hosts given a certificate, per user | 7 a day | TLS error |
| Waiting for the response head | 60 s | 504 |
| Slow request headers | 10 s (Caddy read_header) | dropped |
Requests one lnk serves at once | 512 | refused |
| WebSocket message through a tunnel | 512 KiB | that 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
| What | Limit | Over it |
|---|---|---|
| Agent message to the relay | 1 MiB, before and after authentication | connection closed |
| Pending handshakes, relay-wide | 64 | 503 |
| Pending handshakes, per visitor | 4 | 503 |
| Pending handshakes, per IPv4 /24 or IPv6 /48 | 8 | 503 |
| Pending handshakes with a valid token, per user | 8, apart from the above | wait with the rest |
Time to send Hello | 2 s | connection closed |
| Idle connection | 45 s without receiving anything; pings every 15 s | dropped |
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
| What | Limit | Over it |
|---|---|---|
| Live links, per user | 100 | 429 |
| Live links, relay-wide | 100,000 | 503 |
| Their files' names and URLs, per user | 4 MiB | 429 |
| Their files' names and URLs, relay-wide | 256 MiB | 503 |
| Links made or removed, per user | 120 in 10 minutes, counted before the request is read | 429 |
| Share API calls at once, per user | 4 | 429 |
| Files in a link | 1 to 1,000 | 400 |
| Request size | 2 MiB | 413 |
| Name / URL length | 1 KiB / 4 KiB | 400 |
user:password | 256 bytes | 400 |
| Lifetime | at most 7 days | 400 |
| Wrong passwords | as for tunnels, per link | 429 |
| Visits | as for tunnel requests, per user and per visitor | 429 |
| Passwords checked at once, relay-wide | 2, each waiting at most 5 s | 503 |
Short tokens for the share API and /_link/whoami | 10 minutes, and until the relay restarts | 401 |
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
lnkhold 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. Whatlnkhas handed to the local side stays counted in those 64 MiB until it has leftlnk:- 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
Requestrather 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.
Cancelis 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.
lnkonly dials out. Nothing listens on the user's machine for the relay, which reaches it only through the connectionlnkopened. - 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,
lnkwrites 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.1and any*.localhostname count as this machine, solnkreaches them over plainws://orhttp://unless told otherwise, andlnk bucket pullsends a share's password to such a link overhttp://. That relies on the system's resolver keeping*.localhoston 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 preferLNK_AUTHto--auth, andLNK_TOKENto--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
--githubor--authfor anything private.lnk tunnel openrefuses to start without--github,--author 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.
