Internals

Security

Tempolight is made for a home or venue network: anyone who can reach its port can see the lights' state and, without a PIN lock, change everything. This page describes what protects what, and where it is in the code. To report a vulnerability, see SECURITY.md.

The PIN lock

  • POST /api/lock {pin} (4-8 digits) stores lock: {salt, hash} in tempolight.json: scrypt of the PIN with a random 8-byte salt, 32 bytes, hex (store.js hashPin). The PIN itself is never stored. It answers with a token, so the device that set the lock stays unlocked.
  • POST /api/unlock {pin} waits 300 ms before checking (slowing down guessing), compares with crypto.timingSafeEqual, and answers {token} or 403 Wrong PIN.
  • A token is 16 random bytes (hex), valid for 12 hours, kept in memory only (a restart logs everyone out). Setting a new PIN or removing the lock (DELETE /api/lock) clears all tokens. Backups never include lock, and a restore keeps the current PIN.
  • The online demo uses a light FNV hash instead of scrypt, since everything there stays in the visitor's browser.

Areas

Each route has an area (the generated table in api.md):

AreaWithout a lockWith a lock
open (null)allowedallowed
adminallowedneeds a valid token
guest area: effects, scenes, pads, brightnessallowedallowed when settings.guest.<area> is true (all four are by default), else needs a token

core.authorised(ctx, area) checks, in order: no lock → yes; the guest area is allowed → yes; the token from the x-tempolight-token header or ?token= is known and not expired → yes. need() throws 403 Settings are locked. The web app keeps the token in localStorage and forgets it on any 403.

Some open routes check something of their own:

  • POST /api/wake, /api/wake/snooze, /api/wake/stop, DELETE /api/wake, POST /api/sleep, DELETE /api/sleep: allowed like the brightness area, or with the link key (?key=, header x-tempolight-key, or key in the body), a random token made for phone automations that opens these calls only (compared in constant time). GET /api/home returns wake.key only to callers who are unlocked.
  • POST /api/gsi/:game: the game's token from its config file (auth.token in the body, compared in constant time).
  • POST /api/requests: only while settings.requests is on.

Open routes that read (GET /api/state, /api/status, /api/devices, /api/room, /api/songs, ...) are open on purpose: guests' phones and Home Assistant read them. They never contain secrets (below).

The WebSocket

/ws?token=<token>. The token is checked per message type, against the same areas: action and midi-clock need the pads area, screen the effects area (refused with {type: "screen-refused", why: "locked"}), map (camera mapping) is admin. preview, phone, audio-start/audio-stop, binary audio and clock are open, like the HTTP song routes: they feed music in, they don't change settings.

The Origin check (cross-site requests)

A page on any website could otherwise make a visitor's browser send requests to http://192.168.1.x:8080. So (ws.js foreignOrigin, used in app.js):

  • every /api/ request that isn't a GET is refused with 403 Requests from other websites aren't allowed when it carries an Origin header whose host differs from the request's Host;
  • WebSocket upgrades from another origin get 403 Forbidden.

Scripts, curl, Home Assistant and other apps send no Origin and aren't affected. Behind a reverse proxy that rewrites Host, list the public host in TEMPOLIGHT_ALLOWED_ORIGINS (comma-separated, with or without https://). GET requests are not checked (they don't change anything, and the browser's same-origin policy keeps other sites from reading the answers, since no CORS headers are sent).

The one exception is opt-in: Integrations → Docs site → Let the docs site try API calls (PATCH /api/integrations/docs {tryIt: true}, admin only, off by default) lets the API reference's Try it panel at https://tempolight.pages.dev/docs (and the docs dev server at http://localhost:3000) call /api/*: for those origins only, Tempolight answers the CORS preflight and sends Access-Control-Allow-Origin, and the Origin check lets their requests through. The PIN lock still applies to them, and the WebSocket stays closed to them.

Secrets

SecretWhere it is keptSent to browsers?In a backup (GET /api/backup)?
PINlock (scrypt hash)nono
Unlock tokensmemoryonly to the caller who unlockedno
MQTT passwordsettings.mqtt.passwordno: blanked, hasPassword insteadyes
Hue user name and client key, Nanoleaf tokenthe device (hue, nanoleaf)no: safeDev() removes themyes
OBS password, Twitch sign-in, Discord webhookintegrationsno: hasPassword, auth: {login, scopes}, a masked webhookyes
Game (GSI) tokensintegrations.gamesto admins only (GET /api/integrations, .../cfg)yes
Wake-up link keyhome.wake.keyto unlocked callers onlyyes
Spotify tokensspotify-token.json (mode 0600)nono
HomeKit identity, setup code, pairingshomekit.json (mode 0600)the setup code to admins (GET /api/homekit)no
Beat-map install IDbeatmaps-install-id (mode 0600)nono

A backup is admin-only, but it does contain the secrets marked yes: treat backup files like the settings file. tempolight.json itself is written with the default file mode (the process's umask); on a shared computer, keep the data folder private. Status broadcasts carry no secrets, and the start-up line that shows an MQTT broker set from the environment strips its credentials.

Rate limits and size limits

WhatLimitWhere
PIN guesses300 ms per attemptcore.js POST /api/unlock
Song requests30 a minute for everyone together (429)core.js
GSI posts40 a second per game; 30 wrong tokens a minute → 429 for a minuteintegrations/index.js
Twitch chat commandscooldownSec (10 s) per command, userCooldownSec (30 s) per user (moderators exempt)integrations/twitch.js
Request bodies12 MB (413)app.js readBody
WebSocket frames16 MB (closed with 1009)ws.js
Screen sync pictures22,000 base64 characters, at most 4,096 pixels; one browser at a timecore.js, runtime.js setScreen
Song memory400 kB per song, ~2 MB and 60 songs in allruntime.js
Lists100 custom effects, 200 requests, 32 zones, 128 DMX fixtures per universe, 500 timeline items of each kind, 8,192 LEDs per shapecore.js, pro-api.js, timeline.js

Slow consumers can't stall the server: preview frames, phones and broadcasts skip a client whose socket has more than 256 KB queued.

Safety of people

Not a network matter, but enforced in code: the photosensitivity limiter (settings.safe: at most 3 big brightness jumps a second), night mode (forces it), DMX lasers and fog off until turned on, lasers only in drops and never in safe or night mode, fog with a cool-down and a maximum duty cycle. See director and effects and runtime and outputs.

Network exposure

The server listens on 0.0.0.0:8080 by default (--host, --port), so phones on the same Wi-Fi can reach it; it speaks plain HTTP. Don't forward the port to the internet; for remote access put it behind a reverse proxy with TLS and authentication, and set TEMPOLIGHT_ALLOWED_ORIGINS. Other ports it may open: OSC (UDP 9000), Pro DJ Link (UDP 50001), Ableton Link (UDP 20808), and HomeKit (a TCP port, advertised by mDNS), all off until turned on; on the Raspberry Pi image, the Wi-Fi setup service opens its own hotspot when there is no known network.

Edit on GitHub