239 lines
14 KiB
Markdown
239 lines
14 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 | Next |
|
|
| 3 | Customer contact users: what they can reach and read | |
|
|
| 4 | Report rendering (templates, the server-side browser, report data and download routes) | |
|
|
| 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 |
|
|
|---|---|---|---|
|
|
| F1 | High | candidate | Placeholder JWT secret in the LAN/IIS and Linux server config templates |
|
|
| F2 | Medium | candidate | Windows installers give all local users modify rights on the program and data folders |
|
|
| F3 | High | candidate | Any logged-in user, customer contacts included, can send arbitrary email through the company's SMTP server |
|
|
| F4 | Low | candidate | Any logged-in user can send an in-app notification to any user |
|
|
|
|
### F1 Placeholder JWT secret (from the 8.3.0 installer test)
|
|
|
|
`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: anyone who knows it can mint a token for any user
|
|
id, including SuperUser, as far as the signature goes. What else such a forged token needs
|
|
(the per-user latest-token check, `CurrentAuthToken`) is to be confirmed in pass 2, as it may
|
|
limit this considerably. Hosted droplets are not affected (the setup script generates a
|
|
random secret since `64d8f62`; droplets created earlier: to check). Fix options: generate a
|
|
random secret at install time; refuse to boot with the placeholder; or treat the placeholder
|
|
as unset (random per boot).
|
|
|
|
### 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 |
|