PickListSqlBuilder put the search text and the tag search text into the SQL inside quotes with no escaping, reachable by any login (security review F18); an apostrophe in a search also made the query fail. The builder now writes @plq / @pltag and PickListFetcher adds them as Npgsql parameters (same %text% match; wildcards typed by the user still apply). Changelog: searching selection lists for text with an apostrophe now works. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
912 lines
72 KiB
Markdown
912 lines
72 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 | Done 2026-10-23 |
|
|
| 7 | Staff roles: the authenticated routes without a role check in the action (list below) | Done 2026-10-23 |
|
|
| 8 | Email and notifications (`direct-smtp`, templates, delivery) | Done 2026-10-23 |
|
|
| 9 | Client (XSS, token storage, `npm audit` triage), CORS, headers, Swagger UI | Done 2026-10-23 |
|
|
| 10 | Deployment: installers, Linux setup script, nginx, systemd, config and data permissions | Done 2026-10-23 |
|
|
| 11 | Sockeye public routes (licence fetch, trial requests) | Done 2026-10-23 |
|
|
|
|
The order is by risk to internet-facing servers. Findings from any pass go in **Findings**.
|
|
|
|
## Scope
|
|
|
|
- **In:** the AyaNova server (`raven/server/AyaNova`) and client (`raven-client`); the Windows
|
|
installers and launcher; Linux packages and the hosted subscription server setup script
|
|
(`sockeye-client/src/views/biz-subscription-server.vue`); the configuration templates.
|
|
- **Sockeye:** only its public routes (licence fetch and trial requests). Its admin UI is used
|
|
by John alone (revisit if agents use it later).
|
|
- **Out:** QBI.
|
|
|
|
## Threat model
|
|
|
|
**Deployments**
|
|
|
|
| Deployment | Reachable by | Notes |
|
|
|---|---|---|
|
|
| Hosted subscription droplet (Ubuntu, nginx + Let's Encrypt, `www-data`) | The internet | Most exposed. One company per droplet |
|
|
| Self-hosted Windows IIS | Often the internet | The manual recommends IIS when there is internet access |
|
|
| Self-hosted Linux server | LAN or internet | Customer's own reverse proxy, if any |
|
|
| Windows LAN / Linux desktop | Local network | Runs as the logged-in user (Linux desktop: as root with `sudo ./AyaNova`) |
|
|
| Windows standalone | The PC itself | Mostly evaluation (John) |
|
|
|
|
**Who might attack**
|
|
|
|
1. **Anyone on the internet** (no login): 14 anonymous routes, the static client, Swagger UI,
|
|
health check.
|
|
2. **A customer contact** (customer-user login, roles `Customer`/`CustomerRestricted`):
|
|
should see only its own customer's data. The highest-value question in this review: can
|
|
one customer's login read or change another customer's data, or staff data?
|
|
3. **A staff user with limited roles**: should not exceed them (e.g. a restricted tech
|
|
reaching accounting, ops or user admin).
|
|
4. **Someone holding a leaked token**: a JWT or a download token (`?t=` in URLs, so it can
|
|
land in browser history and proxy logs).
|
|
5. **A local user** on a Windows or Linux machine running AyaNova.
|
|
6. **A report template author**: report templates are JavaScript run in a browser on the
|
|
server, with access to report data and the API.
|
|
|
|
**What is at stake**: customer business data (customers, work orders, invoices,
|
|
attachments), user logins and passwords, the company's email reputation (SMTP), the licence,
|
|
the server itself (code execution, the database, backups), and availability.
|
|
|
|
## Attack-surface map
|
|
|
|
### Authentication and authorisation design
|
|
|
|
- **Login**: `POST auth` returns a JWT (HS256, `jose-jwt`), signed with `AYANOVA_JWT_SECRET`
|
|
(random per boot if not set). Validation (`Startup.cs:255-279`): signature, issuer
|
|
`ayanova.com`, expiry, zero clock skew; audience not checked.
|
|
- **One session per user**: the user's latest token is stored (`CurrentAuthToken`); the
|
|
middleware (`Startup.cs:366-451`) rejects any other valid token with E2004, except on
|
|
`/auth` and `notify/hello`, and except local report-render requests: host `127.0.0.1`,
|
|
`Request.IsLocal()` and an `rpl` claim. Behind the hosted nginx the client address is
|
|
forwarded (`X-Forwarded-For`, `UseForwardedHeaders`), so remote requests are not local.
|
|
The exception skips only the latest-token check, not signature or expiry.
|
|
- **Roles** are read from the database on every request (middleware), not from the token.
|
|
**There is no route-level role attribute and no global "login required" policy**: each
|
|
controller has `[Authorize]` (login only) and each action calls `Authorized.Has…Role(...)`
|
|
itself. A missing call means any logged-in user, customer contacts included.
|
|
- **Download routes** (`?t=`) take a per-user random download key set at login (`DlKey`,
|
|
with expiry, `AuthController.cs:272-276`) instead of a JWT. Some check only that the key
|
|
belongs to some active user (export, report, translation); others resolve the user and
|
|
check rights (attachments, backups, logs). To verify in passes 4 and 5.
|
|
- **CORS**: any origin, any method, any header (`Startup.cs:167-174`). Tokens travel in the
|
|
`Authorization` header, not cookies, which limits the impact; checked in pass 9.
|
|
- **Swagger UI** is public at `/api-docs` with the full API description (pass 9).
|
|
|
|
### Routes
|
|
|
|
Generated with `devdocs/modernization/security/routemap.py` (`python -I routemap.py
|
|
<Controllers folder> <out.tsv>`): **460 actions** in 71 controllers. Regenerate it in later
|
|
passes rather than trusting these counts if the code has changed.
|
|
|
|
| Access | Actions |
|
|
|---|---|
|
|
| Anonymous (`[AllowAnonymous]`) | 14 |
|
|
| Download key only (no attribute; check `?t=`) | 2 (`attachment/download/{id}`, `backup/download/{fileName}`) |
|
|
| Login required (`[Authorize]`) | 444 |
|
|
|
|
Controllers without a class-level `[Authorize]` (`Attachment`, `Backup`, `ServiceBank`) were
|
|
checked: every action has its own `[Authorize]` or is a download-key route; `ServiceBank` is
|
|
entirely commented out.
|
|
|
|
**Anonymous routes** (pass 2 unless noted)
|
|
|
|
| Route | Action |
|
|
|---|---|
|
|
| `GET /` (API root) | Index |
|
|
| `POST auth` | PostCreds (login) |
|
|
| `POST auth/tfa-authenticate` | TfaAuthenticate |
|
|
| `POST auth/reset-password` | ResetPassword |
|
|
| `GET export/download/{fileName}` | DownloadAsync (download key; pass 5) |
|
|
| `GET health` | Get |
|
|
| `POST license/lc` | lc |
|
|
| `GET log-file/download/{logname}` | DownloadLog (download key; pass 5) |
|
|
| `GET logo/{size}` | DownloadLogo |
|
|
| `GET notify/hello` | GetPreLoginPing |
|
|
| `GET report/download/{fileName}` | DownloadAsync (download key; pass 4) |
|
|
| `GET report/export/{id}` | DownloadTemplate (download key; pass 4) |
|
|
| `POST translation/subset/{id}` | SubSet |
|
|
| `GET translation/download/{id}` | DownloadTranslation (download key; pass 5) |
|
|
|
|
**Logged-in routes with no role or user-type check in the action itself** (53, heuristic:
|
|
the check may sit in the business layer, or the route may be harmless, such as lists,
|
|
own settings or own notifications). Each is resolved in pass 7 unless an earlier pass covers
|
|
it: `GET server-info`, `GET role-rights`, `GET build-mode`, `POST auth/change-password`,
|
|
`GET auth/totp`, `POST auth/totp-validate`, `GET authorization-roles/list`,
|
|
`GET/POST/DELETE data-list-column-view…`, `GET data-list/listkeys`, `GET data-list/listfields`,
|
|
`GET/PUT/POST/DELETE data-list-filter…`, `GET enum-list/list/{enumkey}`,
|
|
`GET enum-list/listkeys`, `GET form-field-reference/{key}`, `GET global-biz-setting/client`,
|
|
`GET job-operations/status|progress|logs/{gid}`, `POST job-operations/request-cancel`,
|
|
`POST kpi`, `POST license/permanently-erase-all-data[-keep-tax-codes]`,
|
|
`GET notify/new-count`, `GET notify/app-notifications`, `DELETE notify/{id}`,
|
|
`POST notify/direct-message`, `POST notify/direct-smtp`, `GET notify-subscription/list`,
|
|
`GET pick-list/template/list`, `GET pick-list/template/listfields/{ayaType}`,
|
|
`POST report/data`, `POST report/upload`, `POST schedule/svc`, `POST schedule/user`,
|
|
`POST search`, `GET server-state`, `GET tag-list/list`, `GET tag-list/cloudlist`,
|
|
`GET translation/{id}`, `GET translation/list`, `GET translation/translationkeycoverage`,
|
|
`POST translation/subset`, `POST translation/upload`, `POST trial/seed`, `GET user/amsu`,
|
|
`GET workorder/customer/{id}`.
|
|
|
|
Already resolved from that list in pass 1:
|
|
- `license/permanently-erase-all-data[-keep-tax-codes]`: SuperUser only (user id 1) plus the
|
|
typed confirmation `i understand` (`LicenseController.cs:248-275`). OK.
|
|
- `trial/seed`: only with a trial licence and an empty database (only SuperUser exists then)
|
|
(`TrialController.cs`). OK.
|
|
- `notify/direct-smtp` and `notify/direct-message`: see F3 and F4.
|
|
|
|
**Highest-interest leads for later passes**: `POST search` and `POST kpi` (do they filter by
|
|
role and customer?), `POST report/data` and the report routes (pass 4), `GET
|
|
workorder/customer/{id}` (customer users reaching other customers' work orders?), `POST
|
|
schedule/svc|user`, `GET user/amsu`, `POST report/upload` and `POST translation/upload` (who
|
|
may upload; what the content can do), `job-operations/*` (another user's job logs?).
|
|
|
|
### Other ways in
|
|
|
|
- **Report templates**: user-authored JavaScript and Handlebars run in a headless Chrome on
|
|
the server (`biz/ReportBiz.cs`), with report data, a server-issued token for API calls from
|
|
the template (`rpl` claim), and helpers such as `ayPutToAPI` (pass 4).
|
|
- **Raw SQL**: data-list filters and sorts (`DataList/*`; case 4676 fixed the numeric and role
|
|
values), saved filters, search, KPI, schedules (pass 6).
|
|
- **Files**: attachments (upload, download, move, maintenance), exports, report files and
|
|
templates, translation upload and download, logo, log files, backups and their deletion
|
|
(passes 4 and 5).
|
|
- **Background jobs**: backup (`pg_dump`, fixed in this release to run without a shell),
|
|
notifications, licence fetch (outbound to the licence servers), report rendering, seeding.
|
|
- **Email**: SMTP settings, notification templates, `direct-smtp` (pass 8).
|
|
- **Configuration and secrets**: `config.json` (database password, JWT secret), logs (do
|
|
they contain tokens or passwords?), the boot log's config line (password redacted).
|
|
- **Deployment**: Windows installers (folder permissions), the launcher, the hosted setup
|
|
script, nginx headers, the systemd unit (no hardening options), Linux desktop running as
|
|
root (pass 10).
|
|
|
|
## Findings
|
|
|
|
Severity: **Critical** (remote, no login, or full takeover) · **High** (any login, or one
|
|
customer reaching another's data; or serious abuse of the company's resources) · **Medium**
|
|
(needs a specific role, local access or an unusual setup) · **Low** (hardening, information).
|
|
Status: *candidate* (seen in code, not yet confirmed), *confirmed*, *fixed in case NNNN*,
|
|
*accepted*.
|
|
|
|
**Finding IDs are permanent:** never renumber or reuse them; John's cases refer to them. New findings
|
|
take the next free number; a fixed or accepted finding keeps its ID and changes status.
|
|
|
|
| ID | Severity | Status | Title |
|
|
|---|---|---|---|
|
|
| F41 | **Critical** | **fixed in case 4710** (Sockeye; the business server gets it with 8.3.0) | F5 also in Sockeye (internet-facing admin login) and in the 8.2.x business AyaNova server |
|
|
| F42 | High | confirmed (code) | The licence-signing RSA private keys are hardcoded in Sockeye's source and binaries |
|
|
| F5 | **Critical** | **fixed in case 4710** | 2FA login step accepts an empty or missing temp token: the password is skipped for users with 2FA on |
|
|
| F1 | Critical (installs that kept it) | **fixed in case 4711** | Placeholder JWT secret in the LAN/IIS and Linux server config templates |
|
|
| F19 | Critical | **fixed in case 4718** | Any logged-in user, customer contacts included, can import report templates, whose JavaScript runs in the server's browser |
|
|
| F21 | Critical | **fixed in case 4715** | Download file names from the URL are not checked: `report/download` and `export/download` may serve files outside the temp folder on Windows |
|
|
| F18 | Critical | **fixed in case 4716** | Pick-list search text is put into SQL without escaping; any logged-in user can reach it |
|
|
| F26 | Critical | confirmed (code) | KPI `interval` criterion is put into SQL unchecked; reachable by staff users down to TechRestricted |
|
|
| 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 | Medium | confirmed (code) | Notification subscriptions accept any `UserId`, and object events reach subscribers without a rights check: record names to any email address |
|
|
| F20 | High | candidate | Report templates receive the renderer's token (SuperUser for customer notification reports) and the report browser can reach any network address |
|
|
| F27 | High | **confirmed** | Restricted roles (TechRestricted, SubContractor, SubContractorRestricted) get whole work-order items through report data, rendering and export |
|
|
| F28 | High | candidate | Users with change rights on Customer (Service, Sales, Accounting) can give customer contact logins any role, BizAdmin and OpsAdmin included |
|
|
| F33 | High | confirmed (code) | Client renders some record text as HTML without escaping (search results, work-order address dialog, unit warranty info, dialogs) |
|
|
| F3 | High | confirmed (code) | Any logged-in user, customer contacts included, can send arbitrary email through the company's SMTP server |
|
|
| F23 | Medium | confirmed (code) | Logo upload accepts any content type and the anonymous logo route serves it inline from the app's origin (stored XSS) |
|
|
| F24 | Medium | **fixed in case 4718** | Translation import has no role check: any login can add translations |
|
|
| F30 | Medium | confirmed (code) | SMTP: the mail server's TLS certificate is never checked |
|
|
| F31 | Medium | confirmed (code) | The SMTP password is returned to read-only roles (OpsAdminRestricted, BizAdmin, BizAdminRestricted) |
|
|
| F32 | Medium | confirmed (code) | A real-looking SMTP account and password are in the DEBUG defaults in `raven` and `sockeye` source |
|
|
| F34 | Medium | confirmed | Shipped client libraries behind on security fixes: DOMPurify 2.3.8 (wiki sanitiser), Vuetify 2.6.6 |
|
|
| F6 | Medium | confirmed | No lockout or rate limit on login or the 2FA step; no password rules |
|
|
| F9 | Medium | confirmed | Default SuperUser password (by design for trials), and an anonymous route that says whether it is still set |
|
|
| F36 | Low | confirmed (code) | AyaNova listens on all interfaces on plain HTTP port 7575 in every template; hosted droplets are protected by a cloud firewall |
|
|
| F37 | Low | accepted (John) | Standalone PostgreSQL accepts any local connection without a password (`trust`) |
|
|
| F2 | Medium | confirmed (code) | Windows installers give all local users modify rights on the program and data folders, `config.json` included |
|
|
| F7 | Low | confirmed | Password change or reset does not end existing sessions, and makes the download key non-expiring |
|
|
| F8 | Low | confirmed | Latest-token check skipped for any URL ending in `/auth` or `notify/hello` |
|
|
| F4 | Low | confirmed (code) | Any logged-in user can send an in-app notification to any user |
|
|
| F10 | Low | confirmed | Anyone can record acceptance of the licence agreement (`license/lc`) |
|
|
| F11 | Low | confirmed | API root page shows licensee, version, licence terms (and DB ID when unlicensed) to anyone |
|
|
| F12 | Low | confirmed | Password hashing: PBKDF2-SHA512 with 10,000 iterations |
|
|
| F25 | Low | confirmed (code) | Attachment and export gaps: upload stored before the rights check, "attached by" can be set to anyone, exports not tied to their user |
|
|
| F29 | Low | confirmed (code) | `server-info` gives every login, customer contacts included, the server DB ID and full licence details |
|
|
| F35 | Low | confirmed | No security headers from the server itself (self-hosted), no CSP anywhere, CORS any origin, public Swagger UI, download keys in URLs |
|
|
| F38 | Low | confirmed (code) | Linux hardening: no systemd sandboxing, service user can replace program files, desktop runs as root, unused Docker files with a fixed database password |
|
|
| F39 | Low | confirmed (code) | Licence fetch is one-shot by DB ID, and the DB ID is shown anonymously while unlicensed: someone else can take a new key first |
|
|
| F40 | Low | confirmed (code) | Trial request routes: no rate limit, non-cryptographic verification codes, a fixed bot-check value |
|
|
| F22 | Low | confirmed | Minor report and job route gaps: template export by any download key, job status, logs and cancel by GUID without an owner check, browser without sandbox |
|
|
|
|
**Release decisions.** John, 2026-10-23: **F13 to F17 are urgent and are fixed in 8.3.0**, as
|
|
"next release" cases (case numbers to come). Hosted and self-hosted perpetual installations
|
|
matter equally; how many use customer contact logins is unknown, so every installation is
|
|
assumed to. **F5, F1 and F18 are fixed in 8.3.0 as well** (John, 2026-10-23). John: "I won't
|
|
release until it's secure, all holes must be plugged": 8.3.0 waits for the findings of every
|
|
pass to be fixed or explicitly accepted by John.
|
|
|
|
### F5 2FA step skips the password (pass 2, confirmed on the local server)
|
|
|
|
`AuthController.TfaAuthenticate` (`POST auth/tfa-authenticate`, anonymous) finds the user
|
|
only by `TempToken == pin.TempToken` (plus active, login allowed, 2FA on), then checks the
|
|
6-digit code. The temp token is the only proof that the password was given in step one, but:
|
|
|
|
- after a successful 2FA login it is set to `""` (`AuthController.cs:193`), so sending
|
|
`"tempToken": ""` matches every 2FA user who has completed a 2FA login;
|
|
- a user who has just turned 2FA on has it `null`; sending no temp token matches them;
|
|
- `TempToken` is not required in `TFAPinParam`, and a wrong code does not clear it.
|
|
|
|
So anyone, with no login and no password, can send codes to this route and log in as
|
|
whichever 2FA user the query returns first. TOTP accepts 3 codes at any moment
|
|
(TwoFactorAuth.Net default discrepancy 1), the only throttle is the 3 s failure delay,
|
|
which parallel requests get around, and there is no lockout (F6) and no nginx rate limit on
|
|
hosted droplets. At a few hundred guesses per second that is, on average, well under an
|
|
hour; even at 20 per second, a few hours. Users who turn 2FA on are the more privileged
|
|
ones, so 2FA makes those accounts weaker than a password alone.
|
|
|
|
**Proof** (local dev server, seeded `Inventory` user, 2FA turned off again afterwards):
|
|
2FA just turned on, no password, temp token omitted, correct code: 200 and a token for
|
|
Inventory. After one normal 2FA login, no password, temp token `""`, correct code: 200 and a
|
|
token. Wrong code: 401. (Script kept outside the repo; the test needs the user's code, which
|
|
an attacker guesses.)
|
|
|
|
**Fix** (proposed for 8.3.0): reject a null or empty temp token before the query; clear the
|
|
temp token (set it to null) on success and on every failed code, so each guess needs the
|
|
password again; optionally expire the temp token after a few minutes. Small change in one
|
|
method; client unaffected (it always sends the `tt` it received).
|
|
|
|
### F1 Placeholder JWT secret (from the 8.3.0 installer test; confirmed in pass 2)
|
|
|
|
`dist/assets/lan-install-config.json` and `dist/assets/linux-server/config.json` ship
|
|
`AYANOVA_JWT_SECRET` set to a placeholder that is also printed in the manual. The manual
|
|
tells users to change it (Linux: "optional but recommended"). An installation that keeps it
|
|
signs logins with a publicly known secret.
|
|
|
|
**Pass 2: the latest-token check does not save it.** Tokens are deterministic: the payload is
|
|
only `exp` (login time + 5 days, in seconds), `iss` and `id`, with no random part
|
|
(`AuthController.cs:280-291`); two logins in the same second give identical tokens. So with
|
|
the secret, someone with no login can rebuild a user's *current* token by trying each second
|
|
of the last 5 days: at most 432,000 requests per user, each rejected at once (no delay). It
|
|
works for any user who logged in in the last 5 days, SuperUser included. **Proof**: with the
|
|
local dev secret, a forged token for `Inventory` was accepted after 6 tries and was identical
|
|
to the real one. In addition, routes ending in `/auth` (F8) accept a forged token without
|
|
any search, and behind a reverse proxy on the same machine that does not set
|
|
`X-Forwarded-For`, a forged token with an `rpl` claim and `Host: 127.0.0.1` skips the check
|
|
too (`IsLocalExtension`: remote address equals local address).
|
|
|
|
Hosted droplets set up with the current script are not affected (random secret since
|
|
`64d8f62`); **droplets created before that: to check (John)**. Self-hosted IIS and Linux
|
|
servers that are reachable from the internet and kept the placeholder are fully open.
|
|
|
|
Fix options: treat the placeholder (and any secret shorter than 32 characters) as unset, so
|
|
a random secret is used per boot, and log a warning (users would log in again after each
|
|
restart); or generate a random secret at install time (Windows installer, Linux packages);
|
|
or refuse to boot. Adding a random value (`jti`) to the token is cheap hardening as well.
|
|
|
|
### F6 No lockout or rate limit on login and 2FA (pass 2)
|
|
|
|
`POST auth` and `POST auth/tfa-authenticate` delay 3 s on failure but count nothing: no
|
|
per-user or per-address lockout, and parallel requests are not slowed. Hosted nginx has no
|
|
`limit_req`. There is no password rule beyond "not empty" (seeded trial users use the login
|
|
as password). Fix options: count failures per login and per temp token with a short lockout;
|
|
`limit_req` on `/api/v8/auth` in the hosted nginx template.
|
|
|
|
### F7 Password change does not end sessions; download key loses its expiry (pass 2)
|
|
|
|
`UserBiz.ChangePasswordAsync` (used by change-password and reset-password) does not replace
|
|
`CurrentAuthToken`, so a session someone else holds keeps working up to 5 days after the
|
|
owner changes the password. It also sets `DlKeyExpire = null`, apparently meant to be
|
|
`PasswordResetCodeExpire`; with a null expiry `ValidateDownloadTokenAndReturnUserAsync`
|
|
never treats the key as expired, until the next login replaces it. Setting `AllowLogin` to
|
|
false does not end a session either (only deactivating does). Fix: on password change, set
|
|
`CurrentAuthToken` and `DlKey` to new random values and clear `PasswordResetCodeExpire`.
|
|
|
|
### F8 Latest-token check bypassed by the URL suffix (pass 2, confirmed)
|
|
|
|
The middleware skips the `CurrentAuthToken` comparison when the path ends with `/auth` or
|
|
`notify/hello` (`Startup.cs:409-413`, `EndsWith`), meant for the login and pre-login routes.
|
|
Any route whose last segment is free text can be called with a replaced or revoked token
|
|
(e.g. `tag-list/batch-add/auth`, `form-custom/auth`). Confirmed: a replaced token got E2004
|
|
on `form-field-reference/Customer` and passed the middleware on `form-field-reference/auth`.
|
|
The middleware also does not check `Active`. Impact is small on its own (only those routes,
|
|
with the user's current roles), larger with F1. Fix: compare the exact route
|
|
(`/api/v8/auth`, `/api/v8/notify/hello`), or skip the check by endpoint metadata
|
|
(`[AllowAnonymous]`) instead of by path.
|
|
|
|
### F9 Default SuperUser password, advertised anonymously (pass 2)
|
|
|
|
A new database has SuperUser `superuser` / `l3tm3in` (`PrimeData.cs`). Anonymous
|
|
`GET notify/hello` returns `sudf: true` while that is still the password, and the login form
|
|
then fills it in. Anyone can therefore check an internet-facing server for it in one request
|
|
and log in as SuperUser. Each call also runs a PBKDF2 hash, a cheap way to load the server.
|
|
Whether hosted droplets set their own SuperUser password at setup: to check in pass 10. **Pass 10:** they do not. The setup script generates the database password and JWT secret but not a SuperUser password, so every new hosted droplet starts internet-facing with `l3tm3in` until someone changes it. Fix in the script: generate one, set it once with `AYANOVA_SET_SUPERUSER_PW`, and write it to `/root/ayanova-secrets.txt` with the others. **John, 2026-10-23:** the published password is deliberate while a server is in trial mode, for easy evaluation; once licensed for production it must be changed on first login. Checked: that rule is enforced by the client only (`api/initialize.js` sends the user to the password page when `knownPassword` and the licence status is ActivePurchased); the server still accepts `superuser`/`l3tm3in` on a licensed server (API logins, or a user who navigates away) and `notify/hello` keeps reporting it. Fix that keeps the trial design: on a purchased licence, have the server refuse a login with the default SuperUser credentials (or allow it only to change the password), and stop returning `sudf` outside trial mode.
|
|
Fix options: only return `sudf` from local or LAN addresses, or force a password change on
|
|
first SuperUser login; at least warn in the UI and the manual.
|
|
|
|
### 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). Pass 9: `unit/customerunit` (`UnitBiz.CustomerCreateAsync`) also takes `CustomerId` from the request, so a contact can add units to any customer; same fix (force the contact's customer).
|
|
|
|
### F15 Pick lists expose the whole customer list (pass 3, confirmed)
|
|
|
|
`POST pick-list/list` checks only `Select`, which the `Customer` role has for customers, head
|
|
offices, vendors, users, parts, units, contracts, projects, rates and more. The caller also
|
|
sends its own `template`, which chooses the returned columns. **Proof**: the contact got all
|
|
70 seeded customers with phone and email. `pick-list/single` works the same way. A
|
|
customer's contact can therefore download the company's customer list with contact details.
|
|
Fix: no `Select` for customer roles on these types (keep what the customer UI needs, likely
|
|
units of its own customer) and ignore `template` from customer-type users.
|
|
|
|
### F16 Service schedule open to everyone logged in (pass 3, confirmed)
|
|
|
|
`POST schedule/svc` and `POST schedule/user` have no role check. **Proof**: the contact got
|
|
1,257 schedule items, each "work order number + customer name" with times and the assigned
|
|
technicians. Fix: require read rights on `WorkOrderItemScheduledUser` (or `WorkOrder`) and
|
|
refuse customer-type users. (`schedule/personal` is the caller's own schedule.)
|
|
|
|
### F17 Notification subscription for someone else (pass 3, candidate)
|
|
|
|
`NotifySubscriptionBiz.CreateAsync` and `PutAsync` keep the `UserId` sent by the client, and
|
|
validation checks the event type against *that* user's type, not the caller's. A subscription
|
|
has its own `DeliveryAddress` and can attach a report (`LinkReportId`). So, by code, a
|
|
customer contact could create a subscription with a staff user's id, a staff event type and
|
|
its own email address, and receive staff notifications, possibly with reports attached. Not
|
|
tested live because it sends real email. Fix: force `UserId` to the caller unless the caller
|
|
has user-admin rights. Pass 8 to confirm how delivery checks rights. **Pass 8 (code):** confirmed, and it goes further: `NotifyEventHelper` matches object events (created, modified, deleted, age) to subscriptions by record type only, without checking that the subscriber may read that type, and `CoreJobNotify` sends to the subscription's `DeliveryAddress`. A user notification email carries the record type, its name and a link (no data, no report: report attachments exist only on customer notification subscriptions, which staff create). So a contact, or a staff user without rights on a type, can get a running feed of record names (new customers, work orders, ...) at any address. Re-rated Medium. Fix as above, plus a read-rights check on the subscriber when an object event is matched.
|
|
|
|
### F18 Pick-list search text in SQL (pass 3; code reading completed in pass 6)
|
|
|
|
`PickListSqlBuilder.Build` puts the search text and the tag search text into `like` fragments
|
|
by string interpolation, and no escaping was found on the way from
|
|
`PickListController.PostList` through `PickListBiz.GetPickListAsync`. The route is open to
|
|
every logged-in user, customer contacts included. Data-list text filters escape quotes since
|
|
case 4676; pick lists were not covered. Deliberately not tested live. Fix: pass the search
|
|
texts as SQL parameters (preferred) or escape them as the data-list builder does; review the
|
|
rest of the pick-list builder in pass 6. **Pass 6:** the search text (`autoCompleteQuery`) and the tag search text (`tagSpecificQuery`) are the only free-text inputs of the pick-list builder, and neither is escaped on any path; template fields map to column definitions, ids are `long`, variants are parsed as numbers or matched to fixed strings. Status: confirmed by code.
|
|
|
|
|
|
### F19 Report template import open to every login (pass 4, confirmed)
|
|
|
|
`POST report/upload` (`ReportController.UploadAsync`) has `[Authorize]` only, and
|
|
`ReportBiz.ImportAsync` checks nothing either; creating or editing a template through the
|
|
normal routes needs `Change` on `Report` (BizAdmin, BizAdminRestricted). A template holds
|
|
JavaScript (`JsPrerender`, `JsHelpers`) and a Handlebars template, which `ReportBiz` loads into
|
|
the headless browser on the server at render time. **Confirmed**: the seeded contact
|
|
`Customer` imported a copy of a stock template (202, new report 104, deleted afterwards).
|
|
|
|
Consequences (from code; the rendering side was deliberately not tested):
|
|
- `report/render-job` requires read rights on `Report` (everyone) and on the template's
|
|
record type, which the uploader chooses (a contact can read CSRs), so any login can run
|
|
its own JavaScript in the server's browser: it can reach the local API and any address the
|
|
server can reach (internal network, cloud metadata endpoints) and put the results into the
|
|
PDF it downloads.
|
|
- An imported template also appears in staff report lists; when staff or SuperUser render it,
|
|
it runs with their token (see F20).
|
|
|
|
Fix: in `UploadAsync`, require `HasCreateRole(Report)` like `POST report`; F20's fixes limit
|
|
what a template can do even for legitimate authors.
|
|
|
|
### F20 What a report template can reach (pass 4, candidate)
|
|
|
|
The page gets the renderer's own bearer token in `AYMETA.ayClientMetaData.Authorization` (for
|
|
`ayGetFromAPI`/`ayPostToAPI`/`ayPutToAPI`), and nothing restricts the page's network access
|
|
(no request interception in `ReportBiz`). So a template's script can send the token of
|
|
whoever renders it to any outside address; that token is the user's current session token,
|
|
usable from anywhere. Customer notifications with an attached report
|
|
(`CoreJobCustomerNotify`) render with `AuthController.GenRpt`: a SuperUser (id 1) token with an
|
|
`rpl` claim, accepted from 127.0.0.1 for the render timeout. A template author (including the
|
|
"restricted" `BizAdminRestricted` role) can therefore act as SuperUser, or as any colleague who
|
|
runs the report. Fix options: intercept page requests (PuppeteerSharp
|
|
`SetRequestInterceptionAsync`) and allow only the local API URL and `data:`/`blob:`; render
|
|
notification reports as a dedicated low-rights identity rather than SuperUser; consider a
|
|
short-lived token limited to read routes for template API calls.
|
|
|
|
### F21 File names from the URL in download routes (pass 4, candidate, code reading)
|
|
|
|
`report/download/{fileName}` and `export/download/{fileName}` accept any user's download key
|
|
and serve `FileUtil.GetFullPathForTemporaryFile(fileName)`, which is
|
|
`Path.Combine(TemporaryFilesFolder, fileName)` with no check. On Windows, a backslash in the
|
|
route value is not a separator for routing but is one for `Path.Combine`, and an absolute
|
|
name replaces the folder entirely; the result is passed to `PhysicalFile`. Any login
|
|
(customer contacts included) could then read files outside the temp folder, such as
|
|
`config.json` (database password, JWT secret). Linux is likely not affected (`/` stays encoded
|
|
as `%2F` in route values); IIS request filtering may block some forms. Not tested live by
|
|
choice. The same unchecked pattern is in `log-file/{logname}`, `log-file/download/{logname}`
|
|
and the backup download route (ops roles; pass 5 confirmed: `LogFilesController` `GetLog` and `DownloadLog` need `LogFile` read, i.e. OpsAdmin or OpsAdminRestricted; `BackupController.DownloadAsync` needs `Backup` change, i.e. OpsAdmin; backup *delete* is safe, `FileUtil.EraseBackupFile` already strips directories with `Path.GetFileName`, the pattern the other routes should share). Fix: accept only a bare file
|
|
name (`Path.GetFileName(name) == name`, no `..`), and check the resolved full path is inside
|
|
the folder, in one helper used by every such route.
|
|
|
|
### F22 Minor report and job gaps (pass 4)
|
|
|
|
`report/export/{id}` gives any report template to any valid download key (templates are not
|
|
secret, but staff-only templates go to customer contacts). `job-operations/status|progress|
|
|
logs/{gid}` and `report/request-cancel/{gid}` check no owner; job ids are random GUIDs, so
|
|
this needs a leaked id. The browser runs with `--no-sandbox` (Chrome's own sandbox off), so a
|
|
browser bug reached through a template is not contained; Linux runs as `www-data`, Windows LAN
|
|
as the logged-in user. Fix: owner check on job routes; consider enabling the sandbox where
|
|
the platform allows.
|
|
|
|
### F23 Logo upload: any content type, served inline (pass 5, from code)
|
|
|
|
`LogoController.UploadAsync` stores the bytes and the content type the browser sent
|
|
(`file.ContentType`), with no check of either; `DownloadLogo` (anonymous, `GET logo/{size}`)
|
|
returns them inline with that type from the AyaNova origin. A file uploaded as HTML (or SVG
|
|
with script) therefore runs as a page of the AyaNova site when someone opens its URL, and the
|
|
client keeps its session in `localStorage` (`vuex-persistedstate`, key `AyaNova`), which such a
|
|
page can read. Upload and delete check `HasReadFullRole(Global)`: BizAdmin, and
|
|
BizAdminRestricted, whose rights on Global are meant to be read-only. Fix: require
|
|
`HasModifyRole(Global)`; accept only PNG, JPEG, GIF, WebP (check the bytes, not the header;
|
|
no SVG); serve with `X-Content-Type-Options: nosniff` and a restrictive
|
|
`Content-Security-Policy` (pass 9 looks at headers generally).
|
|
|
|
### F24 Translation import without a role check (pass 5, from code)
|
|
|
|
`TranslationController.UploadAsync` (`POST translation/upload`) and `TranslationBiz.ImportAsync`
|
|
check no role, the same shape as F19. Any login, customer contacts included, can add
|
|
translations (up to 15 MiB each; imported as non-stock under a unique name). They affect
|
|
only users who select them, unless an administrator makes one the default; whether
|
|
translated text is ever rendered as HTML in the client is a pass 9 question. Fix: require
|
|
`HasCreateRole(Translation)`, like the duplicate route.
|
|
|
|
### F25 Attachment and export gaps (pass 5, from code)
|
|
|
|
- `AttachmentController.UploadAsync` streams the whole upload to disk (limit 10 GiB) before
|
|
it checks rights on the target record, then deletes it on 403: any login can make the
|
|
server write large files repeatedly. Check rights before reading the body, or lower the
|
|
limit for users without rights.
|
|
- The form field `OverrideUserId` is accepted from anyone and recorded as "attached by":
|
|
limit it to the import path that needs it, or to SuperUser.
|
|
- `export/download/{fileName}` accepts any user's download key; the name is
|
|
`{type}-{yyyyMMddHHmmssfff}.json` and files are deleted after about 15 minutes, so guessing
|
|
another user's export is impractical, but the route should still check that the caller
|
|
made it.
|
|
- Customer contacts can attach files to any customer's service request (part of F14; fixed
|
|
by the same ownership check).
|
|
|
|
OK in this pass: attachments are served as downloads (`Content-Disposition: attachment`), stored
|
|
under content-hash names in the attachments folder; batch move, batch delete and maintenance
|
|
check rights; data import (`POST import`) checks create rights per type; there is no restore
|
|
route (restore is a manual `pg_restore`, see the manual).
|
|
|
|
### F26 KPI `interval` in SQL (pass 6, from code)
|
|
|
|
Six KPIs (`WorkOrderCreatedCount`, `WorkOrderStatusCount`, `WorkOrderStatusPct`,
|
|
`WorkOrderCompletedOnTimePct`, `WorkOrderItemLaborQuantitySummary`,
|
|
`WorkOrderItemLaborQuantityEveryoneSummary`) read `interval` from the request
|
|
(`options.Criteria["interval"]`, a string) and put it into the query as
|
|
`DATE_TRUNC('{interval}', ...)` with no check. `POST kpi` has no role check of its own;
|
|
`KPIFetcher` checks the KPI's `AllowedRoles`, which for the labour summary are Tech and
|
|
TechRestricted, so the least privileged staff roles reach it (customer roles do not). A value
|
|
that leaves the quotes changes the query, which can read or change anything in the database
|
|
(password hashes, roles, licence). Fix: accept only the values the client sends (`day`, `week`,
|
|
`month`, `quarter`, `year`; check `raven-client` for the exact list) and reject anything else,
|
|
in one helper used by all six. The other KPI criteria are safe: `timeSpan` goes through the
|
|
data-list date builder (fixed tokens, otherwise `DateTime.Parse`), tags through the tag
|
|
helper (escaped), `wostatus` and `userid` are integers.
|
|
|
|
### Raw SQL: what was checked and found safe (pass 6)
|
|
|
|
- **Data-list filters** (`DataListSqlFilterCriteriaBuilder`): column keys are looked up in the
|
|
list's field definitions (unknown keys throw); text, phone, email and URL values have quotes
|
|
doubled; numbers, roles and ids are parsed (case 4676); bools become `true`/`false`; dates
|
|
and relative-date tokens are parsed; durations are rebuilt from parsed integers; tags are
|
|
normalised and quote-doubled.
|
|
- **Data-list sort and columns**: keys looked up in the field definitions; direction becomes
|
|
`ASC`/`DESC`.
|
|
- **`ClientCriteria`**: every list that reads it (51) turns it into a typed filter option, so it
|
|
goes through the filter builder above; saved filters too.
|
|
- **Search**: the word breaker keeps only letters, digits and `%`, so terms cannot contain a
|
|
quote. Safe by construction, but fragile: parameters would not depend on the tokenizer.
|
|
- **Customer notification "who" query, schedule, attachments list, name fetcher, unit and
|
|
customer queries, accounting list, server metrics, job and sweeper queries**: typed values
|
|
or fixed SQL; schedule uses EF.
|
|
- `DbUtil` and `AySchema` build SQL from configuration (database name), not from requests.
|
|
|
|
Hardening, not a finding: hand-built SQL with string escaping is spread over the builders.
|
|
Moving free-text values to `NpgsqlParameter`s (pick lists, data-list text filters, search,
|
|
tags) would remove the whole class of bug; at least F18 and F26 should use parameters or an
|
|
allowlist rather than escaping.
|
|
|
|
### F27 Restricted roles bypass work-order item filtering (pass 7, confirmed)
|
|
|
|
`WorkOrderBiz.WorkOrderGetAsync` removes, for TechRestricted, SubContractor and
|
|
SubContractorRestricted users, every item they are not scheduled on, other users' labour,
|
|
travel and scheduling, and (by role) parts, units, expenses, loans and outside services.
|
|
`GetReportData` (also used by `GetExportData`) calls `WorkOrderGetPartialAsync`, which applies
|
|
that filtering only when the requested type is `WorkOrder`; for the child types (item,
|
|
labour, expense, part, ...) it loads the parent header and the whole item, with every
|
|
descendant when `IncludeWoItemDescendants` is set by the caller, and filters nothing. These
|
|
roles have change (so read) rights on the child types they work with, so `report/data`,
|
|
`report/render-job` and `export/render` accept them. **Proof**: on work order 1, seeded
|
|
`SubContractorRestricted` got 0 items from `workorder/1` (correct, not scheduled), and from
|
|
`report/data` with a labour id and descendants on got the item with another user's labour,
|
|
the scheduling and 2 parts (a role that should see no parts at all). Fix: apply the same
|
|
restricted-user filtering in `WorkOrderGetPartialAsync` (one shared method used by both
|
|
paths), and refuse child-type report and export requests for items the user is not scheduled
|
|
on. Check `QuoteBiz` and `PMBiz` for the same split (they also reference restricted users).
|
|
|
|
### F28 Contact logins with staff roles (pass 7, candidate, code reading)
|
|
|
|
`UserBiz` lets anyone with modify (create) rights on `Customer` create and edit customer contact
|
|
users (`IsOutsideCustomerContactTypeUser`), and `BizRoles` gives `Customer` change rights to
|
|
Service, Sales and Accounting. Changing a user between inside and outside types needs rights
|
|
on both User and Customer (`UserBiz.cs:803`), but nothing limits the **roles** a contact user may
|
|
hold: `ValidateAsync` checks only that the roles value is valid. Authorisation is by role
|
|
everywhere, so a contact with BizAdmin or OpsAdmin roles is an administrator. A Sales, Service
|
|
or Accounting user could therefore create a contact login with administrator roles and a
|
|
password of their choosing. Not tested live. The F13 to F17 allowlist would limit what a
|
|
customer-type login can call, but the roles must still be constrained. Fix: in
|
|
`UserBiz.ValidateAsync`, allow only `Customer` and `CustomerRestricted` roles on Customer and
|
|
HeadOffice users (and only inside roles on inside users); reject anything else on create and
|
|
update.
|
|
|
|
### F29 `server-info` to every login (pass 7)
|
|
|
|
`GET server-info` has no role check and returns the version, schema, server time zone, the
|
|
server DB ID and the licence as JSON, to any login including customer contacts. The about
|
|
page needs some of it; contacts need little or none. Whether the DB ID matters to Sockeye
|
|
(licence fetch, trial requests) is checked in pass 11. Fix: return the DB ID and licence only
|
|
to staff, or only to ops and admin roles.
|
|
|
|
### Pass 7: routes without a role check that were found OK
|
|
|
|
`DELETE notify/{id}` (owner checked), `notify/new-count`, `notify/app-notifications`,
|
|
`notify-subscription/list` (the caller's own), data-list column views (validation refuses
|
|
another user's id), saved filters (own or public), `user-option/{id}` (own unless admin),
|
|
`auth/change-password`, `auth/totp*` (own), `POST kpi` (each KPI checks its own roles; but see
|
|
F26), `POST search` (results filtered by read rights per type), `GET user/amsu` (returns only
|
|
whether the caller is SuperUser), enum lists, form field references, translations (read),
|
|
tag lists, role lists, build mode, server state, `global-biz-setting/client` (display
|
|
settings only). Already covered elsewhere: `job-operations/*` (F22), `schedule/*` (F16),
|
|
`workorder/customer/{id}` (F13), `report/upload` (F19), `translation/upload` (F24),
|
|
`notify/direct-smtp` and `direct-message` (F3, F4).
|
|
|
|
### F30 SMTP certificate not checked (pass 8, from code)
|
|
|
|
`Mailer.SendEmailAsync` sets `client.ServerCertificateValidationCallback = (s, c, h, e) => true`,
|
|
so any certificate is accepted. Anyone who can intercept the connection (a hostile network,
|
|
DNS) can pose as the mail server and collect the SMTP account and password and every message.
|
|
Also, with security set to none, MailKit's `ConnectAsync(host, port, false)` uses STARTTLS only
|
|
if offered, which an attacker can suppress. Fix: remove the callback (validate normally); if a
|
|
customer needs a self-signed mail server, make that an explicit setting.
|
|
|
|
### F31 SMTP password returned to read-only roles (pass 8, from code)
|
|
|
|
`GET global-ops-notification-setting` returns the whole settings object, `SmtpPassword` included,
|
|
to every role with read rights on `OpsNotificationSettings`: OpsAdminRestricted, BizAdmin and
|
|
BizAdminRestricted, while only OpsAdmin may change it. Those users can then use the company's
|
|
mail account directly. Fix: never send the password to the client (return a "set" flag; keep the
|
|
stored value when the PUT leaves it empty).
|
|
|
|
### F32 SMTP credentials in source (pass 8)
|
|
|
|
`models/GlobalOpsNotificationSettings.cs`, in the `#if (DEBUG)` constructor, sets a FastMail SMTP
|
|
server, account and password; the same file is in `sockeye/server/models`. Release builds use
|
|
placeholders, so shipped software is not affected, but the credentials are in both repositories
|
|
and their history (contrary to the "secrets never go in a repo" rule), and debug builds send real
|
|
email through that account (which explains report 81's real bounces in testing). Fix: rotate that
|
|
FastMail app password now (John); remove the values from both files and read debug SMTP settings
|
|
from an environment variable or user secrets. History need not be rewritten once the password is
|
|
rotated. **John, 2026-10-23:** rotation planned once the old Sockeye server is retired after the
|
|
migration; its own case.
|
|
|
|
OK in pass 8: SMTP settings change and the test-send route are OpsAdmin only; batch direct SMTP
|
|
(`job-operations/batch-direct-smtp`) checks modify rights on the record type and sends only to the
|
|
records' own addresses; notification emails are plain text built from record names (no HTML
|
|
injection); the customer notification "who" query escapes tags.
|
|
|
|
### F33 Unescaped record text rendered as HTML in the client (pass 9, from code)
|
|
|
|
Of the 13 `v-html` uses in `raven-client`, these render text that users enter, without
|
|
escaping it first:
|
|
- `views/home-search.vue`: the search result excerpt (`item.info`, from `Search.GetInfoAsync`,
|
|
which does not encode it), with the search terms highlighted. Searchable text includes
|
|
fields customer contacts can write (service requests, units).
|
|
- `components/work-order-address.vue`: addresses built by `formatAddress` from customer, head
|
|
office and unit address fields.
|
|
- `components/work-order-item-units.vue`, `quote-item-units.vue`, `pm-item-units.vue`: the
|
|
warranty summary (vendor name, receipt number, work order list).
|
|
- `components/gzconfirm.vue`: dialog messages, which are translation strings; with F24 any login
|
|
can import a translation.
|
|
|
|
The session token is kept in `localStorage` (`vuex-persistedstate`), so script running in the
|
|
page could act as the signed-in user. Fix: escape these values (an `escapeHtml` helper, or
|
|
build the markup with Vue templates instead of strings); encode the excerpt on the server too;
|
|
render translation strings as text. Safe: the wiki (`marked` then DOMPurify, see F34), the
|
|
calendar summaries (Vuetify escapes event names), and the remaining sinks (fixed strings).
|
|
A Content-Security-Policy (F35) would be a second layer.
|
|
|
|
### F34 Client libraries behind on security fixes (pass 9)
|
|
|
|
`npm audit` (read-only, lockfile) reports 550 advisories, almost all in build tools (webpack,
|
|
Vue CLI, Babel, ESLint) that run only on the build machine and do not ship. Of the packages
|
|
the app ships: **DOMPurify 2.3.8**, the wiki sanitiser, has sanitiser-bypass fixes in 2.4.x and
|
|
2.5.x; **Vuetify 2.6.6** has XSS fixes in 2.6.10 (the last 2.x is 2.7.x); `marked` 1.2.9, `luxon`
|
|
1.28.0 and `vue` 2.6.14 have only slow-regex (browser-side) advisories. Fix: raise DOMPurify to
|
|
the latest 2.5.x and Vuetify to the latest 2.7.x, both on the same major version. This is a
|
|
front-end dependency change, which CLAUDE.md rules out without John's decision; check that
|
|
both still build with Node 12.22.9. **John, 2026-10-23: both upgrades approved for 8.3.0**, provided they do not conflict with the Node 12.22.9 pin or break the client. Checked (read-only `npm view`): DOMPurify 2.5.9 is the latest 2.x, no engine limit, same API; Vuetify 2.7.2 (latest 2.x, peer `vue ^2.6.4`) is the target, with **2.6.16** (last 2.6, has the 2.6.10 fix) as the fallback if webpack 4 on Node 12 cannot build 2.7. Prove by a full client build, the lint, and a smoke test of wiki, calendar and date pickers, then Cypress on `devtestn10`; `package.json` and `package-lock.json` change in their own commit.
|
|
|
|
### F35 Headers, CORS, Swagger, keys in URLs (pass 9)
|
|
|
|
- The server sets no security headers. Hosted droplets get HSTS, `nosniff`, `X-Frame-Options`
|
|
and `Referrer-Policy` from the nginx template in the setup script; self-hosted IIS, Linux and
|
|
Windows installs get none. No deployment sets a Content-Security-Policy.
|
|
- CORS allows any origin, method and header. Impact is small because the token travels in the
|
|
`Authorization` header (not a cookie), so another site cannot use a visitor's session.
|
|
- Swagger UI and the full API description are public at `/api-docs`.
|
|
- Download keys (`?t=`) are in URLs, so they reach proxy access logs and browser history;
|
|
they last 5 days (F7 makes some last longer).
|
|
|
|
Fix: add the headers in the app (so every deployment gets them), with a CSP that allows the
|
|
client's own scripts; limit CORS to the server's own origin unless API users need more;
|
|
consider Swagger UI for logged-in users only.
|
|
|
|
### F36 Listening on all interfaces, no firewall (pass 10, from code)
|
|
|
|
Every `config.json` template (`lan-install-config.json`, `linux-server`, `linux-desktop`, the
|
|
standalone launcher's) sets `AYANOVA_USE_URLS` to `http://*:7575`. On hosted droplets nginx
|
|
proxies HTTPS to `127.0.0.1:7575`, but Kestrel also listens on the public address, and the setup
|
|
script configures no host firewall. Unless a DigitalOcean cloud firewall blocks it (**John, 2026-10-23: every droplet has a restrictive
|
|
cloud firewall**, the same settings confirmed for the Sockeye migration, so hosted droplets are
|
|
not exposed; re-rated Low), anyone can reach AyaNova directly on port 7575 over plain HTTP, without TLS and
|
|
without nginx's headers, and logins sent there travel in clear. The standalone (single-PC)
|
|
install also accepts connections from the whole network. Fix: hosted and Linux server
|
|
templates bind `http://127.0.0.1:7575` when behind a proxy; the setup script enables `ufw`
|
|
allowing only 22, 80 and 443; standalone binds to localhost; LAN stays on all interfaces (that
|
|
is its purpose) and the manual recommends HTTPS through IIS for anything beyond the LAN.
|
|
|
|
### F37 Standalone PostgreSQL trusts local connections (pass 10, from code)
|
|
|
|
`dist/win-x64/postgres-empty-database/pg_hba.conf` uses `trust` for local, 127.0.0.1 and ::1, and
|
|
`listen_addresses` is the default (localhost). Any user or program on that PC can connect as any
|
|
database user, the `postgres` superuser included, without a password. Standalone is a
|
|
single-PC (mostly evaluation) install, so the exposure is local only; with F2 the database
|
|
files are writable by every local user anyway. Fix: `scram-sha-256` with a password generated
|
|
at install and written to the launcher's `config.json`; or accept it for evaluation installs
|
|
(John's decision). **John, 2026-10-23: accepted** for standalone unless the fix is very short and
|
|
low-risk; the scram change needs a generated password in the installer, the launcher config and a
|
|
prepared database, so it is not that short: accepted.
|
|
|
|
### F38 Linux hardening (pass 10, from code)
|
|
|
|
- The systemd unit written by the setup script runs as `www-data` with no sandboxing options
|
|
(`NoNewPrivileges`, `ProtectSystem=strict` with `ReadWritePaths=/var/ayanova/data`,
|
|
`PrivateTmp`, `ProtectHome`).
|
|
- `chmod g+rw /var/ayanova/` gives `www-data` (which nginx also runs as) write access to the
|
|
program folder itself, so the service account can replace program files by renaming. Only
|
|
`data` needs write access.
|
|
- The Linux desktop install runs as root (`sudo ./AyaNova`, see Deferred), so the report browser,
|
|
already started without Chrome's sandbox, runs as root too.
|
|
- `dist/docker/linux-x64/docker-compose.yml` publishes PostgreSQL on port 5432 with a fixed
|
|
superuser password. It is not built (`build-release.bat` lines are commented out) or
|
|
documented; delete it or fix it before anyone uses it.
|
|
|
|
OK in pass 10: the setup script generates a unique database password and JWT secret per
|
|
droplet, stores them in `/root/ayanova-secrets.txt` (600), makes `config.json` 640 `root:www-data`,
|
|
runs AyaNova as `www-data`, installs unattended upgrades, uses PGDG PostgreSQL on its default
|
|
localhost listener, and gets a Let's Encrypt certificate with HSTS and the headers in F35.
|
|
|
|
### F41 The 2FA flaw (F5) in Sockeye and the business server (pass 11, from code)
|
|
|
|
Sockeye is a near-copy of this codebase: `sockeye/server/Controllers/AuthController.cs:168` matches
|
|
the 2FA step on `TempToken` alone and sets it to `string.Empty` after success, exactly as F5.
|
|
Sockeye's login is on the internet (sockeye.ayanova.com) and holds the customer list, licences
|
|
and subscription servers; the business AyaNova server (8.2.x) has the same code. If an account on
|
|
either has 2FA turned on, it can be reached with no password and a guessed code. **Immediate
|
|
mitigation (no code change):** turn 2FA off on those accounts until the fix is deployed; with 2FA
|
|
off the account is not matched by this query and the password protects it as usual. Then apply
|
|
the F5 fix to Sockeye too. **John, 2026-10-23: 2FA turned off** on the affected accounts; the fix goes
|
|
in the same case as F5 (one commit per repo). Other shared-code findings apply to Sockeye as well (F7, F8, F12, F19,
|
|
F21, F30; F1 if its config kept a placeholder); its admin UI is used by John alone, so only the
|
|
anonymous ones (F5, F41) are urgent there.
|
|
|
|
### F42 Licence-signing private keys in Sockeye source (pass 11, from code)
|
|
|
|
`sockeye/server/biz/RavenKeyFactory.cs` holds two RSA private keys as string literals (lines 158
|
|
and 222; not reproduced here) and signs licence keys with them. They are therefore in the Sockeye
|
|
repository and its history (self-hosted Gitea), in every build of Sockeye, and on the Sockeye
|
|
droplet in the deployed DLL. Anyone who obtains any of those can create licence keys that every
|
|
AyaNova server accepts. AyaNova checks licences against the matching public key
|
|
(`License.cs`, "our real 2016 public key"). Fix: load the private key at run time from a file or
|
|
environment variable on the Sockeye server only (root-readable, outside the repo and the
|
|
published files) and remove it from the source. Rotation (new key pair, new public key in
|
|
AyaNova, every licence reissued) is a business decision for John; without rotation the keys stay
|
|
in history, so access to the repository and its backups must be treated as access to the keys.
|
|
**John, 2026-10-23:** no rotation now; moving the key out of source becomes its own case for later
|
|
(not an 8.3.0 blocker).
|
|
|
|
### F39 One-shot licence fetch by DB ID (pass 11, from code)
|
|
|
|
`RvfController` (`POST rvf`, anonymous) returns the newest active licence for a DB ID, once:
|
|
`FetchedOn` is set on the first fetch and later fetches get 404. DB IDs are random (32 bytes) but
|
|
not secret: the anonymous API root page shows it while a server is unlicensed (F11) and
|
|
`server-info` shows it to every login (F29). Someone who knows it can take a newly issued key
|
|
first; the real server then never receives it (a support case; the key only works for that DB
|
|
ID, but it names the licensee and features). Fix: let the same DB ID fetch its key again (the key
|
|
is bound to the DB ID, so one-shot adds nothing), and stop showing the DB ID anonymously.
|
|
|
|
### F40 Trial request routes (pass 11, from code)
|
|
|
|
`POST rvr` (AyaNova trial request) and `POST subscription-server-trial-request` are anonymous with no
|
|
rate limit. Each `rvr` request with a new DB ID stores a request and sends Sockeye's fixed
|
|
verification email to the address given; the email confirm code (`StringUtil.GenFetchCode`) is
|
|
10 letters from `System.Random`, not a cryptographic generator. The subscription-server request's
|
|
only bot check is a fixed value (`RValue == "totallylegit"`). Approval is manual, so none of this
|
|
issues a licence; the exposure is email to arbitrary addresses and a cluttered request list.
|
|
Fix: nginx `limit_req` on these routes; `RandomNumberGenerator` for codes; optionally a CAPTCHA
|
|
on the website forms.
|
|
|
|
OK in pass 11: no licence is issued without John's approval; a DB ID with a purchased licence
|
|
cannot request a trial; licence keys are bound to the DB ID and signed (AyaNova verifies the
|
|
signature).
|
|
|
|
### F2 Windows folder permissions (from the 8.3.0 installer test)
|
|
|
|
`lan.iss` and `standalone.iss` set `Permissions: users-modify` on `{app}` (the program
|
|
folder) and on `{commonappdata}\ayanova` (data: database for standalone, backups,
|
|
attachments, logs). Any local Windows user can therefore replace program files that run as
|
|
the user who starts AyaNova or as the IIS application pool, and can read the database files
|
|
and backups. Matters on shared or terminal-server machines. The program-folder permission
|
|
originally let the report browser download beside the app; since 8.3.0 it downloads under
|
|
the data folder, so `{app}` may no longer need it. To confirm in pass 10. **Pass 10:** confirmed from `lan.iss` and `standalone.iss`. `config.json`, with the database password and the JWT secret, is installed in `{app}`, so any local user can read those secrets and change the configuration or the program files; standalone also grants `users-modify` on `{app}\local-postgres` (the PostgreSQL binaries) and the database folder. Fix: drop `users-modify` from `{app}` and `local-postgres`; keep write access only where the running account needs it (logs, backups, temp, attachments, report browser), granted to that account or to Administrators rather than all Users; give `config.json` an ACL for Administrators and the service account.
|
|
|
|
### F3 `notify/direct-smtp` open to every logged-in user
|
|
|
|
`NotifyController.SendNotifySmtpDirectMessage`: no role or user-type check; the code comment
|
|
says this is intentional (Customer form, and API users emailing rendered reports, case 4310).
|
|
Any logged-in user, customer contacts included, can send an email with any recipient
|
|
(`ToAddress`), subject and HTML body through the company's configured SMTP server, from its
|
|
address. On a hosted or internet-facing server one customer-contact login is enough to send
|
|
phishing or spam under the company's name. To confirm in pass 8: which callers the client
|
|
actually has, and the narrowest check that keeps them working (staff roles only; or
|
|
recipient restricted to the object's own address). **Pass 8 (code):** confirmed. When `ToAddress` is given it is used as is (only when it is empty is the address taken from the Customer, HeadOffice, Vendor or User record); subject, text and HTML body come from the request; one recipient per call (`MailboxAddress.Parse`), no rate limit. The event log records each send. Fix: drop `ToAddress` from the route and always take the address from the record (the Customer form's use), check read rights on that record, and refuse customer-type users; API users who email rendered reports keep working through a record address.
|
|
|
|
### F4 `notify/direct-message` open to every logged-in user
|
|
|
|
Any logged-in user can add a general notification, with the sender's name and any text, for
|
|
any user id. Low on its own (in-app only); relevant with F3 for social engineering of staff
|
|
by a customer contact. Pass 8: confirmed from code (`NotifyController.SendNotifyDirectMessage`, no role check, any `ToUserId`). Fix: staff only, or only to users the caller may see.
|
|
|
|
## Cases
|
|
|
|
John's cases for the findings (created 2026-10-24; full list in `NET10-PORT.md`, "Release 8.3.0
|
|
cases"): 4710 (F5, F41), 4711 (F1), 4712 (F13 to F17), 4715 (F21), 4716 (F18), 4717 (F26),
|
|
4718 (F19, F24), 4719 (F20), 4720 (F27), 4721 (F33), 4722 (F34), 4723 (F3, F4), 4724 (F30, F31),
|
|
4725 (F7, F8, F9), 4726 (F6), 4579 (F2), 4713 (F28), 4727 (F10, F11, F12, F22, F23, F25, F29, F35, F36, F38, and
|
|
the AyaNova half of F39), Sockeye 4681 (F40 and the Sockeye half of F39). Not in 8.3.0: F32, F42 (John's later cases); F37 accepted. When a
|
|
case is done, its findings' status becomes *fixed in case NNNN*.
|
|
|
|
## Customer contact route allowlist (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 |
|
|
| 2026-10-23 | 6 Raw SQL | Code reading only. Listed every hand-built query (`CommandText`, `*SqlRaw`, interpolated SQL) and traced each request value into it: data-list filter, sort and select builders, all 51 `ClientCriteria` users, saved filters, search tokenizer, every KPI's criteria, pick-list builder and variants, customer notification query, controllers and biz classes with raw SQL. New: **F26** (KPI `interval`). F18 completed by code. Everything else safe (see "Raw SQL: what was checked") |
|
|
| 2026-10-23 | 7 Staff roles | Went through the remaining logged-in routes without a role check, the restricted-role filtering in `WorkOrderBiz`, and user and contact administration in `UserBiz`. Tested on the local dev server, read-only: **F27 confirmed** (`SubContractorRestricted` through `report/data`). F28 from code (live test not done). New: F29. The rest of the list found OK (see "Pass 7: routes ... found OK") |
|
|
| 2026-10-23 | 8 Email and notifications | Code reading only, nothing sent. Read `NotifyController` (direct SMTP and message), `Mailer`, `GlobalOpsNotificationSettings` controller, biz and model, `NotifyEventHelper`, `CoreJobNotify`, `CoreJobCustomerNotify`, `NotifySubscriptionBiz`. F3 and F4 confirmed by code; F17 confirmed and re-rated Medium (names and links, no reports). New: F30, F31, F32. **Action for John: rotate the FastMail app password in F32** |
|
|
| 2026-10-23 | 9 Client, headers, CORS, Swagger | Code reading of every `v-html` use in `raven-client` and its data source; `npm audit` read-only against the lockfile (no install, no change), triaged to shipped packages; server and nginx headers, CORS, Swagger. New: F33, F34 (needs John's decision on two front-end packages), F35; F14 extended (`unit/customerunit`) |
|
|
| 2026-10-23 | 10 Deployment | Code reading: `lan.iss`, `standalone.iss`, the four `config.json` templates, the standalone PostgreSQL configuration, the hosted setup script in `sockeye-client` (secrets, permissions, systemd unit, nginx, certbot), `dist/docker`. F2 confirmed and extended (`config.json` readable by all users); F9 extended (hosted droplets start with the default SuperUser password). New: F36, F37, F38. John then confirmed hosted droplets have a restrictive cloud firewall (F36 re-rated Low), accepted F37, and explained the trial default password (F9 note added) |
|
|
| 2026-10-23 | 11 Sockeye public routes | Code reading of `RvfController` (licence fetch), `RvrController` and `SubscriptionServerTrialRequestController` (trial requests), `TrialLicenseRequestBiz`, the code generator, Sockeye's `AuthController` and token middleware, and where licences are signed. New: **F41** (F5 in Sockeye and the business server; turn 2FA off until fixed), **F42** (signing private keys in source), F39, F40. **All 11 passes done** |
|