Files
raven/devdocs/modernization/SECURITY-REVIEW.md
John Cardinal 2fca018add Security review: F5, F1 and F18 also fixed in 8.3.0; findings gate the release
John's decision: 8.3.0 is not released until every finding is fixed or
explicitly accepted. The review therefore runs before the release, not after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:58:31 -07:00

453 lines
30 KiB
Markdown

# 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) | Next |
| 5 | File handling: attachments, uploads, exports, logs, backups, restore, translations, logo | |
| 6 | Raw SQL: data lists, saved filters, search, KPI, schedule, pick lists | |
| 7 | Staff roles: the authenticated routes without a role check in the action (list below) | |
| 8 | Email and notifications (`direct-smtp`, templates, delivery) | |
| 9 | Client (XSS, token storage, `npm audit` triage), CORS, headers, Swagger UI | |
| 10 | Deployment: installers, Linux setup script, nginx, systemd, config and data permissions | |
| 11 | Sockeye public routes (licence fetch, trial requests) | |
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*.
| ID | Severity | Status | Title |
|---|---|---|---|
| F5 | **Critical** | **confirmed** | 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) | **confirmed** | Placeholder JWT secret in the LAN/IIS and Linux server config templates |
| F18 | Critical | candidate | Pick-list search text is put into SQL without escaping; any logged-in user can reach it |
| F13 | High | **confirmed** | Customer contacts can open any customer's work order (customer view, customer report, attachments) by id |
| F14 | High | **confirmed** | Customer contacts can read, and by code change, delete and create, any customer's service requests |
| F15 | High | **confirmed** | 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 | High | candidate | Notification subscriptions accept any `UserId`: a contact could subscribe on a staff user's behalf with delivery to its own email |
| F3 | High | candidate | Any logged-in user, customer contacts included, can send arbitrary email through the company's SMTP server |
| F6 | Medium | confirmed | No lockout or rate limit on login or the 2FA step; no password rules |
| F9 | Medium | confirmed | Default SuperUser password, and an anonymous route that says whether it is still set |
| F2 | Medium | candidate | Windows installers give all local users modify rights on the program and data folders |
| 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 | candidate | 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 |
**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.
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.
### F10 Anonymous licence-agreement consent (pass 2)
`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).
### 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.
### F18 Pick-list search text in SQL (pass 3, candidate, code reading only)
`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.
### 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.
### 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).
### 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.
## 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) |