Files
PacketStation/docs/SECURITY.md
T
spencermandClaude Fable 5.1 39a4eb5213
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m30s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 1m40s
Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board
(waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag.
Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the
Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with
receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays
blocked until every line is pulled or short and every pulled line has a heat.

Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short
reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull
counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind,
optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events
table (migration 0005) shown on the Admin Storage tab.

Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat
pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board
and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI
stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 17:13:31 -06:00

20 KiB

Packet Station security

This document is rewritten for the server build. The old browser build's security document is in the old repo (C:\Users\spencerm\Coding\PacketStation\docs\SECURITY.md). The design comes from BRIEF.md and PLAN.md.

Status: phase 4. The identity controls (sessions, PIN, rate limits, audit, Origin check, derived group map) and the policy controls (server policy on every write, workflow steps as compare-and-set, server-side validation and limits, IT edit reasons, the filed lock, the upload pipeline for drawings and documents) are built and tested. Phase 3 adds the worker controls (read-only VISUAL and M-Files reads, stack guard, advisory locks, redacted errors, audited system writes). No requirement ID closes in phase 3. Phase 4 completes the upload pipeline (a kind per upload with its own types and limit, XLSX and CSV by content, the optional ClamAV scan, the assets volume quota alert) and adds the warehouse rules (the heat gate on release, closed hold and short reasons, receive by custody). Phase 4 closes D5. Every other control names the phase that builds it.

Contents:

  • Part 1: the 20 old layers and where each one lives now.
  • Part 2: the new build's controls.
  • Part 3: the production requirement IDs and the phase that closes each.

Part 1: the old layers, moved to the server

The M: drive build had 20 layers of protection, most of them enforced in the browser. Every layer that still applies moves to the server. The rest are dropped or replaced.

# Old layer Now Phase
1 PESCO network and firewall Kept. The app runs on docker-1 inside the PESCO network, reached only through Nginx Proxy Manager. It publishes no port. PostgreSQL is on each stack's internal network only. 0
2 Windows folder rights on the M: folder Dropped. Replaced by database roles: the app role cannot change the schema or update or delete the audit; migrations run as a separate role. Files sit on a volume only the containers see. 0
3 Employee ID + PIN sign-in Replaced by AD sign-in with the domain password, plus an optional Packet Station PIN checked on the server, for registered shop devices only. 1
4 Department and level screens A static role-to-screen map in the browser, plus server policy on every request. The browser only decides what to show. 1, 2
5 Per-PC access log Replaced by the server audit. Each row carries the AD account, session, client IP and user agent. No per-PC browser ids. 1
6 Filing seals and filed-packet lock HMAC seals computed on the server with a server key. The filed lock is a server policy rule. 7
7 Record integrity check A worker job (seals, asset hashes, audit hash chain) with an alert. 9
8 Self-hosted libraries Kept: no CDN. The CSP allows nothing external. 0
9 Output escaping React escapes output by default. The PDF export escapes on the server. 1, 7
10 Central permission policy Server policy, checked on every write, including writes that do not come from the screens. Built. 2
11 PIN sign-off (electronic signature) Server-verified signature with the PIN or the domain password, stored with who, when, what, factor and a content hash. 1
12 IT edit reasons Kept. Admin writes need a reason and are marked as IT edits in the packet history and the audit. 2
13 Screen lock Server-enforced sessions. The browser reports idle and background state; the server records it and refuses an idle or locked session. Unlock is a step-up check on the server. 1
14 Input and upload validation On the server: field limits on every write, closed reason sets, uploads typed by content per kind (drawing PDF; MTR and document PDF, PNG or JPEG; BOM XLSX or CSV), a size limit per kind (25 MB, BOM 5 MB), sanitised names, an optional ClamAV scan before anything is stored, and a quota alert on the assets volume. Built. 2, 4
15 Second-admin approval Superseded. Roles come only from AD groups and the group-to-role map is derived, so there is nothing to grant in the app. 1
16 Security alerts and automatic integrity check Worker jobs and the Notifications tab (email through app-03). 9
17 Controlled-copy stamp on data books Applied to server-built PDFs and signed with the seal key. 7
18 CSV formula guard Applied to every server export. 7
19 Update lock and stale-version block Replaced by a build version endpoint. A screen on an older build shows a banner and reloads. 0 (endpoint), 10 (banner)
20 VISUAL helper write guard Dropped. There is no helper. VisualKit and MFilesKit are read-only by design and connect as read-only accounts. 3

Part 2: the new build's controls

Sessions (phase 1)

  • HttpOnly, SameSite=Strict session cookie. The session row on the server decides.
  • Password sessions: idle sign-out after 15 minutes, absolute 12 hours. PIN shop sessions: absolute 10 hours, idle 15 minutes. All are settings.
  • Idle and background lock are reported by the browser and enforced by the server. Unlock is a step-up check with the PIN or the domain password.
  • Origin check on every state-changing request: the Origin header (or the Referer origin) must match the request host, and X-Requested-With: PacketStation must be present. Otherwise 403.
  • Admin is a password session only. A PIN session cannot open Admin or change Account settings.
  • Roles are resolved from AD at sign-in (nested groups through LDAP_MATCHING_RULE_IN_CHAIN), cached on the session, and re-resolved every 15 minutes on the session and by the AD refresh. A disabled AD account is always refused. A session whose user has lost every role is ended at the next refresh.
  • The session cookie token is stored only as an HMAC with the session key. The AD password is used for the bind and discarded.
  • The group-to-role map is derived from the department table and the PS- naming convention at startup. No screen edits it, so nobody can promote a group or themselves inside the app.
  • The browser reports hidden tabs and idle time; the server records locked_since and refuses a locked session with 423 until a step-up unlock. The server also locks a session itself when no request arrived for the lock interval (3 minutes, as the old build's screen lock), and when a tab stayed hidden for over a minute (the old build's background lock). A reload or an in-app navigation never locks.

PIN (phase 1)

  • Optional, per user, set by the user in Account settings after a password sign-in. Setting or changing it needs the domain password in the same request.
  • 6 to 12 digits, hashed with Argon2id on the server. Never visible to Admin. Admin can clear a PIN (audited), not set one.
  • PIN sign-in uses the employee ID and works only on registered devices. Devices are registered by an Admin or a shop department Supervisor in a password session.
  • Five wrong PIN attempts in a row, across sign-in, unlock and signature, disable the PIN. Three wrong attempts at unlock or signature end the session. A guessed PIN never touches AD.
  • A PIN sign-in still checks AD with the service account: a disabled account, or one removed from every group, is refused.
  • The device cookie is a random token stored as an HMAC; revoking a device ends every PIN session on it.

Rate limits (phase 1)

  • Checked before any bind to AD, so the app can never lock a domain account out.
  • Defaults: 5 failed password sign-ins per account per 5 minutes, then a 10-minute cooldown. 30 failed attempts per client address per 5 minutes. Successful sign-ins never count.
  • The domain's own lockout (20 attempts in 5 minutes) stays the last line.
  • The client address comes from X-Forwarded-For only when the request comes from npm_app_1 itself, re-resolved on change. Other containers on npm_default are not trusted.
  • A refused attempt answers 429 with Retry-After, is recorded as rate_limited and never counts toward the limits itself.
  • Failed sign-ins answer one message for an unknown account, a wrong password and a disabled account, so accounts cannot be enumerated. The audit and signin_attempts keep the real reason.

Policy (phase 2)

  • One policy in Python on the server (server/packetstation/policy/), pure functions with no I/O. Every write goes: session, policy, transition, validation, version check, packet log row, audit row, NOTIFY, in one transaction with the packet row locked. Nothing in the browser decides.
  • Every packet read returns can: [...], blocked: {...} and holder, so the browser enables controls from the server's answer. The server re-checks on the write.
  • A workflow step taken twice is refused with 409 and the current state (compare-and-set on the expected status and department). Child rows carry a row version checked with If-Match. Weld, heat tag, page and change order numbers are assigned by the server under the lock.
  • Preconditions (heat map answered, drawing attached, BOM pulled or short, heats recorded on pulled lines, checklist complete, welds recorded, Q.C. items complete, closed in VISUAL before filing) run on the server and are never bypassed, Admin included.
  • Admin edits outside custody need an IT reason of at least 8 characters, recorded in the packet log and the audit row (it_reason). An Admin cannot write a filed packet except through the reopen action, which needs a reason and a signature and breaks the seal.
  • Signatures (PIN or domain password, verified in the same transaction) on submit, send to Q.C., Q.C. accept and reject, route, file, reopen, change order issue, approve, reject and verify, the revision approval and hand drawing uploads.
  • Input: every string is cleaned (control and invisible characters removed, NFC, trimmed) and cut to its field limit; heat numbers, stamps, project numbers and lots are checked against the old patterns; JSON bodies over 200 KB are refused.
  • Uploads: typed by content (PDF, PNG, JPEG), 25 MB limit read in chunks, sha256, stored by id under the assets volume, served only with a session and with Content-Disposition and X-Content-Type-Options: nosniff. Phase 4 completes it (see "Uploads and storage").
  • The filed lock: a filed packet accepts only the data book date and the Admin reopen. Filing writes an HMAC-SHA256 seal with the server seal key; a reopen keeps the old seal with who broke it and why.
  • Details in POLICY.md.

Uploads and storage (phase 4)

  • Every upload names a kind (purpose): drawing, mtr, document or bom. No purpose means document. An unknown kind is 422.
  • The type is read from the content, never from the file name or the browser's type. A drawing is a PDF. An MTR or a document is a PDF, PNG or JPEG. A BOM is an XLSX or a CSV. A wrong type for the kind is 415 unsupported_type with a message that names the allowed types.
  • An XLSX must be a zip that lists [Content_Types].xml and xl/workbook.xml; a plain zip is refused. A CSV has no magic, so it is accepted only as a BOM, and only when its first 64 KB decode as UTF-8 with no NUL and no control character other than tab, CR and LF.
  • Limits per kind: 25 MB (upload_max_bytes) for drawing, mtr and document, 5 MB (upload_max_bytes_bom) for a BOM. The Content-Length is checked first, then the bytes as they are read. Over the limit is 413 too_large.
  • XLSX and CSV are always served as an attachment, never inline. Every blob keeps Content-Disposition and nosniff.
  • Optional ClamAV (requirement D5). When CLAMAV_HOST is set, every upload and every file the worker stores (drawings from M-Files) is streamed to clamd with zINSTREAM after it is read in full and before it is deduped or placed. A hit is 422 infected, nothing is stored, and an asset.rejected audit row keeps the signature, size and type. A scanner that cannot be reached refuses the upload with 503 scan_unavailable (fail closed). With CLAMAV_HOST empty there is no scan.
  • Quota alert. After every stored file and in the housekeeping job, the assets volume usage is checked against ASSETS_QUOTA_WARN_PCT (85 by default). Over it, one open notification_events row of kind assets_quota is written and audited as storage.quota_warning. Back under, the row is resolved. A failed check never fails an upload. Admin sees usage, the alert and the scanner on the Storage tab and can acknowledge an alert (alert.acknowledged). Phase 9 sends the email.

Warehouse rules (phase 4)

  • Release to the supervisor is refused on the server until every line is pulled or short and every pulled line that issued pieces has a heat (N/A and sub-assembly lines excepted). Heat numbers are checked against the old pattern.
  • Hold reasons and short reasons are closed sets on the server.
  • Short receipts go to the warehouse in any status but filed, or to the holder of the packet through the BOM edit rule. Nobody else.
  • An MTR on a line must be a PDF, PNG or JPEG asset.

Worker (phase 3)

  • The worker publishes no port and joins no proxy network. It reaches out to VISUAL and M-Files only; nothing reaches in. Admin and Clerical reach it only through rows in PostgreSQL (job_requests, the feed settings row) and NOTIFY.
  • It reads VISUAL through VisualKit as visualkit_ro and M-Files through MFilesKit as mfileskitRO. Both libraries are read-only in their own code. Nothing in Packet Station writes to either.
  • Stack guard at start and again before every feed run: the test stack refuses a production VISUAL target, production refuses an implicit one, and production refuses a fixture source.
  • One run per job at a time, by a PostgreSQL advisory lock per job name. Feed writes run in one transaction with a lock timeout and skip rows a person has locked.
  • Job errors are redacted (connection strings, password=, Authorization: and the VISUAL and M-Files passwords themselves) and cut to 20 entries of 500 characters before they are stored or shown.
  • Every feed write goes through the server policy as the system principal, which can take only the vis_* actions. Each one writes a packet log row and an audit row with role system. Each run writes a feed.run audit row with its counts, and each drawing fetch a drawing.fetched row with the sha256. Feed settings and resource map changes are audited with before and after.
  • Downloaded drawings go through the same checks as uploads: PDF magic, size limit, streamed sha256, stored by id.

Audit (phase 1)

  • Append-only audit_events, with a hash chain (prev_hash, row_hash).
  • The app role may insert and select, never update or delete. A trigger also refuses updates and deletes. Migrations run as a separate role.
  • packetstation doctor checks that an audit UPDATE fails.
  • Every identity event is a row: sign-in, failed sign-in with a reason, sign-out, lock, unlock, session ended, PIN set, disabled or cleared, device registered or revoked, signature, AD refresh. Each carries the user, session, roles, client address and a small detail object, never a secret.
  • signin_attempts and signatures are append-only in the same way.
  • Phase 4 audit actions: asset.rejected (an upload the virus scan refused, with reason, signature, size and mime), storage.quota_warning (the assets volume went over the warning threshold, with the usage) and alert.acknowledged (an Admin acknowledged an open alert). Every warehouse write is a packet.<action> row with detail.item, the warehouse stage before and after, and detail.qty on short writes.

CI identity fixtures (phase 1)

  • CI runs a throwaway Samba Active Directory domain controller (PSTEST.LOCAL) built from deploy/samba/, with a generated CA so the LDAPS path is real. It is seeded with the 50 groups (one missing and one extra on purpose) and test users for every role, including nested, disabled, multi-role, missing and duplicate employee ID cases. Passwords are random per run and exported to a volume the tests read. PESCO's AD is never used by CI.

Seals (phase 7)

  • Filing writes an HMAC seal with the server seal key. A reopen by Admin needs a reason and keeps the old seal in history.
  • The integrity job checks seals and asset hashes (phase 9).

Headers (phase 0)

  • The app sets HSTS, CSP and the other security headers itself. NPM's own HSTS is off.
  • The CSP allows nothing external. It is tuned again in phase 10.

Secrets (phase 0)

  • Docker secrets only, files under /etc/packetstation/<stack>/secrets, owner root:10001, mode 0440.
  • Read through *_PASSWORD_FILE settings. MFilesKit's password is exported by the entrypoint from its secret file.
  • Never in the repo, .env, logs or chat. See DEPLOY.md.

Part 3: requirement IDs

The IDs come from Part 3 of the old SECURITY.md. The status follows the approved plan.

ID Status
H1, H2 Closed, phase 0 (docker-1, internal DNS, NPM)
H3 Closed, phase 0 (NPM TLS, HSTS from the app)
H4 Closed, phase 0 (two stacks)
H5 IT's (host patching); documented in DEPLOY.md
I1 Partly closed, phase 1 (AD sign-in, no SSO); Entra SSO deferred
I2 Deferred (MFA with Entra)
I3 Closed for "PIN on the server", phase 1; device certificate deferred
I4 Closed, phase 1
I5 Superseded (no in-app admin grant); separate IT admin accounts remain IT's
I6 Largely closed, phase 9 (groups plus reconcile); quarterly review IT's
I7 Closed, phase 1
I8 App part closed, phase 1; kiosk lock policy IT's (Intune or GPO)
A1, A2, A3 Closed, phase 2 (server policy on every call, workflow steps as compare-and-set, validation and limits on the server)
A4 Closed, phase 0
D1, D2, D4 Deferred (envelope encryption, Key Vault)
D3 IT's (host disk encryption on docker-1)
D5 Closed, phase 4 (optional ClamAV: a clamav compose profile; when CLAMAV_HOST is set every upload and every stored drawing is scanned before it is kept, a hit is refused and audited, and an unreachable scanner refuses uploads; off when the host is not set)
D6, D7 Deferred (retention period and customer copy need a decision from Seth and Q.C.)
M1 Closed, phase 2 (append-only audit and packet log written only by the server; every packet write carries both)
M2 Closed, phase 7
M3 Closed, phase 9
M4, M5 Deferred (logs to stdout now; SIEM shipping is IT's)
S1 Closed, phase 0, tuned phase 10
S2 Closed, phase 1
S3 Closed, phase 9 (CI scanning)
S4 Deferred to IT (pentest before go-live)
S5 Closed, phase 2 (React escapes output; server messages are plain text; PDF export escaping comes with phase 7)
S6, S7, S8 Closed, phase 7
O1, O2 Closed, phase 9 (pg_dump timer plus PBS; one rehearsed restore); quarterly restore IT's
O3, O4 Closed, phase 10
O5 Superseded (no migration; M: retired read-only)

The brief's wording for the identity IDs, which the table above follows:

  • I4 and I7 (PIN lockout, PIN signatures verified on the server) are closed by this build.
  • I8 is closed for the app's part. The kiosk device lock policy in I8 is IT's, via Intune or GPO.
  • I6 (joiner, mover, leaver) is largely carried by the AD groups plus the nightly reconcile. The quarterly access review is still IT's.
  • I3 is closed for "PIN checked on the server" and open for "Intune-managed, device certificate". Registered devices use a device cookie for now.
  • I1 is partly closed by AD sign-in. There is no SSO yet.
  • I5 is superseded. No in-app admin grant exists, so second-admin approval has nothing to approve. Separate IT admin accounts with MFA remain IT's.

Out of scope for this build, with hooks left: writing to VISUAL or M-Files (never), Entra ID sign-in and MFA (I1, I2), envelope encryption with Key Vault (D1, D2), true .xlsx exports, scheduled report emails.