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:

  1. Go to gitlab.com/calevans/picture-frame-system/-/issues/new.
  2. Give the issue a short, neutral title. Do not put the vulnerability in the title.
  3. Select the This issue is confidential checkbox before you submit.
  4. 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' with script-src 'self' and style-src 'self'. No page allows unsafe-inline or unsafe-eval. Tests fail if a widget fragment contains style=, an on…= handler or a <script>.
  • Widgets never use innerHTML, document.write or eval. Text goes in with textContent. 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 the Authorization header, 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:

  1. The offered file's hash and size match the offer.
  2. A detached ECDSA signature from your offline release key verifies against the public key built into the kiosk.
  3. The file is a plain ZIP (no prepended or hidden data).
  4. It is the kiosk's own package, with the version the offer promised, and a higher version than the one installed.
  5. 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).

text
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 SIGINT or SIGTERM), 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-disable and remove change 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=Strict means a link from another site lands on the login page even if you are signed in.
  • Behind a proxy, set client_ip_header and trusted_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/frames and receives a six-digit pairing code from random_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 /64 counts 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 403 or 401 and 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 (addJavascriptInterface is 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.

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_hosts and photo_hosts in config.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 healthy version and the to version of the update state. Each file must be a regular file, not a symlink, owned by the app's user id, and read through timeout dd so 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, runs pm 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: true only 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/tmp under a random 32-character name, created exclusively with mode 0600. A running count stops it at upload_bytes (30 MiB by default) with a 413. A Content-Length over the cap is refused before reading.
  • The Content-Type must be image/* or application/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 ftyp box whose major brand is heic, heix, heim, heis or mif1, and whose compatible brands include one of heic, heix, heim, heis. If avif or avis appears 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, APP1 or a trailing payload, is a 422.
  • 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/jpeg with nosniff, Content-Security-Policy: default-src 'none'; sandbox, Cross-Origin-Resource-Policy: same-origin, Content-Disposition: inline and a strong ETag from 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).

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 require status = '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 returns 503 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 iptables chain frame_adb and an ip6tables chain 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 INPUT for port 5555. Android's network daemon flushes INPUT when 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_ALLOW is 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 date syntax 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_PKG from frame.conf and 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.

  1. __Host- admin cookies with SameSite=Strict, Secure, HttpOnly. Weakening the prefix lets other hosts plant cookies.
  2. Referrer-Policy: same-origin on admin responses. no-referrer breaks the login (see above). unsafe-url leaks.
  3. Charge-then-refund rate limiting before password checking, and the hash lock. Moving the count after the check lets parallel guesses through.
  4. The dummy hash matching the live Argon2id parameters. Change them together.
  5. Secrets only on the Authorization header path. Never add the PIN hash, a token or an update offer to the cookie path, the dashboard JSON or an admin response.
  6. The server never holds a signing key, and the kiosk builds the download address itself. Never trust a URL from the server.
  7. No downgrade by the app. Only the root watchdog may install an older version, and only its own saved copy.
  8. No DISALLOW_INSTALL_APPS and no other restriction that blocks package installs. It would also block updates.
  9. Root never writes into the app's data directory, and reads only validated digits from it.
  10. The worker stays a separate process with the limits and the segment allow-list. Never call GD, heif-dec or any decoder inside a web request. Never build a shell command from request data; use argument lists.
  11. 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.
  12. Every frame-side photo query keeps status = 'approved'.
  13. The photo data directory stays outside the web root.
  14. Content Security Policy without unsafe-inline or unsafe-eval; no innerHTML, inline styles or inline handlers in widgets.
  15. The core never names a widget, and widgets make no outbound requests. Outbound calls go through http_get with HTTPS, a host allow-list, no redirects and a size cap.
  16. Tokens are stored as hashes and are never logged.
  17. Client addresses come from a proxy header only when the TCP peer is a listed trusted proxy.
  18. The narrow LimitRequestBody exceptions in .htaccess keyed on THE_REQUEST. Widen one and every endpoint can take a large body.
  19. The allow-list .gitignore, the leak check and the pre-commit hook.
  20. An invalid ADB_ALLOW entry fails closed.

Checklist for people who run a server

  • [ ] Serve the frame site on its own host name, over HTTPS, and turn on hsts once that is stable.
  • [ ] Create your admin user and turn on two-step sign-in.
  • [ ] Set client_ip_header and trusted_proxies if 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_ALLOW on 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, libde265 and libwebp patched; bin/migrate.php warns about old versions.
  • [ ] Run php app/bin/migrate.php after every deploy and read its warnings.
  • [ ] Back up var/ (it holds frames.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 pick http.
  • 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.