Files
raven/devdocs/modernization/SECURITY-REVIEW.md
John Cardinal 443d382394 case 4712 - customer contacts' pick lists limited to their own records
Step 4 of 4712 (security review F15). Pick lists checked only the Select role,
which customer contacts have for most types, and took the columns from the
request, so a contact could list every customer with phones and emails. For
contacts now: the customer list holds only their own customer (head office
contacts: their head office's customers); the unit list only their own
customer's units (head office contacts: the chosen customer, which must be
theirs; none until one is chosen); unit models as before; any other list 403.
pick-list/single names only those records. New CustomerPickList variant for a
single customer. Staff unchanged.

Checked: the contact's customer list went from 72 customers to its own one;
units only its own; vendors and users 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:14:43 -07:00

74 KiB

AyaNova v8 security review

Whole-codebase security review requested by John (2026-10-23). Done in passes, one area per session; each pass ends with its findings written here and committed. Review only: no code changes. Each fix John approves becomes its own case.

Findings in this file describe weaknesses in shipped software. The repository is private; keep it that way while findings are open.

Status

Pass Area Status
1 Threat model and attack-surface map Done 2026-10-23
2 Unauthenticated routes, login, tokens, password reset, 2FA Done 2026-10-23
3 Customer contact users: what they can reach and read Done 2026-10-23
4 Report rendering (templates, the server-side browser, report data and download routes) Done 2026-10-23
5 File handling: attachments, uploads, exports, logs, backups, restore, translations, logo Done 2026-10-23
6 Raw SQL: data lists, saved filters, search, KPI, schedule, pick lists Done 2026-10-23
7 Staff roles: the authenticated routes without a role check in the action (list below) Done 2026-10-23
8 Email and notifications (direct-smtp, templates, delivery) Done 2026-10-23
9 Client (XSS, token storage, npm audit triage), CORS, headers, Swagger UI Done 2026-10-23
10 Deployment: installers, Linux setup script, nginx, systemd, config and data permissions Done 2026-10-23
11 Sockeye public routes (licence fetch, trial requests) Done 2026-10-23

The order is by risk to internet-facing servers. Findings from any pass go in Findings.

Scope

  • In: the AyaNova server (raven/server/AyaNova) and client (raven-client); the Windows installers and launcher; Linux packages and the hosted subscription server setup script (sockeye-client/src/views/biz-subscription-server.vue); the configuration templates.
  • Sockeye: only its public routes (licence fetch and trial requests). Its admin UI is used by John alone (revisit if agents use it later).
  • Out: QBI.

Threat model

Deployments

Deployment Reachable by Notes
Hosted subscription droplet (Ubuntu, nginx + Let's Encrypt, www-data) The internet Most exposed. One company per droplet
Self-hosted Windows IIS Often the internet The manual recommends IIS when there is internet access
Self-hosted Linux server LAN or internet Customer's own reverse proxy, if any
Windows LAN / Linux desktop Local network Runs as the logged-in user (Linux desktop: as root with sudo ./AyaNova)
Windows standalone The PC itself Mostly evaluation (John)

Who might attack

  1. Anyone on the internet (no login): 14 anonymous routes, the static client, Swagger UI, health check.
  2. A customer contact (customer-user login, roles Customer/CustomerRestricted): should see only its own customer's data. The highest-value question in this review: can one customer's login read or change another customer's data, or staff data?
  3. A staff user with limited roles: should not exceed them (e.g. a restricted tech reaching accounting, ops or user admin).
  4. Someone holding a leaked token: a JWT or a download token (?t= in URLs, so it can land in browser history and proxy logs).
  5. A local user on a Windows or Linux machine running AyaNova.
  6. A report template author: report templates are JavaScript run in a browser on the server, with access to report data and the API.

What is at stake: customer business data (customers, work orders, invoices, attachments), user logins and passwords, the company's email reputation (SMTP), the licence, the server itself (code execution, the database, backups), and availability.

Attack-surface map

Authentication and authorisation design

  • Login: POST auth returns a JWT (HS256, jose-jwt), signed with AYANOVA_JWT_SECRET (random per boot if not set). Validation (Startup.cs:255-279): signature, issuer ayanova.com, expiry, zero clock skew; audience not checked.
  • One session per user: the user's latest token is stored (CurrentAuthToken); the middleware (Startup.cs:366-451) rejects any other valid token with E2004, except on /auth and notify/hello, and except local report-render requests: host 127.0.0.1, Request.IsLocal() and an rpl claim. Behind the hosted nginx the client address is forwarded (X-Forwarded-For, UseForwardedHeaders), so remote requests are not local. The exception skips only the latest-token check, not signature or expiry.
  • Roles are read from the database on every request (middleware), not from the token. There is no route-level role attribute and no global "login required" policy: each controller has [Authorize] (login only) and each action calls Authorized.Has…Role(...) itself. A missing call means any logged-in user, customer contacts included.
  • Download routes (?t=) take a per-user random download key set at login (DlKey, with expiry, AuthController.cs:272-276) instead of a JWT. Some check only that the key belongs to some active user (export, report, translation); others resolve the user and check rights (attachments, backups, logs). To verify in passes 4 and 5.
  • CORS: any origin, any method, any header (Startup.cs:167-174). Tokens travel in the Authorization header, not cookies, which limits the impact; checked in pass 9.
  • Swagger UI is public at /api-docs with the full API description (pass 9).

Routes

Generated with devdocs/modernization/security/routemap.py (python -I routemap.py <Controllers folder> <out.tsv>): 460 actions in 71 controllers. Regenerate it in later passes rather than trusting these counts if the code has changed.

Access Actions
Anonymous ([AllowAnonymous]) 14
Download key only (no attribute; check ?t=) 2 (attachment/download/{id}, backup/download/{fileName})
Login required ([Authorize]) 444

Controllers without a class-level [Authorize] (Attachment, Backup, ServiceBank) were checked: every action has its own [Authorize] or is a download-key route; ServiceBank is entirely commented out.

Anonymous routes (pass 2 unless noted)

Route Action
GET / (API root) Index
POST auth PostCreds (login)
POST auth/tfa-authenticate TfaAuthenticate
POST auth/reset-password ResetPassword
GET export/download/{fileName} DownloadAsync (download key; pass 5)
GET health Get
POST license/lc lc
GET log-file/download/{logname} DownloadLog (download key; pass 5)
GET logo/{size} DownloadLogo
GET notify/hello GetPreLoginPing
GET report/download/{fileName} DownloadAsync (download key; pass 4)
GET report/export/{id} DownloadTemplate (download key; pass 4)
POST translation/subset/{id} SubSet
GET translation/download/{id} DownloadTranslation (download key; pass 5)

Logged-in routes with no role or user-type check in the action itself (53, heuristic: the check may sit in the business layer, or the route may be harmless, such as lists, own settings or own notifications). Each is resolved in pass 7 unless an earlier pass covers it: GET server-info, GET role-rights, GET build-mode, POST auth/change-password, GET auth/totp, POST auth/totp-validate, GET authorization-roles/list, GET/POST/DELETE data-list-column-view…, GET data-list/listkeys, GET data-list/listfields, GET/PUT/POST/DELETE data-list-filter…, GET enum-list/list/{enumkey}, GET enum-list/listkeys, GET form-field-reference/{key}, GET global-biz-setting/client, GET job-operations/status|progress|logs/{gid}, POST job-operations/request-cancel, POST kpi, POST license/permanently-erase-all-data[-keep-tax-codes], GET notify/new-count, GET notify/app-notifications, DELETE notify/{id}, POST notify/direct-message, POST notify/direct-smtp, GET notify-subscription/list, GET pick-list/template/list, GET pick-list/template/listfields/{ayaType}, POST report/data, POST report/upload, POST schedule/svc, POST schedule/user, POST search, GET server-state, GET tag-list/list, GET tag-list/cloudlist, GET translation/{id}, GET translation/list, GET translation/translationkeycoverage, POST translation/subset, POST translation/upload, POST trial/seed, GET user/amsu, GET workorder/customer/{id}.

Already resolved from that list in pass 1:

  • license/permanently-erase-all-data[-keep-tax-codes]: SuperUser only (user id 1) plus the typed confirmation i understand (LicenseController.cs:248-275). OK.
  • trial/seed: only with a trial licence and an empty database (only SuperUser exists then) (TrialController.cs). OK.
  • notify/direct-smtp and notify/direct-message: see F3 and F4.

Highest-interest leads for later passes: POST search and POST kpi (do they filter by role and customer?), POST report/data and the report routes (pass 4), GET workorder/customer/{id} (customer users reaching other customers' work orders?), POST schedule/svc|user, GET user/amsu, POST report/upload and POST translation/upload (who may upload; what the content can do), job-operations/* (another user's job logs?).

Other ways in

  • Report templates: user-authored JavaScript and Handlebars run in a headless Chrome on the server (biz/ReportBiz.cs), with report data, a server-issued token for API calls from the template (rpl claim), and helpers such as ayPutToAPI (pass 4).
  • Raw SQL: data-list filters and sorts (DataList/*; case 4676 fixed the numeric and role values), saved filters, search, KPI, schedules (pass 6).
  • Files: attachments (upload, download, move, maintenance), exports, report files and templates, translation upload and download, logo, log files, backups and their deletion (passes 4 and 5).
  • Background jobs: backup (pg_dump, fixed in this release to run without a shell), notifications, licence fetch (outbound to the licence servers), report rendering, seeding.
  • Email: SMTP settings, notification templates, direct-smtp (pass 8).
  • Configuration and secrets: config.json (database password, JWT secret), logs (do they contain tokens or passwords?), the boot log's config line (password redacted).
  • Deployment: Windows installers (folder permissions), the launcher, the hosted setup script, nginx headers, the systemd unit (no hardening options), Linux desktop running as root (pass 10).

Findings

Severity: Critical (remote, no login, or full takeover) · High (any login, or one customer reaching another's data; or serious abuse of the company's resources) · Medium (needs a specific role, local access or an unusual setup) · Low (hardening, information). Status: candidate (seen in code, not yet confirmed), confirmed, fixed in case NNNN, accepted.

Finding IDs are permanent: never renumber or reuse them; John's cases refer to them. New findings take the next free number; a fixed or accepted finding keeps its ID and changes status.

ID Severity Status Title
F43 Critical fixed in case 4716 Unit pick-list variant (customerid:<value>) is put into SQL unparsed; any logged-in user can reach it
F41 Critical fixed in case 4710 (Sockeye; the business server gets it with 8.3.0) F5 also in Sockeye (internet-facing admin login) and in the 8.2.x business AyaNova server
F42 High confirmed (code) The licence-signing RSA private keys are hardcoded in Sockeye's source and binaries
F5 Critical fixed in case 4710 2FA login step accepts an empty or missing temp token: the password is skipped for users with 2FA on
F1 Critical (installs that kept it) fixed in case 4711 Placeholder JWT secret in the LAN/IIS and Linux server config templates
F19 Critical fixed in case 4718 Any logged-in user, customer contacts included, can import report templates, whose JavaScript runs in the server's browser
F21 Critical fixed in case 4715 Download file names from the URL are not checked: report/download and export/download may serve files outside the temp folder on Windows
F18 Critical fixed in case 4716 Pick-list search text is put into SQL without escaping; any logged-in user can reach it
F26 Critical fixed in case 4717 KPI interval criterion is put into SQL unchecked; reachable by staff users down to TechRestricted
F13 High fixed in case 4712 Customer contacts can open any customer's work order (customer view, customer report, attachments) by id
F14 High fixed in case 4712 Customer contacts can read, and by code change, delete and create, any customer's service requests
F15 High fixed in case 4712 Customer contacts can list every customer (and vendors, users, parts...) with any pick-list columns, phones and emails included
F16 High confirmed schedule/svc and schedule/user have no role check: anyone logged in sees the whole service schedule
F17 Medium confirmed (code) Notification subscriptions accept any UserId, and object events reach subscribers without a rights check: record names to any email address
F20 High candidate Report templates receive the renderer's token (SuperUser for customer notification reports) and the report browser can reach any network address
F27 High confirmed Restricted roles (TechRestricted, SubContractor, SubContractorRestricted) get whole work-order items through report data, rendering and export
F28 High candidate Users with change rights on Customer (Service, Sales, Accounting) can give customer contact logins any role, BizAdmin and OpsAdmin included
F33 High confirmed (code) Client renders some record text as HTML without escaping (search results, work-order address dialog, unit warranty info, dialogs)
F3 High confirmed (code) Any logged-in user, customer contacts included, can send arbitrary email through the company's SMTP server
F23 Medium confirmed (code) Logo upload accepts any content type and the anonymous logo route serves it inline from the app's origin (stored XSS)
F24 Medium fixed in case 4718 Translation import has no role check: any login can add translations
F30 Medium confirmed (code) SMTP: the mail server's TLS certificate is never checked
F31 Medium confirmed (code) The SMTP password is returned to read-only roles (OpsAdminRestricted, BizAdmin, BizAdminRestricted)
F32 Medium confirmed (code) A real-looking SMTP account and password are in the DEBUG defaults in raven and sockeye source
F34 Medium confirmed Shipped client libraries behind on security fixes: DOMPurify 2.3.8 (wiki sanitiser), Vuetify 2.6.6
F6 Medium confirmed No lockout or rate limit on login or the 2FA step; no password rules
F9 Medium confirmed Default SuperUser password (by design for trials), and an anonymous route that says whether it is still set
F36 Low confirmed (code) AyaNova listens on all interfaces on plain HTTP port 7575 in every template; hosted droplets are protected by a cloud firewall
F37 Low accepted (John) Standalone PostgreSQL accepts any local connection without a password (trust)
F2 Medium confirmed (code) Windows installers give all local users modify rights on the program and data folders, config.json included
F7 Low confirmed Password change or reset does not end existing sessions, and makes the download key non-expiring
F8 Low confirmed Latest-token check skipped for any URL ending in /auth or notify/hello
F4 Low confirmed (code) Any logged-in user can send an in-app notification to any user
F10 Low confirmed Anyone can record acceptance of the licence agreement (license/lc)
F11 Low confirmed API root page shows licensee, version, licence terms (and DB ID when unlicensed) to anyone
F12 Low confirmed Password hashing: PBKDF2-SHA512 with 10,000 iterations
F25 Low confirmed (code) Attachment and export gaps: upload stored before the rights check, "attached by" can be set to anyone, exports not tied to their user
F29 Low confirmed (code) server-info gives every login, customer contacts included, the server DB ID and full licence details
F35 Low confirmed No security headers from the server itself (self-hosted), no CSP anywhere, CORS any origin, public Swagger UI, download keys in URLs
F38 Low confirmed (code) Linux hardening: no systemd sandboxing, service user can replace program files, desktop runs as root, unused Docker files with a fixed database password
F39 Low confirmed (code) Licence fetch is one-shot by DB ID, and the DB ID is shown anonymously while unlicensed: someone else can take a new key first
F40 Low fixed in case 4681 (Sockeye) Trial request routes: no rate limit, non-cryptographic verification codes, a fixed bot-check value
F22 Low confirmed Minor report and job route gaps: template export by any download key, job status, logs and cancel by GUID without an owner check, browser without sandbox

Release decisions. John, 2026-10-23: F13 to F17 are urgent and are fixed in 8.3.0, as "next release" cases (case numbers to come). Hosted and self-hosted perpetual installations matter equally; how many use customer contact logins is unknown, so every installation is assumed to. F5, F1 and F18 are fixed in 8.3.0 as well (John, 2026-10-23). John: "I won't release until it's secure, all holes must be plugged": 8.3.0 waits for the findings of every pass to be fixed or explicitly accepted by John.

F5 2FA step skips the password (pass 2, confirmed on the local server)

AuthController.TfaAuthenticate (POST auth/tfa-authenticate, anonymous) finds the user only by TempToken == pin.TempToken (plus active, login allowed, 2FA on), then checks the 6-digit code. The temp token is the only proof that the password was given in step one, but:

  • after a successful 2FA login it is set to "" (AuthController.cs:193), so sending "tempToken": "" matches every 2FA user who has completed a 2FA login;
  • a user who has just turned 2FA on has it null; sending no temp token matches them;
  • TempToken is not required in TFAPinParam, and a wrong code does not clear it.

So anyone, with no login and no password, can send codes to this route and log in as whichever 2FA user the query returns first. TOTP accepts 3 codes at any moment (TwoFactorAuth.Net default discrepancy 1), the only throttle is the 3 s failure delay, which parallel requests get around, and there is no lockout (F6) and no nginx rate limit on hosted droplets. At a few hundred guesses per second that is, on average, well under an hour; even at 20 per second, a few hours. Users who turn 2FA on are the more privileged ones, so 2FA makes those accounts weaker than a password alone.

Proof (local dev server, seeded Inventory user, 2FA turned off again afterwards): 2FA just turned on, no password, temp token omitted, correct code: 200 and a token for Inventory. After one normal 2FA login, no password, temp token "", correct code: 200 and a token. Wrong code: 401. (Script kept outside the repo; the test needs the user's code, which an attacker guesses.)

Fix (proposed for 8.3.0): reject a null or empty temp token before the query; clear the temp token (set it to null) on success and on every failed code, so each guess needs the password again; optionally expire the temp token after a few minutes. Small change in one method; client unaffected (it always sends the tt it received).

F1 Placeholder JWT secret (from the 8.3.0 installer test; confirmed in pass 2)

dist/assets/lan-install-config.json and dist/assets/linux-server/config.json ship AYANOVA_JWT_SECRET set to a placeholder that is also printed in the manual. The manual tells users to change it (Linux: "optional but recommended"). An installation that keeps it signs logins with a publicly known secret.

Pass 2: the latest-token check does not save it. Tokens are deterministic: the payload is only exp (login time + 5 days, in seconds), iss and id, with no random part (AuthController.cs:280-291); two logins in the same second give identical tokens. So with the secret, someone with no login can rebuild a user's current token by trying each second of the last 5 days: at most 432,000 requests per user, each rejected at once (no delay). It works for any user who logged in in the last 5 days, SuperUser included. Proof: with the local dev secret, a forged token for Inventory was accepted after 6 tries and was identical to the real one. In addition, routes ending in /auth (F8) accept a forged token without any search, and behind a reverse proxy on the same machine that does not set X-Forwarded-For, a forged token with an rpl claim and Host: 127.0.0.1 skips the check too (IsLocalExtension: remote address equals local address).

Hosted droplets set up with the current script are not affected (random secret since 64d8f62); droplets created before that: to check (John). Self-hosted IIS and Linux servers that are reachable from the internet and kept the placeholder are fully open.

Fix options: treat the placeholder (and any secret shorter than 32 characters) as unset, so a random secret is used per boot, and log a warning (users would log in again after each restart); or generate a random secret at install time (Windows installer, Linux packages); or refuse to boot. Adding a random value (jti) to the token is cheap hardening as well.

F6 No lockout or rate limit on login and 2FA (pass 2)

POST auth and POST auth/tfa-authenticate delay 3 s on failure but count nothing: no per-user or per-address lockout, and parallel requests are not slowed. Hosted nginx has no limit_req. There is no password rule beyond "not empty" (seeded trial users use the login as password). Fix options: count failures per login and per temp token with a short lockout; limit_req on /api/v8/auth in the hosted nginx template.

F7 Password change does not end sessions; download key loses its expiry (pass 2)

UserBiz.ChangePasswordAsync (used by change-password and reset-password) does not replace CurrentAuthToken, so a session someone else holds keeps working up to 5 days after the owner changes the password. It also sets DlKeyExpire = null, apparently meant to be PasswordResetCodeExpire; with a null expiry ValidateDownloadTokenAndReturnUserAsync never treats the key as expired, until the next login replaces it. Setting AllowLogin to false does not end a session either (only deactivating does). Fix: on password change, set CurrentAuthToken and DlKey to new random values and clear PasswordResetCodeExpire.

F8 Latest-token check bypassed by the URL suffix (pass 2, confirmed)

The middleware skips the CurrentAuthToken comparison when the path ends with /auth or notify/hello (Startup.cs:409-413, EndsWith), meant for the login and pre-login routes. Any route whose last segment is free text can be called with a replaced or revoked token (e.g. tag-list/batch-add/auth, form-custom/auth). Confirmed: a replaced token got E2004 on form-field-reference/Customer and passed the middleware on form-field-reference/auth. The middleware also does not check Active. Impact is small on its own (only those routes, with the user's current roles), larger with F1. Fix: compare the exact route (/api/v8/auth, /api/v8/notify/hello), or skip the check by endpoint metadata ([AllowAnonymous]) instead of by path.

F9 Default SuperUser password, advertised anonymously (pass 2)

A new database has SuperUser superuser / l3tm3in (PrimeData.cs). Anonymous GET notify/hello returns sudf: true while that is still the password, and the login form then fills it in. Anyone can therefore check an internet-facing server for it in one request and log in as SuperUser. Each call also runs a PBKDF2 hash, a cheap way to load the server. Whether hosted droplets set their own SuperUser password at setup: to check in pass 10. Pass 10: they do not. The setup script generates the database password and JWT secret but not a SuperUser password, so every new hosted droplet starts internet-facing with l3tm3in until someone changes it. Fix in the script: generate one, set it once with AYANOVA_SET_SUPERUSER_PW, and write it to /root/ayanova-secrets.txt with the others. John, 2026-10-23: the published password is deliberate while a server is in trial mode, for easy evaluation; once licensed for production it must be changed on first login. Checked: that rule is enforced by the client only (api/initialize.js sends the user to the password page when knownPassword and the licence status is ActivePurchased); the server still accepts superuser/l3tm3in on a licensed server (API logins, or a user who navigates away) and notify/hello keeps reporting it. Fix that keeps the trial design: on a purchased licence, have the server refuse a login with the default SuperUser credentials (or allow it only to change the password), and stop returning sudf outside trial mode. Fix options: only return sudf from local or LAN addresses, or force a password change on first SuperUser login; at least warn in the UI and the manual.

POST license/lc with body "iaccepttheagreement" records the EULA consent; the comment says "SuperUser only", but the route is anonymous. Legal rather than technical: the consent record does not prove who accepted. Fix: require SuperUser.

F11 Anonymous API root page discloses licence details (pass 2)

GET /api/v8/ returns an HTML page with the exact version, "Registered to", licence type, expiry dates and licensed features; unlicensed, the server DB ID. Helps an attacker pick targets by version. Fix: show it only to logged-in users, or show the version only.

F12 Password hashing strength (pass 2)

PBKDF2-HMAC-SHA512, 10,000 iterations, 32-byte random salt (Hasher.cs). Current guidance (OWASP) is 210,000 iterations for SHA-512. Only matters if the database or a backup leaks. Raising it needs re-hashing at next login (store the iteration count). Roadmap item, not 8.3.0.

Pass 3 overview: customer contact isolation

Customer and head-office contacts are users with UserType Customer/HeadOffice and the Customer role (seeded contacts: AuthorizationRoles.Customer). Isolation to their own customer is enforced only in data lists: DataListController allows them two lists (CustomerServiceRequestDataList, CustomerWorkOrderDataList) and sets the customer filter itself. Everywhere else the checks are role sets designed for staff, and AuthorizationRoles.All includes Customer and CustomerRestricted: Select is All on almost every type (Customer, User, Vendor, Part, Unit, WorkOrder…), ReadFullRecord is All on Report, TaskGroup, QuoteStatus, and the Customer role can change any CustomerServiceRequest. The customer-specific rights helper (UserBiz.CustomerUserEffectiveRightsAsync) answers "is this feature on for this contact and these tags", never "does this record belong to the contact's customer".

All proofs below: local dev server, seeded contact Customer (customer id 1), read-only requests; seeded data is fake.

Fix direction (one case, possibly in steps): deny customer-type users by default, with an allowlist of the routes the customer UI actually calls (to be listed from the client), as a filter or in the token middleware, where AY_USER_TYPE is already set; then add the ownership check in each allowed route that takes an id (work order customer view, customer report, attachments, CSR). This also covers routes not yet reviewed.

F13 Any customer's work order by id (pass 3, confirmed)

GET workorder/customer/{id} (WorkOrderBiz.WorkOrderGetForCustomerAsync) loads the work order by id and never compares its CustomerId with the contact's. It returns serial, service date, customer name, customer reference number, contact name, invoice number, and the wiki when tag rules allow it. Proof: the contact of customer 1 read work order 1 of customer 12 (200, with that customer's name). A staff user calling it gets a 500 (the helper throws for non-customer users). The same tag-only rights, without ownership, also gate (code reading):

  • the customer work-order report (ReportBiz, report id -100): takes SelectedRowIds[0], gets the effective report from that work order's tags, then renders with the role check skipped (RequestIsCustomerWorkOrderReport), so a contact can render the configured customer report for any work order (pass 4 to confirm);
  • work-order attachments (attachment/list, attachment/download/{id} for customer users): ThisWOCanAttachments from tags only.

These need the customer work-order features turned on in global settings (common where the customer portal is used); the customer view route does not, it always answers.

F14 Customer service requests across customers (pass 3, confirmed for reading)

CSR Change includes Customer, and HasReadFullRole counts Change as read. CustomerServiceRequestBiz never checks CustomerId against the contact (create, get, put, delete). Proof: the contact read CSR 1 of customer 16 (200, with its text). Change, delete and create for another customer were not tested (they write data) but follow from the same code. Search also returns CSRs of all customers (it filters by role only). Pass 9: unit/customerunit (UnitBiz.CustomerCreateAsync) also takes CustomerId from the request, so a contact can add units to any customer; same fix (force the contact's customer).

F15 Pick lists expose the whole customer list (pass 3, confirmed)

POST pick-list/list checks only Select, which the Customer role has for customers, head offices, vendors, users, parts, units, contracts, projects, rates and more. The caller also sends its own template, which chooses the returned columns. Proof: the contact got all 70 seeded customers with phone and email. pick-list/single works the same way. A customer's contact can therefore download the company's customer list with contact details. Fix: no Select for customer roles on these types (keep what the customer UI needs, likely units of its own customer) and ignore template from customer-type users.

F16 Service schedule open to everyone logged in (pass 3, confirmed)

POST schedule/svc and POST schedule/user have no role check. Proof: the contact got 1,257 schedule items, each "work order number + customer name" with times and the assigned technicians. Fix: require read rights on WorkOrderItemScheduledUser (or WorkOrder) and refuse customer-type users. (schedule/personal is the caller's own schedule.)

F17 Notification subscription for someone else (pass 3, candidate)

NotifySubscriptionBiz.CreateAsync and PutAsync keep the UserId sent by the client, and validation checks the event type against that user's type, not the caller's. A subscription has its own DeliveryAddress and can attach a report (LinkReportId). So, by code, a customer contact could create a subscription with a staff user's id, a staff event type and its own email address, and receive staff notifications, possibly with reports attached. Not tested live because it sends real email. Fix: force UserId to the caller unless the caller has user-admin rights. Pass 8 to confirm how delivery checks rights. Pass 8 (code): confirmed, and it goes further: NotifyEventHelper matches object events (created, modified, deleted, age) to subscriptions by record type only, without checking that the subscriber may read that type, and CoreJobNotify sends to the subscription's DeliveryAddress. A user notification email carries the record type, its name and a link (no data, no report: report attachments exist only on customer notification subscriptions, which staff create). So a contact, or a staff user without rights on a type, can get a running feed of record names (new customers, work orders, ...) at any address. Re-rated Medium. Fix as above, plus a read-rights check on the subscriber when an object event is matched.

F18 Pick-list search text in SQL (pass 3; code reading completed in pass 6)

PickListSqlBuilder.Build puts the search text and the tag search text into like fragments by string interpolation, and no escaping was found on the way from PickListController.PostList through PickListBiz.GetPickListAsync. The route is open to every logged-in user, customer contacts included. Data-list text filters escape quotes since case 4676; pick lists were not covered. Deliberately not tested live. Fix: pass the search texts as SQL parameters (preferred) or escape them as the data-list builder does; review the rest of the pick-list builder in pass 6. Pass 6: the search text (autoCompleteQuery) and the tag search text (tagSpecificQuery) are the only free-text inputs of the pick-list builder, and neither is escaped on any path; template fields map to column definitions, ids are long, variants are parsed as numbers or matched to fixed strings. Status: confirmed by code.

F19 Report template import open to every login (pass 4, confirmed)

POST report/upload (ReportController.UploadAsync) has [Authorize] only, and ReportBiz.ImportAsync checks nothing either; creating or editing a template through the normal routes needs Change on Report (BizAdmin, BizAdminRestricted). A template holds JavaScript (JsPrerender, JsHelpers) and a Handlebars template, which ReportBiz loads into the headless browser on the server at render time. Confirmed: the seeded contact Customer imported a copy of a stock template (202, new report 104, deleted afterwards).

Consequences (from code; the rendering side was deliberately not tested):

  • report/render-job requires read rights on Report (everyone) and on the template's record type, which the uploader chooses (a contact can read CSRs), so any login can run its own JavaScript in the server's browser: it can reach the local API and any address the server can reach (internal network, cloud metadata endpoints) and put the results into the PDF it downloads.
  • An imported template also appears in staff report lists; when staff or SuperUser render it, it runs with their token (see F20).

Fix: in UploadAsync, require HasCreateRole(Report) like POST report; F20's fixes limit what a template can do even for legitimate authors.

F20 What a report template can reach (pass 4, candidate)

The page gets the renderer's own bearer token in AYMETA.ayClientMetaData.Authorization (for ayGetFromAPI/ayPostToAPI/ayPutToAPI), and nothing restricts the page's network access (no request interception in ReportBiz). So a template's script can send the token of whoever renders it to any outside address; that token is the user's current session token, usable from anywhere. Customer notifications with an attached report (CoreJobCustomerNotify) render with AuthController.GenRpt: a SuperUser (id 1) token with an rpl claim, accepted from 127.0.0.1 for the render timeout. A template author (including the "restricted" BizAdminRestricted role) can therefore act as SuperUser, or as any colleague who runs the report. Fix options: intercept page requests (PuppeteerSharp SetRequestInterceptionAsync) and allow only the local API URL and data:/blob:; render notification reports as a dedicated low-rights identity rather than SuperUser; consider a short-lived token limited to read routes for template API calls.

F21 File names from the URL in download routes (pass 4, candidate, code reading)

report/download/{fileName} and export/download/{fileName} accept any user's download key and serve FileUtil.GetFullPathForTemporaryFile(fileName), which is Path.Combine(TemporaryFilesFolder, fileName) with no check. On Windows, a backslash in the route value is not a separator for routing but is one for Path.Combine, and an absolute name replaces the folder entirely; the result is passed to PhysicalFile. Any login (customer contacts included) could then read files outside the temp folder, such as config.json (database password, JWT secret). Linux is likely not affected (/ stays encoded as %2F in route values); IIS request filtering may block some forms. Not tested live by choice. The same unchecked pattern is in log-file/{logname}, log-file/download/{logname} and the backup download route (ops roles; pass 5 confirmed: LogFilesController GetLog and DownloadLog need LogFile read, i.e. OpsAdmin or OpsAdminRestricted; BackupController.DownloadAsync needs Backup change, i.e. OpsAdmin; backup delete is safe, FileUtil.EraseBackupFile already strips directories with Path.GetFileName, the pattern the other routes should share). Fix: accept only a bare file name (Path.GetFileName(name) == name, no ..), and check the resolved full path is inside the folder, in one helper used by every such route.

F22 Minor report and job gaps (pass 4)

report/export/{id} gives any report template to any valid download key (templates are not secret, but staff-only templates go to customer contacts). job-operations/status|progress| logs/{gid} and report/request-cancel/{gid} check no owner; job ids are random GUIDs, so this needs a leaked id. The browser runs with --no-sandbox (Chrome's own sandbox off), so a browser bug reached through a template is not contained; Linux runs as www-data, Windows LAN as the logged-in user. Fix: owner check on job routes; consider enabling the sandbox where the platform allows.

F23 Logo upload: any content type, served inline (pass 5, from code)

LogoController.UploadAsync stores the bytes and the content type the browser sent (file.ContentType), with no check of either; DownloadLogo (anonymous, GET logo/{size}) returns them inline with that type from the AyaNova origin. A file uploaded as HTML (or SVG with script) therefore runs as a page of the AyaNova site when someone opens its URL, and the client keeps its session in localStorage (vuex-persistedstate, key AyaNova), which such a page can read. Upload and delete check HasReadFullRole(Global): BizAdmin, and BizAdminRestricted, whose rights on Global are meant to be read-only. Fix: require HasModifyRole(Global); accept only PNG, JPEG, GIF, WebP (check the bytes, not the header; no SVG); serve with X-Content-Type-Options: nosniff and a restrictive Content-Security-Policy (pass 9 looks at headers generally).

F24 Translation import without a role check (pass 5, from code)

TranslationController.UploadAsync (POST translation/upload) and TranslationBiz.ImportAsync check no role, the same shape as F19. Any login, customer contacts included, can add translations (up to 15 MiB each; imported as non-stock under a unique name). They affect only users who select them, unless an administrator makes one the default; whether translated text is ever rendered as HTML in the client is a pass 9 question. Fix: require HasCreateRole(Translation), like the duplicate route.

F25 Attachment and export gaps (pass 5, from code)

  • AttachmentController.UploadAsync streams the whole upload to disk (limit 10 GiB) before it checks rights on the target record, then deletes it on 403: any login can make the server write large files repeatedly. Check rights before reading the body, or lower the limit for users without rights.
  • The form field OverrideUserId is accepted from anyone and recorded as "attached by": limit it to the import path that needs it, or to SuperUser.
  • export/download/{fileName} accepts any user's download key; the name is {type}-{yyyyMMddHHmmssfff}.json and files are deleted after about 15 minutes, so guessing another user's export is impractical, but the route should still check that the caller made it.
  • Customer contacts can attach files to any customer's service request (part of F14; fixed by the same ownership check).

OK in this pass: attachments are served as downloads (Content-Disposition: attachment), stored under content-hash names in the attachments folder; batch move, batch delete and maintenance check rights; data import (POST import) checks create rights per type; there is no restore route (restore is a manual pg_restore, see the manual).

F26 KPI interval in SQL (pass 6, from code)

Six KPIs (WorkOrderCreatedCount, WorkOrderStatusCount, WorkOrderStatusPct, WorkOrderCompletedOnTimePct, WorkOrderItemLaborQuantitySummary, WorkOrderItemLaborQuantityEveryoneSummary) read interval from the request (options.Criteria["interval"], a string) and put it into the query as DATE_TRUNC('{interval}', ...) with no check. POST kpi has no role check of its own; KPIFetcher checks the KPI's AllowedRoles, which for the labour summary are Tech and TechRestricted, so the least privileged staff roles reach it (customer roles do not). A value that leaves the quotes changes the query, which can read or change anything in the database (password hashes, roles, licence). Fix: accept only the values the client sends (day, week, month, quarter, year; check raven-client for the exact list) and reject anything else, in one helper used by all six. The other KPI criteria are safe: timeSpan goes through the data-list date builder (fixed tokens, otherwise DateTime.Parse), tags through the tag helper (escaped), wostatus and userid are integers.

Raw SQL: what was checked and found safe (pass 6)

  • Data-list filters (DataListSqlFilterCriteriaBuilder): column keys are looked up in the list's field definitions (unknown keys throw); text, phone, email and URL values have quotes doubled; numbers, roles and ids are parsed (case 4676); bools become true/false; dates and relative-date tokens are parsed; durations are rebuilt from parsed integers; tags are normalised and quote-doubled.
  • Data-list sort and columns: keys looked up in the field definitions; direction becomes ASC/DESC.
  • ClientCriteria: every list that reads it (51) turns it into a typed filter option, so it goes through the filter builder above; saved filters too.
  • Search: the word breaker keeps only letters, digits and %, so terms cannot contain a quote. Safe by construction, but fragile: parameters would not depend on the tokenizer.
  • Customer notification "who" query, schedule, attachments list, name fetcher, unit and customer queries, accounting list, server metrics, job and sweeper queries: typed values or fixed SQL; schedule uses EF.
  • DbUtil and AySchema build SQL from configuration (database name), not from requests.

Hardening, not a finding: hand-built SQL with string escaping is spread over the builders. Moving free-text values to NpgsqlParameters (pick lists, data-list text filters, search, tags) would remove the whole class of bug; at least F18 and F26 should use parameters or an allowlist rather than escaping.

F27 Restricted roles bypass work-order item filtering (pass 7, confirmed)

WorkOrderBiz.WorkOrderGetAsync removes, for TechRestricted, SubContractor and SubContractorRestricted users, every item they are not scheduled on, other users' labour, travel and scheduling, and (by role) parts, units, expenses, loans and outside services. GetReportData (also used by GetExportData) calls WorkOrderGetPartialAsync, which applies that filtering only when the requested type is WorkOrder; for the child types (item, labour, expense, part, ...) it loads the parent header and the whole item, with every descendant when IncludeWoItemDescendants is set by the caller, and filters nothing. These roles have change (so read) rights on the child types they work with, so report/data, report/render-job and export/render accept them. Proof: on work order 1, seeded SubContractorRestricted got 0 items from workorder/1 (correct, not scheduled), and from report/data with a labour id and descendants on got the item with another user's labour, the scheduling and 2 parts (a role that should see no parts at all). Fix: apply the same restricted-user filtering in WorkOrderGetPartialAsync (one shared method used by both paths), and refuse child-type report and export requests for items the user is not scheduled on. Check QuoteBiz and PMBiz for the same split (they also reference restricted users).

F28 Contact logins with staff roles (pass 7, candidate, code reading)

UserBiz lets anyone with modify (create) rights on Customer create and edit customer contact users (IsOutsideCustomerContactTypeUser), and BizRoles gives Customer change rights to Service, Sales and Accounting. Changing a user between inside and outside types needs rights on both User and Customer (UserBiz.cs:803), but nothing limits the roles a contact user may hold: ValidateAsync checks only that the roles value is valid. Authorisation is by role everywhere, so a contact with BizAdmin or OpsAdmin roles is an administrator. A Sales, Service or Accounting user could therefore create a contact login with administrator roles and a password of their choosing. Not tested live. The F13 to F17 allowlist would limit what a customer-type login can call, but the roles must still be constrained. Fix: in UserBiz.ValidateAsync, allow only Customer and CustomerRestricted roles on Customer and HeadOffice users (and only inside roles on inside users); reject anything else on create and update.

F29 server-info to every login (pass 7)

GET server-info has no role check and returns the version, schema, server time zone, the server DB ID and the licence as JSON, to any login including customer contacts. The about page needs some of it; contacts need little or none. Whether the DB ID matters to Sockeye (licence fetch, trial requests) is checked in pass 11. Fix: return the DB ID and licence only to staff, or only to ops and admin roles.

Pass 7: routes without a role check that were found OK

DELETE notify/{id} (owner checked), notify/new-count, notify/app-notifications, notify-subscription/list (the caller's own), data-list column views (validation refuses another user's id), saved filters (own or public), user-option/{id} (own unless admin), auth/change-password, auth/totp* (own), POST kpi (each KPI checks its own roles; but see F26), POST search (results filtered by read rights per type), GET user/amsu (returns only whether the caller is SuperUser), enum lists, form field references, translations (read), tag lists, role lists, build mode, server state, global-biz-setting/client (display settings only). Already covered elsewhere: job-operations/* (F22), schedule/* (F16), workorder/customer/{id} (F13), report/upload (F19), translation/upload (F24), notify/direct-smtp and direct-message (F3, F4).

F30 SMTP certificate not checked (pass 8, from code)

Mailer.SendEmailAsync sets client.ServerCertificateValidationCallback = (s, c, h, e) => true, so any certificate is accepted. Anyone who can intercept the connection (a hostile network, DNS) can pose as the mail server and collect the SMTP account and password and every message. Also, with security set to none, MailKit's ConnectAsync(host, port, false) uses STARTTLS only if offered, which an attacker can suppress. Fix: remove the callback (validate normally); if a customer needs a self-signed mail server, make that an explicit setting.

F31 SMTP password returned to read-only roles (pass 8, from code)

GET global-ops-notification-setting returns the whole settings object, SmtpPassword included, to every role with read rights on OpsNotificationSettings: OpsAdminRestricted, BizAdmin and BizAdminRestricted, while only OpsAdmin may change it. Those users can then use the company's mail account directly. Fix: never send the password to the client (return a "set" flag; keep the stored value when the PUT leaves it empty).

F32 SMTP credentials in source (pass 8)

models/GlobalOpsNotificationSettings.cs, in the #if (DEBUG) constructor, sets a FastMail SMTP server, account and password; the same file is in sockeye/server/models. Release builds use placeholders, so shipped software is not affected, but the credentials are in both repositories and their history (contrary to the "secrets never go in a repo" rule), and debug builds send real email through that account (which explains report 81's real bounces in testing). Fix: rotate that FastMail app password now (John); remove the values from both files and read debug SMTP settings from an environment variable or user secrets. History need not be rewritten once the password is rotated. John, 2026-10-23: rotation planned once the old Sockeye server is retired after the migration; its own case.

OK in pass 8: SMTP settings change and the test-send route are OpsAdmin only; batch direct SMTP (job-operations/batch-direct-smtp) checks modify rights on the record type and sends only to the records' own addresses; notification emails are plain text built from record names (no HTML injection); the customer notification "who" query escapes tags.

F33 Unescaped record text rendered as HTML in the client (pass 9, from code)

Of the 13 v-html uses in raven-client, these render text that users enter, without escaping it first:

  • views/home-search.vue: the search result excerpt (item.info, from Search.GetInfoAsync, which does not encode it), with the search terms highlighted. Searchable text includes fields customer contacts can write (service requests, units).
  • components/work-order-address.vue: addresses built by formatAddress from customer, head office and unit address fields.
  • components/work-order-item-units.vue, quote-item-units.vue, pm-item-units.vue: the warranty summary (vendor name, receipt number, work order list).
  • components/gzconfirm.vue: dialog messages, which are translation strings; with F24 any login can import a translation.

The session token is kept in localStorage (vuex-persistedstate), so script running in the page could act as the signed-in user. Fix: escape these values (an escapeHtml helper, or build the markup with Vue templates instead of strings); encode the excerpt on the server too; render translation strings as text. Safe: the wiki (marked then DOMPurify, see F34), the calendar summaries (Vuetify escapes event names), and the remaining sinks (fixed strings). A Content-Security-Policy (F35) would be a second layer.

F34 Client libraries behind on security fixes (pass 9)

npm audit (read-only, lockfile) reports 550 advisories, almost all in build tools (webpack, Vue CLI, Babel, ESLint) that run only on the build machine and do not ship. Of the packages the app ships: DOMPurify 2.3.8, the wiki sanitiser, has sanitiser-bypass fixes in 2.4.x and 2.5.x; Vuetify 2.6.6 has XSS fixes in 2.6.10 (the last 2.x is 2.7.x); marked 1.2.9, luxon 1.28.0 and vue 2.6.14 have only slow-regex (browser-side) advisories. Fix: raise DOMPurify to the latest 2.5.x and Vuetify to the latest 2.7.x, both on the same major version. This is a front-end dependency change, which CLAUDE.md rules out without John's decision; check that both still build with Node 12.22.9. John, 2026-10-23: both upgrades approved for 8.3.0, provided they do not conflict with the Node 12.22.9 pin or break the client. Checked (read-only npm view): DOMPurify 2.5.9 is the latest 2.x, no engine limit, same API; Vuetify 2.7.2 (latest 2.x, peer vue ^2.6.4) is the target, with 2.6.16 (last 2.6, has the 2.6.10 fix) as the fallback if webpack 4 on Node 12 cannot build 2.7. Prove by a full client build, the lint, and a smoke test of wiki, calendar and date pickers, then Cypress on devtestn10; package.json and package-lock.json change in their own commit.

F35 Headers, CORS, Swagger, keys in URLs (pass 9)

  • The server sets no security headers. Hosted droplets get HSTS, nosniff, X-Frame-Options and Referrer-Policy from the nginx template in the setup script; self-hosted IIS, Linux and Windows installs get none. No deployment sets a Content-Security-Policy.
  • CORS allows any origin, method and header. Impact is small because the token travels in the Authorization header (not a cookie), so another site cannot use a visitor's session.
  • Swagger UI and the full API description are public at /api-docs.
  • Download keys (?t=) are in URLs, so they reach proxy access logs and browser history; they last 5 days (F7 makes some last longer).

Fix: add the headers in the app (so every deployment gets them), with a CSP that allows the client's own scripts; limit CORS to the server's own origin unless API users need more; consider Swagger UI for logged-in users only.

F36 Listening on all interfaces, no firewall (pass 10, from code)

Every config.json template (lan-install-config.json, linux-server, linux-desktop, the standalone launcher's) sets AYANOVA_USE_URLS to http://*:7575. On hosted droplets nginx proxies HTTPS to 127.0.0.1:7575, but Kestrel also listens on the public address, and the setup script configures no host firewall. Unless a DigitalOcean cloud firewall blocks it (John, 2026-10-23: every droplet has a restrictive cloud firewall, the same settings confirmed for the Sockeye migration, so hosted droplets are not exposed; re-rated Low), anyone can reach AyaNova directly on port 7575 over plain HTTP, without TLS and without nginx's headers, and logins sent there travel in clear. The standalone (single-PC) install also accepts connections from the whole network. Fix: hosted and Linux server templates bind http://127.0.0.1:7575 when behind a proxy; the setup script enables ufw allowing only 22, 80 and 443; standalone binds to localhost; LAN stays on all interfaces (that is its purpose) and the manual recommends HTTPS through IIS for anything beyond the LAN.

F37 Standalone PostgreSQL trusts local connections (pass 10, from code)

dist/win-x64/postgres-empty-database/pg_hba.conf uses trust for local, 127.0.0.1 and ::1, and listen_addresses is the default (localhost). Any user or program on that PC can connect as any database user, the postgres superuser included, without a password. Standalone is a single-PC (mostly evaluation) install, so the exposure is local only; with F2 the database files are writable by every local user anyway. Fix: scram-sha-256 with a password generated at install and written to the launcher's config.json; or accept it for evaluation installs (John's decision). John, 2026-10-23: accepted for standalone unless the fix is very short and low-risk; the scram change needs a generated password in the installer, the launcher config and a prepared database, so it is not that short: accepted.

F38 Linux hardening (pass 10, from code)

  • The systemd unit written by the setup script runs as www-data with no sandboxing options (NoNewPrivileges, ProtectSystem=strict with ReadWritePaths=/var/ayanova/data, PrivateTmp, ProtectHome).
  • chmod g+rw /var/ayanova/ gives www-data (which nginx also runs as) write access to the program folder itself, so the service account can replace program files by renaming. Only data needs write access.
  • The Linux desktop install runs as root (sudo ./AyaNova, see Deferred), so the report browser, already started without Chrome's sandbox, runs as root too.
  • dist/docker/linux-x64/docker-compose.yml publishes PostgreSQL on port 5432 with a fixed superuser password. It is not built (build-release.bat lines are commented out) or documented; delete it or fix it before anyone uses it.

OK in pass 10: the setup script generates a unique database password and JWT secret per droplet, stores them in /root/ayanova-secrets.txt (600), makes config.json 640 root:www-data, runs AyaNova as www-data, installs unattended upgrades, uses PGDG PostgreSQL on its default localhost listener, and gets a Let's Encrypt certificate with HSTS and the headers in F35.

F41 The 2FA flaw (F5) in Sockeye and the business server (pass 11, from code)

Sockeye is a near-copy of this codebase: sockeye/server/Controllers/AuthController.cs:168 matches the 2FA step on TempToken alone and sets it to string.Empty after success, exactly as F5. Sockeye's login is on the internet (sockeye.ayanova.com) and holds the customer list, licences and subscription servers; the business AyaNova server (8.2.x) has the same code. If an account on either has 2FA turned on, it can be reached with no password and a guessed code. Immediate mitigation (no code change): turn 2FA off on those accounts until the fix is deployed; with 2FA off the account is not matched by this query and the password protects it as usual. Then apply the F5 fix to Sockeye too. John, 2026-10-23: 2FA turned off on the affected accounts; the fix goes in the same case as F5 (one commit per repo). Other shared-code findings apply to Sockeye as well (F7, F8, F12, F19, F21, F30; F1 if its config kept a placeholder); its admin UI is used by John alone, so only the anonymous ones (F5, F41) are urgent there.

F42 Licence-signing private keys in Sockeye source (pass 11, from code)

sockeye/server/biz/RavenKeyFactory.cs holds two RSA private keys as string literals (lines 158 and 222; not reproduced here) and signs licence keys with them. They are therefore in the Sockeye repository and its history (self-hosted Gitea), in every build of Sockeye, and on the Sockeye droplet in the deployed DLL. Anyone who obtains any of those can create licence keys that every AyaNova server accepts. AyaNova checks licences against the matching public key (License.cs, "our real 2016 public key"). Fix: load the private key at run time from a file or environment variable on the Sockeye server only (root-readable, outside the repo and the published files) and remove it from the source. Rotation (new key pair, new public key in AyaNova, every licence reissued) is a business decision for John; without rotation the keys stay in history, so access to the repository and its backups must be treated as access to the keys. John, 2026-10-23: no rotation now; moving the key out of source becomes its own case for later (not an 8.3.0 blocker).

F39 One-shot licence fetch by DB ID (pass 11, from code)

RvfController (POST rvf, anonymous) returns the newest active licence for a DB ID, once: FetchedOn is set on the first fetch and later fetches get 404. DB IDs are random (32 bytes) but not secret: the anonymous API root page shows it while a server is unlicensed (F11) and server-info shows it to every login (F29). Someone who knows it can take a newly issued key first; the real server then never receives it (a support case; the key only works for that DB ID, but it names the licensee and features). Fix: let the same DB ID fetch its key again (the key is bound to the DB ID, so one-shot adds nothing), and stop showing the DB ID anonymously.

F40 Trial request routes (pass 11, from code)

POST rvr (AyaNova trial request) and POST subscription-server-trial-request are anonymous with no rate limit. Each rvr request with a new DB ID stores a request and sends Sockeye's fixed verification email to the address given; the email confirm code (StringUtil.GenFetchCode) is 10 letters from System.Random, not a cryptographic generator. The subscription-server request's only bot check is a fixed value (RValue == "totallylegit"). Approval is manual, so none of this issues a licence; the exposure is email to arbitrary addresses and a cluttered request list. Fix: nginx limit_req on these routes; RandomNumberGenerator for codes; optionally a CAPTCHA on the website forms.

OK in pass 11: no licence is issued without John's approval; a DB ID with a purchased licence cannot request a trial; licence keys are bound to the DB ID and signed (AyaNova verifies the signature).

F43 Unit pick-list variant in SQL (found 2026-10-24 during case 4712, from code)

UnitPickList.GetVariantCriteria takes a customerid:<value> variant from the request and returns aunit.customerid = {v[1]}, the value unparsed, which PickListSqlBuilder adds to the query. The route (pick-list/list, pick-list/single) is open to every login, customer contacts included. Same class as F18 and F26. Correction to pass 6: "Raw SQL: what was checked and found safe" said the pick-list variants were parsed as numbers or matched to fixed strings; that holds for the other six lists with variants but not this one (the pass 6 search looked for {variant} and {crit[...]} interpolations and missed {v[1]}). Fix: parse the value as a long and ignore the variant otherwise, as the other lists do.

F2 Windows folder permissions (from the 8.3.0 installer test)

lan.iss and standalone.iss set Permissions: users-modify on {app} (the program folder) and on {commonappdata}\ayanova (data: database for standalone, backups, attachments, logs). Any local Windows user can therefore replace program files that run as the user who starts AyaNova or as the IIS application pool, and can read the database files and backups. Matters on shared or terminal-server machines. The program-folder permission originally let the report browser download beside the app; since 8.3.0 it downloads under the data folder, so {app} may no longer need it. To confirm in pass 10. Pass 10: confirmed from lan.iss and standalone.iss. config.json, with the database password and the JWT secret, is installed in {app}, so any local user can read those secrets and change the configuration or the program files; standalone also grants users-modify on {app}\local-postgres (the PostgreSQL binaries) and the database folder. Fix: drop users-modify from {app} and local-postgres; keep write access only where the running account needs it (logs, backups, temp, attachments, report browser), granted to that account or to Administrators rather than all Users; give config.json an ACL for Administrators and the service account.

F3 notify/direct-smtp open to every logged-in user

NotifyController.SendNotifySmtpDirectMessage: no role or user-type check; the code comment says this is intentional (Customer form, and API users emailing rendered reports, case 4310). Any logged-in user, customer contacts included, can send an email with any recipient (ToAddress), subject and HTML body through the company's configured SMTP server, from its address. On a hosted or internet-facing server one customer-contact login is enough to send phishing or spam under the company's name. To confirm in pass 8: which callers the client actually has, and the narrowest check that keeps them working (staff roles only; or recipient restricted to the object's own address). Pass 8 (code): confirmed. When ToAddress is given it is used as is (only when it is empty is the address taken from the Customer, HeadOffice, Vendor or User record); subject, text and HTML body come from the request; one recipient per call (MailboxAddress.Parse), no rate limit. The event log records each send. Fix: drop ToAddress from the route and always take the address from the record (the Customer form's use), check read rights on that record, and refuse customer-type users; API users who email rendered reports keep working through a record address.

F4 notify/direct-message open to every logged-in user

Any logged-in user can add a general notification, with the sender's name and any text, for any user id. Low on its own (in-app only); relevant with F3 for social engineering of staff by a customer contact. Pass 8: confirmed from code (NotifyController.SendNotifyDirectMessage, no role check, any ToUserId). Fix: staff only, or only to users the caller may see.

Cases

John's cases for the findings (created 2026-10-24; full list in NET10-PORT.md, "Release 8.3.0 cases"): 4710 (F5, F41), 4711 (F1), 4712 (F13 to F17), 4715 (F21), 4716 (F18), 4717 (F26), 4718 (F19, F24), 4719 (F20), 4720 (F27), 4721 (F33), 4722 (F34), 4723 (F3, F4), 4724 (F30, F31), 4725 (F7, F8, F9), 4726 (F6), 4579 (F2), 4713 (F28), 4727 (F10, F11, F12, F22, F23, F25, F29, F35, F36, F38, and the AyaNova half of F39), Sockeye 4681 (F40). The Sockeye half of F39 (repeatable licence fetch) moved to a later case: AyaNova reinstalls and logs any key it fetches without comparing it with the installed one (trial servers with data would log an E1020 error on every fetch), and deployed older servers cannot be changed, so it needs an AyaNova-side change plus a request flag that Sockeye honours. Not in 8.3.0: F32, F42 (John's later cases); F37 accepted. When a case is done, its findings' status becomes fixed in case NNNN.

Customer contact route allowlist (implemented in case 4712)

The API routes the client can call for a customer contact (UserType Customer or HeadOffice), read from raven-client on 2026-10-23: the customer menu in api/initialize.js (user settings, notification subscriptions, work orders, service requests), the screens behind it (customer-workorders, customer-workorder, customer-csr, customer-csr-edit, home-user-settings, home-password, home-security, home-notify-subscription(s), home-notifications, ay-about, ay-report-view, login, home-reset), and the components they use. Staff-only parts of shared components are left out: data-table extensions (export, batch delete, tags, direct SMTP) are not on customer screens, attachments and wiki are read-only there, and CSR accept/reject are staff menu items. Method used: customer_routes.py (kept outside the repo) follows each screen's imports and gz- components and lists every $gz.api call; the result was then checked by hand.

Everything not on this list would answer 403 to a customer-type user. Ownership says what each id route must also check.

Area Routes Ownership check needed
Login, session (already anonymous or exempt) auth, auth/tfa-authenticate, auth/reset-password, notify/hello, translation/subset[/{id}], logo/{size}, license/lc (F10) —
Own account auth/change-password, auth/totp, auth/totp-validate, auth/totp-disable/{id}, user-option/{id} GET and PUT {id} = the caller (totp-disable already; check user-option)
App shell global-biz-setting/client, enum-list/list/{key}, form-custom/{formkey}, translation/list, translation/subset (logged-in version, used to load translations), server-info, notify/new-count, notify/app-notifications, DELETE notify/{id} notify/{id}: the caller's own notification (pass 7)
Data lists (work orders, CSRs) data-list (server already forces the customer filter), data-list/listfields, data-list-column-view…, data-list-filter…, tag-list/list, pick-list/list from the filter editor saved filters: already scoped to the user
Work order (customer view) workorder/customer/{id}, attachment/list (work order only), attachment/download/{id} (download key), report/render-job with report -100 only, `job-operations/status logs/{gid}, report/request-cancel/{gid}, report/download/{file}`
Service requests csr POST/PUT, csr/{id} GET/DELETE (not accept/reject), unit/customerunit POST, pick-list/list and pick-list/single for Customer (head-office variant), Unit (own customer) and UnitModel only, with the server's own template CSR and unit belong to the contact's customer; CustomerId forced on create: F14, F15
Notification subscriptions notify-subscription/list, notify-subscription/{id} GET/DELETE, notify-subscription POST/PUT, work-order-status/list, quote-status/list (names only, used by the editor) UserId forced to the caller: F17

Confirmed while fixing (case 4712): for customer users the client's open-object handler only navigates to the customer screens, so its staff routes (attachment/parent, form-custom/form-key, user/inside-type, search/ancestor) are not needed; server-info stays open (the About page calls it for everyone; F29 trims what it returns). The list lives in server/AyaNova/ControllerHelpers/CustomerContactRoutes.cs; the token middleware answers 403 to a contact for anything else (also closes F8's suffix exemption for contacts).

Shape of the fix. One check in the token middleware, which already loads UserType for every request: for Customer and HeadOffice users, compare the matched endpoint's route template (context.GetEndpoint(), available because UseRouting runs first) with this allowlist and answer 403 otherwise. New routes then stay closed to contacts unless added on purpose. The ownership checks stay in the routes themselves (F13, F14, F15, F17). F16 (schedule) is closed by the allowlist alone; its staff role check is still needed for staff roles that should not see the schedule (pass 7).

Pass log

Date Pass Result
2026-10-23 1 Threat model and map Scope agreed with John. Middleware, JWT validation, role model, download keys, CORS and Swagger read. Route map generated (460 actions; script kept in security/routemap.py). No route lacks authentication by accident. 53 logged-in routes have no role check in the action: 3 resolved (erase data, trial seed: OK; direct SMTP: F3), the rest assigned to later passes. Local-request exception not reachable through the hosted nginx. Findings F1 to F4 recorded as candidates
2026-10-23 2 Login, tokens, reset, 2FA, anonymous routes Read AuthController, the token middleware, Hasher, download-key check, password reset, TOTP setup, and every anonymous route (notify/hello, license/lc, translation/subset, logo, API root, health). Tested on the local dev server with the seeded Inventory user: F5 critical, confirmed (2FA step without password); F1 confirmed (current token rebuilt from the secret in 6 tries); F8 confirmed. New: F6, F7, F9 to F12. OK: reset codes (32 random characters, 48 h, cleared on use, same reply for unknown codes), login replies do not reveal whether a login exists, roles read from the database on every request, deactivation ends the session, TOTP secret 160 bits. Leads for pass 4: AuthController.GenRpt issues SuperUser (id 1) tokens with an rpl claim for report rendering; pass 5: the logo is served with the content type given at upload
2026-10-23 3 Customer contacts Mapped the Customer role in BizRoles.cs and every customer-reachable route. Tested on the local dev server as seeded contact Customer (customer 1), read-only: F13, F14 (read), F15, F16 confirmed. F17 and F18 from code (F17 not tested because it sends email; F18 not tested by choice). OK: data lists (server sets the customer filter), KPIs (no customer roles), saved filters and notification subscription reads (scoped to the user), memos (no customer access)
2026-10-23 4 Report rendering Read ReportController, ReportBiz (render path, data, browser launch), ay-report.js helpers, CoreJobCustomerNotify and AuthController.GenRpt. Tested on the local dev server: F19 confirmed (contact imported a stock template copy, deleted afterwards). F20, F21 and F22 from code; rendering of a crafted template and the download path were deliberately not tested. OK: report/data and render-job check read rights on the record type; template API calls use the renderer's own token (except notification reports, F20); report output files have random names. Pass 5 picks up F21's other routes
2026-10-23 5 File handling Read ApiUploadProcessor, AttachmentController (upload, list, download, move, delete, maintenance), LogoController, ExportController, TranslationController upload, LogFilesController, BackupController, ImportController, and the path helpers in FileUtil. No live tests (the role gaps have the same shape as F19, already confirmed). New: F23, F24, F25; F21 extended to the log and backup download routes (ops roles), backup delete found safe
2026-10-23 6 Raw SQL Code reading only. Listed every hand-built query (CommandText, *SqlRaw, interpolated SQL) and traced each request value into it: data-list filter, sort and select builders, all 51 ClientCriteria users, saved filters, search tokenizer, every KPI's criteria, pick-list builder and variants, customer notification query, controllers and biz classes with raw SQL. New: F26 (KPI interval). F18 completed by code. Everything else safe (see "Raw SQL: what was checked")
2026-10-23 7 Staff roles Went through the remaining logged-in routes without a role check, the restricted-role filtering in WorkOrderBiz, and user and contact administration in UserBiz. Tested on the local dev server, read-only: F27 confirmed (SubContractorRestricted through report/data). F28 from code (live test not done). New: F29. The rest of the list found OK (see "Pass 7: routes ... found OK")
2026-10-23 8 Email and notifications Code reading only, nothing sent. Read NotifyController (direct SMTP and message), Mailer, GlobalOpsNotificationSettings controller, biz and model, NotifyEventHelper, CoreJobNotify, CoreJobCustomerNotify, NotifySubscriptionBiz. F3 and F4 confirmed by code; F17 confirmed and re-rated Medium (names and links, no reports). New: F30, F31, F32. Action for John: rotate the FastMail app password in F32
2026-10-23 9 Client, headers, CORS, Swagger Code reading of every v-html use in raven-client and its data source; npm audit read-only against the lockfile (no install, no change), triaged to shipped packages; server and nginx headers, CORS, Swagger. New: F33, F34 (needs John's decision on two front-end packages), F35; F14 extended (unit/customerunit)
2026-10-23 10 Deployment Code reading: lan.iss, standalone.iss, the four config.json templates, the standalone PostgreSQL configuration, the hosted setup script in sockeye-client (secrets, permissions, systemd unit, nginx, certbot), dist/docker. F2 confirmed and extended (config.json readable by all users); F9 extended (hosted droplets start with the default SuperUser password). New: F36, F37, F38. John then confirmed hosted droplets have a restrictive cloud firewall (F36 re-rated Low), accepted F37, and explained the trial default password (F9 note added)
2026-10-23 11 Sockeye public routes Code reading of RvfController (licence fetch), RvrController and SubscriptionServerTrialRequestController (trial requests), TrialLicenseRequestBiz, the code generator, Sockeye's AuthController and token middleware, and where licences are signed. New: F41 (F5 in Sockeye and the business server; turn 2FA off until fixed), F42 (signing private keys in source), F39, F40. All 11 passes done