Security model
Printol opens a listening socket on a shop's computer and lets web pages talk to it. That deserves a straight account of what it does and does not protect, rather than a paragraph claiming everything is encrypted.
What is exposed
One HTTP listener bound to 127.0.0.1 on a port between 47600 and
47619. Binding loopback rather than 0.0.0.0 is the first and largest control:
a machine elsewhere on the shop's wifi cannot open a connection to it at all, whatever the firewall
says, because the socket is not reachable off the host.
Anything running on that machine can reach the port. That is unavoidable for a local bridge, and it is also not a meaningful escalation — a program already running as that user can print by asking the operating system directly, and can read the database file whatever we do.
What stops a hostile website
Any page in any tab can send a request to the port. The defence is not preventing the request; it is that an unpaired request can do nothing.
- Two endpoints work without a token —
/ping, which returns only enough to identify the bridge, and/pair, which starts an approval. Everything else answers401 not_paired. - Pairing needs a human. The desktop app raises a window showing the requesting origin, the app name it claimed, the scopes it wants in plain words, and a six-digit code. There is no auto-approve, no timeout that defaults to yes, and no way for the requesting page to influence what is displayed beyond the name it supplied — which is shown as untrusted text, next to the origin, which is not.
- Tokens are bound to an origin. A token presented from a different origin fails with
origin_mismatch. Exfiltrating one out of a paired site's storage does not let another site use it. - Scopes are enforced per request. A token granted only
printgets 403 on the sales ledger. - Cookies are never accepted. The listener does not set or read them and does not send
Access-Control-Allow-Credentials, so classical CSRF — where the browser attaches an ambient credential you did not ask for — has nothing to attach.
# What the bridge answers a cross-origin preflight with.
Access-Control-Allow-Origin: https://billing.example.com # the paired origin, echoed
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Allow-Private-Network: true # Chrome's PNA preflight
Vary: Origin
# Note what is absent: Access-Control-Allow-Credentials. Cookies are never
# accepted, so a token is the only thing that can authorise a request.The six digits
The code exists to defeat one specific attack: a hostile page firing a pairing request at the
moment the shopkeeper is expecting one from a legitimate site, hoping they approve the wrong
dialog. Because the legitimate page can display its own code and the hostile one cannot know
what is on the counter screen, a mismatch is visible. It is only as good as your integration
making the code visible — which is why the SDK hands it to you in onCode rather than
hiding it.
Rate limiting and abuse
A paired app is limited to sixty jobs a minute, and a raw payload to 2 MB. This is not a defence against a determined attacker — they are already paired — but it does stop a bug in an integration emptying a roll of paper before anybody notices.
Pairing requests are limited to one pending request at a time per origin, and expire in three minutes. A page cannot queue a hundred dialogs.
What it does not defend against
Stated plainly, because a security page that claims total coverage is not a security page.
- Malware already on the machine. Anything running as that user can read the SQLite file directly, or drive the printer through the OS. The bridge is not a sandbox and does not claim to be one.
- A shopkeeper who approves anything. If somebody clicks Allow on a dialog naming an origin they do not recognise, that origin can print and — depending on the scopes — read the catalog. The dialog is designed to make that decision informed; it cannot make it for them.
- An origin that is itself compromised. A token belongs to an origin, so an XSS in a paired billing site inherits its access. Scope your pairing requests tightly for exactly this reason.
- Plaintext on loopback. Traffic between the browser and the bridge is unencrypted HTTP. It never leaves the machine, so the only observer is a process on the host — which, again, could read the database anyway. TLS on loopback would mean shipping a private key inside the installer, which is worse than honest plaintext.
Data at rest
The database is an ordinary SQLite file in the user's profile directory, protected by the operating system's file permissions and nothing more. It is not encrypted: the key would have to sit beside it for the app to start unattended, which is theatre rather than protection. If the machine needs encryption, use the platform's — BitLocker, FileVault or LUKS — which is keyed to something we cannot leak.
Pairing tokens are stored hashed, so reading the database does not yield a usable token for another machine. Job history keeps rendered output for reprinting, and is capped at the last 500 jobs.
Updates
Installers and updates are built in public by GitHub Actions from a tagged commit. Update packages are signed with a key held only in the release workflow, and the app verifies the signature before applying one. Every release publishes checksums you can check by hand.
Reporting something
If you find a way to pair without approval, to use a token from another origin, or to reach the listener from off the machine, please email us before publishing. We will credit you in the release notes.