From code: logo upload accepts any content type and is served inline from the app's origin (F23); translation import has no role check (F24); attachment and export gaps (F25). F21 extended to the log and backup download routes; backup delete found safe. Review only, no code changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
604 lines
43 KiB
Markdown
604 lines
43 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) | 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 | Next |
|
|
| 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 |
|
|
| F19 | Critical | **confirmed** (import) | Any logged-in user, customer contacts included, can import report templates, whose JavaScript runs in the server's browser |
|
|
| F21 | Critical | candidate | 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 | 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 |
|
|
| F20 | High | candidate | Report templates receive the renderer's token (SuperUser for customer notification reports) and the report browser can reach any network address |
|
|
| F3 | High | candidate | 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 | confirmed (code) | Translation import has no role check: any login can add translations |
|
|
| 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 |
|
|
| 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 |
|
|
| 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.
|
|
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.
|
|
|
|
|
|
### 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).
|
|
|
|
### 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.
|
|
|
|
## Customer contact route allowlist (draft, for the F13 to F17 fix)
|
|
|
|
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`, `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}` | work order belongs to the contact's customer (or a customer of its head office): **F13**; job and report file belong to the caller (pass 4) |
|
|
| 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** |
|
|
|
|
To confirm while fixing: whether the `open-object-handler` routes (`attachment/parent`,
|
|
`form-custom/form-key`, `user/inside-type`, `search/ancestor`) are used when a customer opens
|
|
a notification; whether `server-info` (about page) must stay open to 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 |
|