Under the hood
Security
On this page
This page explains how the Picture Frame System defends itself: who and what it expects to be attacked by, every control that matters, and why each one exists. It is for people who run a server, review the code, or want to report a problem. If you only want to install the system, start with the server install guide and come back here when you want to know why it asks for what it asks for.
When you finish this page you will know what a leaked link, a hostile photo, a stolen password, a compromised server and a stolen frame can and cannot do, and which parts of the design you must not loosen.
Report a vulnerability
Open a confidential issue on the project's GitLab:
- Go to gitlab.com/calevans/picture-frame-system/-/issues/new.
- Give the issue a short, neutral title. Do not put the vulnerability in the title.
- Select the This issue is confidential checkbox before you submit.
- In the description, say what you found, how to reproduce it, which version or commit you tested, and what an attacker gains.
Please do not post details in a public issue, a merge request or a public chat. This is a volunteer project and offers no response-time guarantee, but a confidential report is read and answered as soon as a maintainer can.
Only the current default branch (master) is supported. Fixes are not back-ported.
How the system is split
Everything below follows from four trust boundaries.
flowchart LR
subgraph internet["Internet"]
admin["Admin browser"]
senders["People with a drop link<br/>or a card link"]
upstream["Upstream services<br/>weather, calendars, Nextcloud"]
end
subgraph server["Your server"]
web["Web requests<br/>Apache and PHP, web user<br/>reads and writes frames.db"]
cron["Cron fetcher<br/>the only code that makes<br/>outbound requests<br/>writes cache.db"]
worker["Image worker<br/>own process, no network,<br/>sees one file"]
end
subgraph lan["The frame's network"]
kiosk["Kiosk app<br/>holds the frame key"]
adb["Root ADB on port 5555"]
ha["Home Assistant<br/>optional"]
end
admin -->|"HTTPS, login, session,<br/>CSRF token"| web
senders -->|"HTTPS, secret in the URL,<br/>write-only"| web
kiosk -->|"HTTPS, frame key"| web
cron -->|"HTTPS, allow-listed hosts"| upstream
web -->|"untrusted bytes in,<br/>JPEGs out"| worker
kiosk -.->|"LAN, optional token"| ha
adb -.- kiosk
- Browser and sender to server. Everything arrives over HTTPS. The admin needs a login. Senders hold a secret in their link and can only write.
- Frame to server. A frame proves itself with a 256-bit key. A frame can read its own data and nothing else.
- Web to worker. Photos from strangers are decoded in a separate, resource-limited, optionally sandboxed process. The web process never decodes an upload itself.
- Server to device. The server can offer a kiosk update. The device decides whether to install it.
Where each secret lives
| Secret | Created by | Stored | Who can read it |
|---|---|---|---|
| Admin password | Command line | frames.db, as an Argon2id hash |
The web user |
| Two-step sign-in (TOTP) secret | Command line | frames.db, as plain base32 text |
The web user, and anyone who can read frames.db |
| Admin session | Login | var/sessions/ (directory mode 0700) |
The web user |
| Trusted-device signing key | First successful login | var/device.key (mode 0600) |
The web user |
| Frame key | The kiosk, on first run | On the device: app-private storage. On the server: only its SHA-256 hash | Root on the device; the server holds no usable key |
| Pairing code | The server | frames.db until claimed (24 hours) |
The web user |
| Drop link and card link secrets | The server | Shown once; only a SHA-256 hash is stored | The person you gave the link to |
| Calendar addresses and Nextcloud album link | The admin | frames.db; never returned by an admin API call |
The web user |
| Home Assistant token | The admin | frames.db as plain text inside the widget settings; write-only in the admin |
The web user; the kiosk's native code through its authenticated request |
| Kiosk PIN | The admin | frames.db, as a salted PBKDF2 hash |
The web user; the kiosk |
| APK signing key | You | Your keystore, outside the repository | You |
| Release signing key | You | Offline. Never on the server | You |
config.php holds no secrets. The release public key may appear in it.
Threat model
| Threat | What the attacker has | What stops them | What they can still do |
|---|---|---|---|
| A drop link leaks | The URL of one album's upload page | The link only accepts uploads. Photos wait in an approval queue. Rate limits, quotas, expiry, revoke and rotate | Fill the pending queue up to its caps; you reject the photos |
| A card link leaks | The URL of one sender page | Per-sender and per-address send limits; revoke and rotate; the sender sees only their own cards | Send cards, up to the limits, until you revoke the link. Cards reach the frame without approval |
| A hostile image is uploaded | A crafted JPEG, PNG, WebP or HEIC | Magic-byte check, size caps before decoding, a separate worker with resource limits, a sandbox, output rebuilt and checked | A decoder bug would run inside the sandbox with no network and no access to your data (if the sandbox is on) |
| The admin password is stolen | The password, not the second factor | Two-step sign-in, rate limits, session limits | Nothing, if two-step sign-in is on. If it is off, they are the admin: re-entering the password does not help against someone who has it |
| The server is compromised | Code execution as the web user, or write access to frames.db |
The device checks every update itself, against two independent keys that never touch the server | Read all server data, change settings, show content on frames. Not: install code you did not sign |
| A frame is stolen or its ADB is reached | Root on one frame | Frame keys are per frame; revoke ends access at once | Impersonate that one frame, read what it can read, including its Home Assistant token |
| Someone on the frame's LAN reaches ADB | A network path to port 5555 | An address allow-list in the boot script, applied by firewall rules | Anything, if their address is allowed or the list is turned off |
| Script runs on the frames web origin | A script injected into a page | No inline script, script-src 'self', no innerHTML, the frame cookie is HttpOnly, secrets travel only in a header |
See below |
| Someone sniffs the LAN | A position on the frame's network | HTTPS to the server | The one plain-http exception: Home Assistant on a private address |
What a script on the frames origin can do
The frames pages, the admin and the public pages share one origin (https://<host>/frames/). That is deliberate: it keeps the install to one virtual host. It also means a cross-site-scripting hole would be serious, so the code goes to some length to prevent one:
- Every page sends
default-src 'none'withscript-src 'self'andstyle-src 'self'. No page allowsunsafe-inlineorunsafe-eval. Tests fail if a widget fragment containsstyle=, anon…=handler or a<script>. - Widgets never use
innerHTML,document.writeoreval. Text goes in withtextContent. A test fails if a widget script contains them. - The frame cookie is
HttpOnly, so page script cannot read it. If a script makes requests, it gets only what the cookie path allows: the data the frame's own page already shows. Secrets, the PIN hash and update offers are sent only to requests that carry theAuthorizationheader, which only the kiosk's native code can add. - Even on the admin origin, script without a signed-in admin session gets a
401. Script with an admin session in the same browser is as powerful as the admin. This is why the install guide recommends a dedicated host name for the frame site.
What a compromised server cannot do
A kiosk installs an update only if all of these hold, and the checks run on the device:
- The offered file's hash and size match the offer.
- A detached ECDSA signature from your offline release key verifies against the public key built into the kiosk.
- The file is a plain ZIP (no prepended or hidden data).
- It is the kiosk's own package, with the version the offer promised, and a higher version than the one installed.
- Its signing certificates match the installed ones exactly.
The server holds neither key. A server attacker can withhold updates, or offer a newer build you have already signed. They cannot make a frame run code you did not sign, and they cannot downgrade a frame. See Kiosk updates.
What a compromised server can do
Be clear-eyed about the rest. With control of the web user, an attacker can read frames.db (calendar addresses, the Home Assistant token and the frames' key hashes), edit any setting, change kiosk PINs, and make frames show arbitrary content from the frame site, because the kiosk trusts that origin. They can see frame key hashes but not keys, so they cannot pose as a frame without the device. Keep the server patched, keep its backups private, and treat a server compromise as a reason to revoke every frame and replace the Home Assistant token.
Admin login
The admin has its own login. There is no HTTP basic auth and no dependency on a proxy. Every request first passes the checks below; only then does any route run.
Requirements before it runs
| Control | What it does | Why |
|---|---|---|
| HTTPS only | The admin returns a 500 ("server not configured") unless frames_url starts with https://. The PHP development server is the one exception. |
The login form and the session cookie must never cross the wire in clear text. A __Host- cookie also requires Secure. |
| Canonical host | A GET of the login page on any other Host is answered with a 303 to the address in frames_url. Every Location header the admin sends is built from frames_url, never from the request. |
Stops host-header tricks and open redirects. |
| Optional HSTS | Set 'hsts' => true and the admin sends Strict-Transport-Security: max-age=31536000. |
Off by default so you can test. Turn it on once the host stays HTTPS. |
Passwords
| Control | What it does | Why |
|---|---|---|
| Argon2id | password_hash with memory_cost 19456 KiB (about 19 MiB), time_cost 2, threads 1. If PHP has no Argon2id, bcrypt with cost 12. |
Memory-hard hashing slows offline guessing. The parameters are the OWASP minimum for Argon2id. They are explicit, so a PHP upgrade cannot quietly weaken them. |
| Rehash on login | If the stored hash uses older parameters, a successful login replaces it. | Parameter increases take effect without a password reset. |
| Dummy hash | When the user name is unknown or malformed, the code verifies the password against a hard-coded hash made with exactly the live parameters. | Both cases cost the same time, so response time does not reveal which names exist. A test asserts that the dummy hash would not be rehashed. Change the parameters and the dummy hash together. |
| Password policy | At least 12 characters; at most 1024 bytes (72 for bcrypt); no NUL bytes; not equal to the user name; not on a bundled list of about 2,000 common passwords (taken from the MIT-licensed SecLists project). | Stops the weakest passwords and keeps hashing time bounded. No online password-breach lookups, so nothing leaves your server. |
| Name rules | User names are lower-case, 1 to 64 characters from a-z 0-9 . _ -, and checked before any database lookup or log line. |
A hostile name never reaches SQL or the log. |
Rate limits and the flood lock
| Control | Value | Why |
|---|---|---|
| Per-address failures | 10 per 15 minutes, keyed by IPv4 address or IPv6 /64 |
A subscriber cannot rotate through IPv6 addresses to get around the limit. |
| Per-user failures | 20 per hour, keyed by a hash of the user name | Slows a guess on one account from many addresses. |
| Charge first, refund on success | Each attempt is counted inside one BEGIN IMMEDIATE transaction before the password is checked, and the count is deleted if the login succeeds. |
Parallel guesses cannot all slip in before the counters move. |
| Locked-out correct password | While a limit is exceeded, even the right password is refused with a 429 and Retry-After. |
Otherwise an attacker could keep guessing and read the lockout from the result. |
| Hash lock | A non-blocking flock on var/login.lock wraps the hashing. If another login is hashing, the request is refused at once with a 429 and Retry-After: 1. |
A flood of logins cannot run many 19 MiB hashes in parallel and exhaust memory. The lock file is mode 0640 so other local users cannot hold it. |
| Purge on failure | Expired rate events are deleted on the failure path. | The table stays small. |
Forms, cookies and sessions
| Control | What it does | Why |
|---|---|---|
| Double-submit form token | GET of the login page sets the cookie __Host-frameadm-login (32 random bytes, 15 minutes). The form carries the same value in a hidden field; the POST must match (hash_equals). |
Cross-site login attempts fail, and an anonymous visitor creates no session file. |
| Origin check | Every state-changing request, and every admin API request including reads, must carry Sec-Fetch-Site: same-origin, or an Origin header that is https and matches the host. |
The session cookie is SameSite=Strict; this is the second layer. |
Order of checks on POST /login |
Content type (415), origin (403), form token (403), rate limits (429), credentials (401). |
Cheap checks run before anything expensive. |
__Host- cookies |
The session (__Host-frameadm), form token and trusted-device cookies are Secure, HttpOnly, SameSite=Strict, Path=/, with no Domain. |
The __Host- prefix makes browsers refuse a cookie that a sibling subdomain or a plain-http page tries to plant or overwrite. |
| No anonymous sessions | A session starts only when the session cookie is present and well formed, or on a successful login. | An unauthenticated flood leaves no files behind. |
| Strict session mode | PHP use_strict_mode and use_only_cookies are on; sessions live in var/sessions/, outside the web root. |
A visitor cannot choose a session id. |
| Limits | 30 minutes idle, 12 hours total. The user's row is read on every request. | Removing a user, or changing their password, ends their sessions on the next request. |
| Session epoch | Each user has a random session_epoch that is copied into the session. It changes on password change, on enabling or disabling two-step sign-in, and when a user is removed. |
One value revokes every session and trusted device for that user. |
| Id rotation | The session id changes at login and every 15 minutes. The old id stays valid for 60 seconds, but only as a pointer to the new one. | Limits the life of a stolen id without failing requests that are already in flight. |
| CSRF token | A 256-bit token in the session; non-GET requests must send it in X-CSRF-Token. |
Defense in depth against forged writes. |
Two-step sign-in (TOTP)
Two-step sign-in is optional per user and strongly recommended. You enable it with php app/bin/user.php totp-enable <user>.
| Control | What it does | Why |
|---|---|---|
| Standard codes | RFC 6238: HMAC-SHA1, six digits, 30-second steps, one step of drift allowed either way. | Works with every authenticator app. |
| Constant-time check | All three candidate codes are always compared with hash_equals. |
No timing signal. |
| Replay guard | The last accepted step is stored. A code for the same or an earlier step is refused. The update is a conditional write, so two racing requests cannot both win. | A code seen by someone over your shoulder is worth nothing once you have used it. |
| One generic error | The login form always shows the code field. Password and code are checked together and fail with the same message. | The form does not reveal whether an account has two-step sign-in. |
| Safe enrollment | Enabling needs a valid code first, so a typo cannot lock you out. It rotates the session epoch. Recovery is totp-disable from the command line. |
A lost phone cannot lock you out, because the shell is the recovery path. |
The TOTP secret is stored in the database as plain text. Anyone who can read frames.db can read it. That is the same trust level as the password hash, but know that it is not encrypted.
Trusted device
After a successful login the browser receives the cookie __Host-frameadm-dev, valid for 90 days. It is an HMAC-SHA256 over the user id, the session epoch and the expiry, signed with var/device.key.
It does one thing: while valid, it exempts that user from the per-user lockout, so that someone who sprays wrong passwords at your user name cannot lock you out of your own admin. The per-address limit still applies. It does not skip the password or the code. Changing the password or the two-step setting changes the epoch and kills every device cookie.
Re-entering the password
Destructive actions need the password (and code, if enabled) entered within the last five minutes. A fresh login counts. If the time has passed the API answers 403 with code reauth_required; the admin page asks for the password through POST /api/session/reauthentications and retries.
Actions that require it:
- Revoking or un-revoking a frame, deleting a frame, deleting all unclaimed frames, changing a frame's display kind or URL, setting or clearing a frame's PIN.
- Every kiosk-release write: upload, edit notes, delete, roll out, set a frame's target release, clear an update failure.
- Deleting an album, deleting photos in bulk, revoking, un-revoking, rotating or deleting a drop link, deleting every photo from a drop link.
- Revoking, rotating or deleting a card link, and deleting all unopened cards from a sender.
Why: a stolen session cookie or a cross-site script should not be able to do irreversible damage, or push software to the frames, without the password.
Response headers
Every admin response carries:
| Header | Value | Why |
|---|---|---|
Cache-Control |
no-store |
Nothing from the admin lands in a shared cache or the browser's history cache. |
Referrer-Policy |
same-origin |
See below. |
X-Frame-Options |
DENY (and CSP frame-ancestors 'none') |
No clickjacking. |
Cross-Origin-Opener-Policy |
same-origin |
No cross-window handles to the admin. |
X-Content-Type-Options |
nosniff |
The browser trusts the declared type. |
Vary |
Cookie |
Caches never mix signed-in and signed-out responses. |
Content-Security-Policy |
The login page: default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; upgrade-insecure-requests, with no script at all. The admin page: default-src 'none', script-src 'self', style-src 'self', connect-src 'self', img-src 'self' (plus the image hosts widgets declare), base-uri 'none', form-action 'none', frame-ancestors 'none'. |
The strictest policy that still works. |
Why Referrer-Policy is same-origin and not no-referrer.
With no-referrer, browsers send Origin: null on the login form's POST. The origin check then refuses the login, because it needs a real origin. same-origin sends nothing to other sites, which is the goal of no-referrer, and still lets the form work. Do not change it back.
Audit log
Login events are written with error_log, in a fixed format that fail2ban can match. Passwords are never logged, and an unknown or malformed user name is logged as (unknown).
frame admin: login ok user=<name> ip=<address>
frame admin: login failed user=<name>|(unknown) ip=<address>
frame admin: login throttled ip=<address>
frame admin: login rejected origin ip=<address>
frame admin: login rejected token ip=<address>
frame admin: logout user=<name>
frame admin: reauth failed user=<name> ip=<address>
frame admin: reauth throttled ip=<address>
Kiosk-release actions also log frame admin: kiosk <event> user=… ip=… … with the version code and hash. Frame keys and Authorization headers are never logged.
The command-line tool
app/bin/user.php is the only way to create users and reset passwords.
- It refuses to run as root, so the database and key files keep the web user as owner.
- The password is read twice from a terminal with echo off (restored on exit and on
SIGINTorSIGTERM), or one line from standard input. It is never read from an argument or an environment variable, where other users on the machine could see it. passwd,totp-enable,totp-disableandremovechange or delete the session epoch, which signs the user out everywhere.
Accepted trade-offs
- A known user name can be locked out for up to an hour by someone spraying wrong passwords. The trusted-device cookie protects your own browser from this.
SameSite=Strictmeans a link from another site lands on the login page even if you are signed in.- Behind a proxy, set
client_ip_headerandtrusted_proxies. Without them the client address is the proxy's, and the per-address limit becomes one limit for everybody. That fails closed: it locks people out sooner, not later.
Frame authentication
A frame is identified by a 128-bit id and proves itself with a 256-bit random key. The kiosk makes both with SecureRandom on first run and keeps them in app-private storage with backups turned off.
| Control | What it does | Why |
|---|---|---|
| Only a hash on the server | The server stores SHA-256 of the raw key and compares with hash_equals. |
A stolen database cannot be used to pose as a frame. A fast hash is right for 256 random bits; a slow one would only cost CPU on every request. |
| Revocation is instant | The frame's status is read from the database on every request. | Revoking in the admin ends access at once. |
| One frame, one id | A frame may only ask for its own id (403 id_mismatch otherwise). |
A leaked key opens one frame. |
| Two ways to present the key | The Authorization: Frame <id>.<key> header, sent by the kiosk's native code, or the fk cookie, set by native code for the page. The header wins if both are present. |
The page needs a credential it cannot read. The native code needs one that cannot be attached by page script. |
| The cookie | Secure; HttpOnly; SameSite=Lax; Path=/frames/, host-only, one year. |
HttpOnly hides it from script. Host-only plus Secure means the browser never sends it to the plain-http Home Assistant address. |
| Secrets only on the header path | On the self request the PIN hash, widget_secrets (the Home Assistant token) and kiosk_update are present only when the request used the header. The kiosk-release download answers 403 header_required to the cookie. |
A page script can make the browser send the cookie but cannot read or forge the header. Anything it could trigger with the cookie must be harmless. |
| Short last-seen writes | last_seen_at and last_ip are written at most once a minute. |
Polling does not turn into database writes. |
Enrolling and claiming a frame
- The kiosk enrolls with
POST /api/v1/framesand receives a six-digit pairing code fromrandom_int, unique among unclaimed frames, valid for 24 hours. The body is limited to 1 KB. - Enrollment is limited to 3 per hour per address (an IPv6
/64counts as one), 3 unclaimed frames per address, and 50 unclaimed frames in total. Expired unclaimed rows are deleted the next time a frame enrolls. - Only a signed-in admin can claim a frame, and the code is checked again inside the claiming transaction. Wrong codes are throttled: 5 failures per 10 minutes, counted across all addresses.
- A frame that is revoked, deleted or whose pairing expired gets a
403or401and goes back to enrolling or to its "disconnected" screen.
The kiosk's own defenses
- PIN. The on-screen panel is protected by a four to eight digit PIN, stored as PBKDF2-HMAC-SHA1 with 10,000 iterations (the only variant Android 6 provides). After 5 wrong tries the panel locks for 5 minutes, then 30 minutes, 2 hours and 8 hours, and the lockout survives a reboot. The PIN keeps people at the frame out of the panel. It does not protect against someone with ADB, who can brute-force the hash offline in seconds.
- Lockdown. Provisioning makes the kiosk the device owner. It runs in lock-task mode with factory reset, safe boot and adding users blocked. It never adds a restriction that would block installing apps, because that would also block self-updates.
- WebView. File and content access off, mixed content never allowed, no third-party cookies, no JavaScript-to-Java bridge (
addJavascriptInterfaceis not used). - Navigation lock. The page may go only to the frame site, to the display address if the frame shows a URL, or to the single Home Assistant origin the server lists. Anything else, including form posts that skip the usual callback, is stopped.
- Broadcast permission. The two intents that release device owner and forget Wi-Fi need a signature-level permission, so only root (ADB) can send them.
Public links: drop links and card links
Two features give a link to someone who has no account.
| Control | Drop link (Photos) | Card link (Cards) |
|---|---|---|
| Secret | 256 random bits in the URL path | 256 random bits in the URL path |
| Stored | Only its SHA-256 hash. The link is shown once. | Same |
| Revoke, rotate | Immediate. Revoking, un-revoking, rotating and deleting need the password again. Un-revoking makes the old link work again, so rotate if it may have leaked. | Same. Restoring a revoked card link rotates its token, so a link that leaked while revoked stays dead. |
| Expiry | 90 days by default, at most 366. The admin can extend. | None |
| What it can do | Add photos to pending. Nothing else. | Send cards to one frame, read only its own cards |
| Cross-site defense | Requests that say Sec-Fetch-Site is cross-site, or carry a foreign Origin, get 403 |
Same |
| Page headers | Referrer-Policy: no-referrer, X-Robots-Tag: noindex, nofollow, a strict CSP with no inline script or style |
Same |
| Guessing | 20 unknown-link tries per 10 minutes per address. Only wrong guesses count, so a valid sender is not locked out by someone else on their network | Same |
| Request budget | 120 requests per 10 minutes per link | 120 per 10 minutes per sender |
| Send budget | 100 photos per hour and 300 per day per link; 500 per day per address; 10 rejected files per hour per link | 6 cards per hour and 20 per day per sender; 30 per day per address; 10 unopened cards per sender; 30 per frame |
| Reading back | None. Nothing lists what is in the album | The sender sees only their own cards |
Because tokens appear in URLs, they also appear in your web server's access log. Rotate logs, restrict who can read them, and do not ship them to a third party.
The admin never reads the text of a card. The admin API returns metadata only, so a hostile card can be deleted, but only without reading it.
Outbound requests
Only the cron fetcher (app/fetch/fetch.php) makes outbound requests. A test fails if any widget file calls cURL or opens a socket directly, if a widget file other than a fetch.php requires anything from app/fetch/, or if anything but the fetcher loads a fetch.php. Fetchers go out through one function, http_get().
- HTTPS only; no port, no credentials, no redirects.
- The host must be on the list:
calendar_hostsandphoto_hostsinconfig.php, or the fixed weather host. - 3 seconds to connect, 8 seconds in total, 5 MB at most (2 MB for weather JSON, 4 MB for album listings).
- Error text never contains a URL path or query, because those can hold secrets.
- Album listings are parsed with entity expansion and network loading off, and a document type declaration is refused.
- Calendar parsing is booked as a failure before it starts, so a file that crashes the parser leaves a back-off behind instead of a retry loop.
Calendar addresses never reach a frame; frames get merged "today" events. A Nextcloud album is different: the kiosk loads the photo previews straight from the Nextcloud host, and each preview address contains the album token. That host is added to the page's img-src, and nothing else is.
Kiosk updates
A frame can update its own app from the admin. The full sequence is in the update protocol; this section is the reasoning.
The principle: the server only offers; the device decides. Neither signing key is ever on the server.
flowchart LR
build["Your build machine<br/>APK signing key<br/>release signing key, offline"] -->|"APK and .sig"| server["Server<br/>stores, offers"]
server -->|"offer: version, hash,<br/>size, signature"| device["Frame<br/>public key built in"]
device --> v1{"Signature over<br/>hash and version<br/>verifies?"}
v1 -->|no| stop["Refuse, remember"]
v1 -->|yes| v2{"Hash, size, plain ZIP,<br/>package, version higher,<br/>certificates equal?"}
v2 -->|no| stop
v2 -->|yes| install["Install when quiet"]
| Control | What it does | Why |
|---|---|---|
| Two independent signatures | Android requires the new APK to be signed by the same certificate as the installed one. Separately, an offline ECDSA P-256 release key signs the exact text <sha256>:<versionCode>; the kiosk verifies it against a public key compiled into the app. |
A signed APK alone is not enough: a leaked or misused APK key, or an old signed build, still needs your explicit per-release approval. The release key alone is not enough either, because Android checks the certificate. |
| Fail closed | A kiosk built without a release public key refuses every update. | A missing key must never mean "skip the check". |
| Hash and size | The download is hashed while it is written and must match the offer exactly. It stops at the offered size. | Truncation and swapped files are caught. |
| Janus check | The file must start with a local file header; the central directory must end exactly at the end record; no data may sit before or between entries. The server checks the same at upload (415). |
Android 6 verifies only the old JAR signature, which allowed APKs with extra data glued in (CVE-2017-13156, "Janus"). The kiosk is built with both v1 and v2 signatures. |
| Never downgrade | The new version code must exceed the installed one. The device refuses anything else. To roll back, you ship the old code under a higher number. | A server or a leaked old build cannot push a frame to a known-vulnerable version. |
| The device builds the URL | The kiosk takes the host from its own build and the path from its own id and the offered hash. It ignores any address the server sends, does not follow redirects and insists on HTTPS. | The server cannot send the device somewhere else. |
| Header-only | The offer and the download need the Authorization header. The download works only for the file the frame is currently told to install, and the file is chosen by a database row, never by the URL. |
Page script cannot request or enumerate releases. |
| Download limits | At most 32 MiB; free space of twice the size plus 32 MiB must be available; 12 downloads per frame per hour; 10-minute deadline; the server sends the type application/vnd.android.package-archive and nosniff, and does not offer byte ranges. |
Bounded cost and no surprises. |
| Server-side upload checks | Streamed with a running 16 MiB cap, hashed as it streams, parsed by a bounds-checked PHP reader that needs no zip extension: 10,000 entries at most, only the manifest is inflated (1 MiB cap), string-pool sizes are checked against the chunk. The package must be com.calevans.frame. |
A hostile APK cannot exhaust memory or confuse the parser. The server does not judge the signing certificate; the device does. |
| Staged rollout | A release is proven only after a frame has downloaded it in full and then reported running that version or newer. A frame without the rollback watchdog, and "roll out to everyone", may only target a proven release. | The first install of any release goes to a frame that can roll itself back. |
| Failure telemetry is sanitized | A frame reports {sha256, reason}. The reason must be one of a fixed list or install_<number>; anything else is dropped. A report about a release that is not the frame's current target is ignored. The admin shows these with textContent. |
A frame cannot inject text into the admin or poison another release's status. |
| Writes need the password | Every kiosk-release write needs re-entry of the password, and is logged. | See Re-entering the password. |
| Receivers not exported | The two update receivers are exported="false". |
No other app can fake an install result. |
The rollback watchdog
A script that runs as root at boot (device/frame-fixmac.sh) rolls the kiosk back if a new version never proves healthy. It treats the app's files as hostile.
- It keeps its own copy of the last good build under
/data/misc/frame/(directory mode 0700, root only). It copies the installed APK there only after the app has reported that exact version healthy (or once, when the watchdog is first installed and no update is in progress). It never installs a file the app wrote. - It never writes inside the app's data directory. The rollback marker is a non-persistent system property.
- It reads two numbers from app files, nothing more: the
healthyversion and thetoversion of the update state. Each file must be a regular file, not a symlink, owned by the app's user id, and read throughtimeout ddso a named pipe cannot hang the script. Each value must be digits only, no leading zero, at most 10 digits and at most 2,100,000,000. This check happens before any arithmetic. - It rolls back only when the installed version equals the version the update promised, the app has not reported it healthy, the saved good version is lower, and 90 consecutive 10-second checks (about 15 minutes) agree. Then it copies its own saved APK to a fresh file in
/data/local/tmp, runspm install -r -d, and deletes the copy. Android still enforces the signature. - The most a server can cause is a rollback to the last build that ran healthy on that frame.
- Health does not depend on the server. The kiosk marks itself healthy after five minutes of running with no crash, or earlier after the first good request and a loaded dashboard.
- The kiosk reports
watchdog: trueonly if the script carries a marker line and root has published a property saying the watchdog can really work. A world-readable file alone is not trusted.
Keep your APK keystore clean: never sign throwaway or test builds with the real key. The release key is the second line of defense, not a reason to be careless with the first.
Photo handling
Photos from strangers are the most hostile input the system accepts. The pipeline assumes a decoder can be exploited, and limits what an exploit can do.
flowchart TD
up["Upload<br/>admin or drop link"] --> a["Stream to a private temp file<br/>running size cap, hash while writing"]
a --> b["Look at the first bytes<br/>JPEG, PNG, WebP or HEIC only"]
b --> c["Cheap refusals<br/>album full, library full,<br/>disk low, pending queue full"]
c --> d["Take the ingest lock<br/>one decode at a time"]
d --> e["Worker process<br/>limits, sandbox, no network"]
e --> f["Header sizes checked<br/>before any pixel is decoded"]
f --> g["Decode, shrink, rotate,<br/>re-encode as a new JPEG"]
g --> h["Web side re-checks the output<br/>segment allow-list, size, plain file"]
h --> i["Store by content hash<br/>row is approved or pending"]
Before decoding
- The body is streamed to
var/photos/tmpunder a random 32-character name, created exclusively with mode 0600. A running count stops it atupload_bytes(30 MiB by default) with a413. AContent-Lengthover the cap is refused before reading. - The
Content-Typemust beimage/*orapplication/octet-stream. It is only a filter: the bytes decide the type. - The first bytes are matched to JPEG, PNG, WebP or HEIC. Anything else, including SVG, PostScript, AVIF and unknown formats, is a
415. The decoder is chosen from this table, never from the file name or the header the client sent. - HEIC brand rules. The file must be an
ftypbox whose major brand isheic,heix,heim,heisormif1, and whose compatible brands include one ofheic,heix,heim,heis. Ifaviforavisappears anywhere, it is refused. This keeps AVIF, which uses a different codec path, out. - Dimensions are read from the file header and capped at 12,000 pixels a side and 64 megapixels before any decoding. For HEIC the worker parses the primary image's size from the file boxes (the first 2 MiB only).
- Animated WebP, JPEG with anything but one or three color components (CMYK), and truncated JPEG are refused with their own codes.
The worker
Decoding runs in worker.php, started as a separate process with an argument list (no shell). The web request never decodes an upload.
| Layer | Value |
|---|---|
| Resource limits | prlimit: 1 GiB address space, 60 seconds CPU, 1.125 GiB largest file, 32 open files; nice -n 10 |
| Wall clock | One 60-second deadline across all steps; the process is killed with SIGKILL at the deadline. Each decoder step is killed after 45 seconds, and a whole HEIC upload (header check, conversion, resize) shares the one 60-second deadline. |
| PHP settings | Its own photo-worker.ini: no exec, proc_open, popen, curl_exec, mail, fsockopen or sockets; allow_url_fopen and allow_url_include off; enable_dl off; 700 MB memory; open_basedir set to the widget folder, the one work directory and the one input file |
| Environment | Standard input is /dev/null; the environment is PATH only; output is capped (64 KiB out, 4 KiB errors) |
| Serialization | One decode at a time, under var/photos/.ingest.lock. A second request waits up to 20 seconds, then gets 503 busy with Retry-After: 5 |
| HEIC | heif-dec runs in the same limits and sandbox and writes a PNG into the work directory; the worker then reads the PNG with GD |
GD does the decoding for JPEG, PNG and WebP. No ImageMagick and no Ghostscript are used, so none of their coder and delegate attack surface applies.
The sandbox
The sandbox option under widgets.photos in config.php chooses how the worker is isolated.
| Mode | What it does | When to use it |
|---|---|---|
bwrap (default) |
Runs the worker inside bubblewrap with no network, its own process, IPC and user namespaces, a new session, and the system directories read-only. It sees the widget code, one writable work directory and the input file. config.php, frames.db, sessions and the rest of var/ do not exist inside. |
Most hosts. Required by default: if bubblewrap cannot run, uploads are refused (sandbox_unavailable) rather than run unprotected. |
user |
Runs the worker as a dedicated unix user through one sudo rule and a root-owned wrapper. The wrapper reads its paths from a root-owned file, accepts only paths of the exact per-run shape, takes a lock, applies the limits and the kill, and kills every process left over by that user. A firewall rule stops that user making any network connection, and the self-test proves it. |
Ubuntu 24.04, where a default AppArmor setting blocks bubblewrap. |
auto |
Uses bubblewrap if it works, otherwise runs unsandboxed with the limits only. | Development and tests. Not for drop links, because it can fall back to no sandbox. |
none |
No sandbox; limits still apply. | An explicit opt-out. The admin shows a note. |
A self-test runs the worker (and the HEIC path) on built-in samples and is repeated at least hourly and after any deploy. If it fails, uploads answer 503, the admin shows a banner, and bin/migrate.php prints a warning. Photo uploads are therefore a feature that can switch itself off; serving stored photos needs no decoder and keeps working.
bin/migrate.php also warns when it finds a libheif older than 1.17.6 or a libwebp older than 1.3.2. Those versions carry known decoder bugs.
What leaves the worker
The worker re-encodes everything as a new baseline JPEG (quality 82, 4:2:0). Nothing from the original is copied.
- Metadata is gone. GD never writes EXIF, GPS, XMP, IPTC or color profiles, and its own comment segment is stripped. The EXIF orientation is read by a small bounded parser and applied to the already-shrunk image.
- A JPEG segment allow-list is checked twice, in the worker and again in the web process. The only segments allowed are start of image, one 16-byte JFIF block, quantization tables, a baseline frame header, Huffman tables, restart interval, the scan with its restart markers, and end of image, with no byte after the end marker. Anything else, such as a comment,
APP1or a trailing payload, is a422. - The output must be a regular file (no symlink, no pipe) within size limits and within the 1280 × 800 and 320 × 200 boxes.
- No color-profile conversion is done: Display P3 photos look slightly less saturated.
Storage and delivery
- Files are named by the hash of the output, in
var/photos/files/<2 characters>/, with directories at 0750 and files at 0640. The data directory must not be inside the web root; the code refuses if it is. - The serving code checks the resolved real path stays under
var/photos/files/. - A photo is served as
image/jpegwithnosniff,Content-Security-Policy: default-src 'none'; sandbox,Cross-Origin-Resource-Policy: same-origin,Content-Disposition: inlineand a strongETagfrom the content hash. - Every write or delete that touches stored files holds the ingest lock, and the "is anything still using this file" check and the delete happen together, so an upload cannot race a delete.
- Each album's duplicate check uses the hash of the uploaded bytes, before decoding for the admin, and after decoding for drop links (see below).
Drop links give nothing away
A drop link is public and write-only, and it must not become a way to learn about your library.
- Approval queue. Photos from a drop link are
pending. They are never put in a frame's catalog and never served to a frame, because every frame-side query and the serve route requirestatus = 'approved'. Admin uploads are approved at once. - Identical answers. A new photo, a duplicate of a pending photo and a duplicate of an approved photo all return
202 {"received": true}. For drop links the decode runs first and the duplicate check second, so a duplicate costs about the same time as a new photo. - No capacity leaks. Album full, library full, pending queue full and disk low all return a generic
507 unavailable. A broken decoder returns503 unavailable. The real reason goes to the log only. - Ordering. The server checks the token, then the origin, then the rate limits (charged for every attempt, even rejected ones), then the caps, and only then reads the body.
- Quota information is minimal. The info call returns the link's own remaining quota and the names it needs to greet the sender, nothing about other links or the album's contents.
Quotas
All values can be changed under widgets.photos in config.php. The defaults:
| Setting | Default | Limits |
|---|---|---|
library_bytes |
5 GiB | Total stored photos |
album_photos |
5,000 | Photos in one album |
upload_bytes |
30 MiB | One upload |
pending_album |
500 | Pending photos in one album |
pending_bytes |
512 MiB | Pending photos in total |
min_free_bytes |
1 GiB | Free disk below which uploads stop (plus room for the decoder's scratch space) |
drop_per_hour |
100 | Uploads per drop link per hour |
drop_per_day |
300 | Uploads per drop link per day |
drop_ip_per_day |
500 | Uploads per address per day, across links |
Cards use a different, lighter path
The Cards widget resizes its cover and stamp art with GD inside the web process. That is intentional: only the admin can upload card art, so the risk is the admin's own files, not strangers'. Do not extend that path to public uploads.
The device
Network ADB
The frame runs a root adbd on TCP port 5555. Root ADB gives access to the frame key, the Home Assistant token and the saved Wi-Fi settings. It is the biggest single risk on the LAN, so the boot script puts an address allow-list in front of it.
How the allow-list works (ADB_ALLOW in /system/etc/frame.conf):
- The script builds an
iptableschainframe_adband anip6tableschain with the same name. The chain accepts loopback, accepts each address or CIDR in the list, and drops everything else. IPv6 gets loopback only. - A jump to the chain is kept at the top of
INPUTfor port 5555. Android's network daemon flushesINPUTwhen it starts, so the script checks every second and puts the jump back, and removes duplicates. - A bad entry fails closed. If any entry is not a valid IPv4 address or CIDR, the chain is built with loopback only, and the error is logged. You must fix the file and reboot to get ADB back over Wi-Fi.
- An empty list is the explicit opt-out. There is no limit and the script logs "allow-list off". Setting
ADB_ALLOWis a decision you have to make; the provisioning guide asks you to. - If the firewall commands themselves fail, the list is not applied and the failure is logged. Read the log after provisioning.
- USB ADB is never touched. It is the recovery path. It needs physical access.
An allow-list based on addresses stops casual attackers, not an attacker who can take over an allowed address. Put the frame on a guest or IoT network where you can. Root ADB also exposes the frame's Wi-Fi password to anyone who reaches it.
The boot script
device/frame-fixmac.sh runs as root at every boot. Beyond the watchdog above, it:
- Pins a per-frame, random, locally-administered Wi-Fi MAC address stored in
/data/misc/frame/(mode 0700), because the Wi-Fi chip would otherwise pick a new one each time. - Saves the clock regularly and tries to restore it after a power loss, then restarts the kiosk when the clock is trustworthy. The frame has no battery-backed clock; with a wrong date every HTTPS connection fails. The exact
datesyntax for setting the clock is marked unverified in the script. - Hands the backlight control to the kiosk's user id, but only if that id is in the app range (10000 to 19999). It never hands it to root or a system user.
- Validates
KIOSK_PKGfromframe.confand falls back to the built-in name if it holds anything unexpected. - Starts the kiosk again if Android ever shows the Home chooser.
Offline use
The kiosk keeps its last photos on disk and shows them if the server is unreachable. Photos from your own library are fetched with the frame's key; the kiosk sends the Authorization header only to URLs that start with its own photo route, rejects .. and encoded dots, and does not follow redirects. Nextcloud preview addresses get no credentials.
Repository hygiene
The repository is public. The risk is not secrets in code; it is private files that happen to sit in the working folder.
| Control | What it does | Why |
|---|---|---|
Allow-list .gitignore |
/* is ignored. Only listed paths are re-included: docs, scripts, tools, kiosk, web, uboot, and a few named files. Inside them, keys, certificates, databases, APKs, images, .env, config.php, var/, cache/ and vendor/ are ignored again. |
A new file in a new place stays private by default. |
| Leak check | scripts/leak-check.sh refuses staged files of private types (.env, databases, .htpasswd, config.php, APKs, keystores, keys, certificates, disk images, .bin, .sunxi) and any file whose content matches a pattern in your private .leak-patterns. |
Your own names, hosts and addresses never reach the history. The pattern list is itself gitignored, so the list of what is private is never published. |
| Pre-commit hook | scripts/pre-commit.sh deletes :Zone.Identifier files that Windows adds, then runs the leak check. Never bypass it with --no-verify. |
The check only works if it always runs. See Developer start. |
| Never redistribute firmware or dumps | Vendor firmware, the eMMC image, the boot files and anything extracted from a frame stay out of the repository and out of issues and merge requests. | They contain vendor code you cannot relicense and your frame's credentials and Wi-Fi settings. The unlock guide shows how to make the patch from your own backup. |
Never put keys or passwords in an issue, a merge request, a log excerpt or a screenshot.
Things you must not undo
If you change one of these, you reopen a hole the project has already closed. Tests cover many of them.
-
__Host-admin cookies withSameSite=Strict,Secure,HttpOnly. Weakening the prefix lets other hosts plant cookies. -
Referrer-Policy: same-originon admin responses.no-referrerbreaks the login (see above).unsafe-urlleaks. - Charge-then-refund rate limiting before password checking, and the hash lock. Moving the count after the check lets parallel guesses through.
- The dummy hash matching the live Argon2id parameters. Change them together.
-
Secrets only on the
Authorizationheader path. Never add the PIN hash, a token or an update offer to the cookie path, the dashboard JSON or an admin response. - The server never holds a signing key, and the kiosk builds the download address itself. Never trust a URL from the server.
- No downgrade by the app. Only the root watchdog may install an older version, and only its own saved copy.
-
No
DISALLOW_INSTALL_APPSand no other restriction that blocks package installs. It would also block updates. - Root never writes into the app's data directory, and reads only validated digits from it.
- The worker stays a separate process with the limits and the segment allow-list. Never call GD,
heif-decor any decoder inside a web request. Never build a shell command from request data; use argument lists. - Drop links answer identically and queue everything. Do not add "duplicate" or "album full" detail to the public answers, and do not show pending photos to frames.
-
Every frame-side photo query keeps
status = 'approved'. - The photo data directory stays outside the web root.
-
Content Security Policy without
unsafe-inlineorunsafe-eval; noinnerHTML, inline styles or inline handlers in widgets. - The core never names a widget, and widgets make no outbound requests. Outbound calls go through
http_getwith HTTPS, a host allow-list, no redirects and a size cap. - Tokens are stored as hashes and are never logged.
- Client addresses come from a proxy header only when the TCP peer is a listed trusted proxy.
-
The narrow
LimitRequestBodyexceptions in.htaccesskeyed onTHE_REQUEST. Widen one and every endpoint can take a large body. -
The allow-list
.gitignore, the leak check and the pre-commit hook. -
An invalid
ADB_ALLOWentry fails closed.
Checklist for people who run a server
- [ ] Serve the frame site on its own host name, over HTTPS, and turn on
hstsonce that is stable. - [ ] Create your admin user and turn on two-step sign-in.
- [ ] Set
client_ip_headerandtrusted_proxiesif you are behind a proxy or CDN. - [ ] Watch the log for the
frame admin:lines and feed them to fail2ban. - [ ] Keep access logs private. They contain drop and card link tokens.
- [ ] Give every frame its own PIN in the admin.
- [ ] Set
ADB_ALLOWon every frame, and put frames on a guest or IoT network. - [ ] Keep the APK keystore and the release key off the server, backed up, and out of the repository.
- [ ] Keep the sandbox on. Keep
libheif,libde265andlibwebppatched;bin/migrate.phpwarns about old versions. - [ ] Run
php app/bin/migrate.phpafter every deploy and read its warnings. - [ ] Back up
var/(it holdsframes.db, photos and card art) and keep the backups private.
Known limits
- The admin and frame pages share an origin. A script-injection bug on any of them is as serious as the admin session in that browser.
- The TOTP secret and the Home Assistant token are stored unencrypted in the database.
- Home Assistant on a private address can use plain
http, so its token and traffic cross the LAN unencrypted. The admin warns you when you pickhttp. - Root ADB on the LAN is only as protected as the allow-list. USB ADB is open to anyone with physical access.
- A card from a leaked sender link reaches the frame without approval, within the limits above.
- Android 6 receives no further security updates. The kiosk limits what the WebView can reach, but the platform underneath is old.
Related
- Install the frame server: permissions, Apache settings, the login and the photo decoder
- Build the kiosk and set up a frame:
ADB_ALLOW, PINs and the release key - Kiosk update protocol: the update sequence in detail
- API reference: every route, its authentication and its limits
- Developer start: how to run the tests that enforce much of this page