diff --git a/devdocs/modernization/NET10-PORT.md b/devdocs/modernization/NET10-PORT.md
index f4236ee0..1d213791 100644
--- a/devdocs/modernization/NET10-PORT.md
+++ b/devdocs/modernization/NET10-PORT.md
@@ -193,7 +193,7 @@ John's "next release" cases for AyaNova (exported 2026-10-24). Each is its own s
| 4724 | SMTP: certificate checked, password never returned | F30, F31. **Done** (certificate checked, opt-out config setting `AYANOVA_SMTP_ACCEPT_ANY_CERTIFICATE` with manual page; password masked as `********` and kept on save; test `SmtpPasswordHidden`; self-signed listener proof; changelog lines for both) |
| 4725 | Sessions: password change ends sessions, exact-path token exemption, default SuperUser refused when licensed | F7, F8, F9. **Done** (sessions end on reset, administrator password change and login disabled; exact route exemption, inactive users refused; default-password SuperUser limited to changing it on a purchased licence, option A; test `Sessions`; John's click-through with a temporary licence override; manual and changelog) |
| 4726 | Login throttling/lockout (raven and sockeye) | F6. **Done** (10 failures per login or 50 per address in 15 minutes, then 429 for the rest of the window; same in Sockeye, whose login is public; test `LoginLockout`; per-address limit proved with a forwarded documentation address) |
-| 4727 | Low severity hardening | F10, F11, F12, F22, F23, F25, F29, F35, F36, F38; the AyaNova half of F39 (DB ID not shown anonymously) |
+| 4727 | Low severity hardening | F10, F11, F12, F22, F23, F25, F29, F35, F36, F38; the AyaNova half of F39 (DB ID not shown anonymously). In steps: 1 logo (F23) **done**; 2 API root page (F11, F39); 3 `server-info` (F29); 4 job owner and template export (F22); 5 `OverrideUserId` and export download (F25); 6 safe headers (F35); 7 remove `dist/docker` (F38) |
| 4681 | Sockeye: licence/trial request hardening (tagged sockeye, rockfish) | F40. **Done** (sockeye `cd3ebed`: rate limits, email check, cryptographic codes; rvf unchanged) |
**From the port and earlier triage**
diff --git a/devdocs/modernization/SECURITY-REVIEW.md b/devdocs/modernization/SECURITY-REVIEW.md
index c724ff79..13369f74 100644
--- a/devdocs/modernization/SECURITY-REVIEW.md
+++ b/devdocs/modernization/SECURITY-REVIEW.md
@@ -208,7 +208,7 @@ take the next free number; a fixed or accepted finding keeps its ID and changes
| F28 | High | **fixed in case 4713** | Users with change rights on Customer (Service, Sales, Accounting) can give customer contact logins any role, BizAdmin and OpsAdmin included |
| F33 | High | **fixed in case 4721** | Client renders some record text as HTML without escaping (search results, work-order address dialog, unit warranty info, dialogs) |
| F3 | High | **fixed in cases 4712 and 4723** (staff sending to any address kept, by design) | 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) |
+| F23 | Medium | **fixed in case 4727** | 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 | **fixed in case 4724** | SMTP: the mail server's TLS certificate is never checked |
| F31 | Medium | **fixed in case 4724** | The SMTP password is returned to read-only roles (OpsAdminRestricted, BizAdmin, BizAdminRestricted) |
@@ -522,6 +522,8 @@ BizAdminRestricted, whose rights on Global are meant to be read-only. Fix: requi
no SVG); serve with `X-Content-Type-Options: nosniff` and a restrictive
`Content-Security-Policy` (pass 9 looks at headers generally).
+**Fixed in case 4727, step 1 (2026-10-24):** upload and delete need `HasModifyRole(Global)` (BizAdmin); the type is taken from the bytes (PNG, JPEG, GIF, WebP, SVG; anything else refused, the claimed and stored types are ignored); downloads send `nosniff` and `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`. SVG is kept (the client accepts any image, so existing customers may have one): the sandbox stops its script when it is opened by URL, `` use is unaffected. Test `LogoSafety`; an SVG with a script was uploaded, served sandboxed, and the original logo restored.
+
### F24 Translation import without a role check (pass 5, from code)
`TranslationController.UploadAsync` (`POST translation/upload`) and `TranslationBiz.ImportAsync`
diff --git a/docs/8.0/ayanova/docs/adm-global-logo.md b/docs/8.0/ayanova/docs/adm-global-logo.md
index 9d517dcf..75a6accd 100644
--- a/docs/8.0/ayanova/docs/adm-global-logo.md
+++ b/docs/8.0/ayanova/docs/adm-global-logo.md
@@ -29,6 +29,10 @@ Logos are stored in the AyaNova database and appear in reports and on the login
If you do not want to customize the login form and only want a logo for reporting then you would only need to provide the Small sized logo.
If you provide a Medium or Large sized logo they will automatically be used by the Login form in place of the standard AyaNova logo.
+### Logo file types
+
+Logos must be PNG, JPEG, GIF, WebP or SVG images. Other files are refused when uploaded.
+
### Maximum logo file size
Maximum file size allowed for any logo is 512KiB (512000 bytes) however you should ensure your logo is as small a file size as possible while still maintaining visual quality. Under 100kb is a good rule of thumb.
diff --git a/docs/8.0/ayanova/docs/changelog.md b/docs/8.0/ayanova/docs/changelog.md
index 2b04f1f2..e55a23e1 100644
--- a/docs/8.0/ayanova/docs/changelog.md
+++ b/docs/8.0/ayanova/docs/changelog.md
@@ -28,6 +28,7 @@ IN PROGRESS
- Server: removed an unused internal performance profiling component.
- Server: AyaNova now checks the email (SMTP) server's security certificate when sending email. If your mail server uses a self-signed or internal certificate and email notifications stop after upgrading, see [SMTP certificate check](ops-config-smtp-accept-any-certificate.md).
- Server: after repeated failed login attempts, further attempts for that login, or from that address, are refused for a few minutes.
+- Server: business logo uploads must be PNG, JPEG, GIF, WebP or SVG images, and need full rights to global settings. See [business logos](adm-global-logo.md).
- Server: when a user's password is reset or set by an administrator, or their login is disabled, they are signed out and must sign in again.
- Server: on a server with a purchased license, the SuperUser account can only be used to change its password while it still has the default password, and the login form no longer offers the default SuperUser login. See [SuperUser login](ay-start-login.md).
- Server: the SMTP server password is no longer shown in the [notification settings](ops-notification-system.md) form; it shows `********` when a password is set. Type a new password to change it.
diff --git a/server/AyaNova/Controllers/LogoController.cs b/server/AyaNova/Controllers/LogoController.cs
index 1410b2ff..2852966d 100644
--- a/server/AyaNova/Controllers/LogoController.cs
+++ b/server/AyaNova/Controllers/LogoController.cs
@@ -82,21 +82,49 @@ namespace AyaNova.Api.Controllers
case "small":
if (logo.Small == null)
return NotFound();
- return new FileStreamResult(new MemoryStream(logo.Small), Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse(logo.SmallType));
+ return LogoFile(logo.Small);
case "medium":
if (logo.Medium == null)
return NotFound();
- return new FileStreamResult(new MemoryStream(logo.Medium), Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse(logo.MediumType));
+ return LogoFile(logo.Medium);
case "large":
if (logo.Large == null)
return NotFound();
- return new FileStreamResult(new MemoryStream(logo.Large), Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse(logo.LargeType));
+ return LogoFile(logo.Large);
}
return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND));
}
+ //case 4727 (F23): the type comes from the bytes, never from what was sent or stored, and the response may not run
+ //script: an SVG opened directly by its URL is shown, its script is not run (an tag is not affected)
+ private FileStreamResult LogoFile(byte[] bytes)
+ {
+ Response.Headers["X-Content-Type-Options"] = "nosniff";
+ Response.Headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
+ return new FileStreamResult(new MemoryStream(bytes), ImageTypeOrNull(bytes) ?? "application/octet-stream");
+ }
+
+ //case 4727 (F23): PNG, JPEG, GIF, WebP or SVG, recognised by their content; null for anything else
+ internal static string ImageTypeOrNull(byte[] b)
+ {
+ if (b == null || b.Length < 12)
+ return null;
+ if (b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4E && b[3] == 0x47 && b[4] == 0x0D && b[5] == 0x0A && b[6] == 0x1A && b[7] == 0x0A)
+ return "image/png";
+ if (b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF)
+ return "image/jpeg";
+ if (b[0] == 'G' && b[1] == 'I' && b[2] == 'F' && b[3] == '8' && (b[4] == '7' || b[4] == '9') && b[5] == 'a')
+ return "image/gif";
+ if (b[0] == 'R' && b[1] == 'I' && b[2] == 'F' && b[3] == 'F' && b[8] == 'W' && b[9] == 'E' && b[10] == 'B' && b[11] == 'P')
+ return "image/webp";
+ var head = System.Text.Encoding.UTF8.GetString(b, 0, System.Math.Min(b.Length, 4096)).TrimStart((char)0xFEFF, ' ', '\t', '\r', '\n');
+ if (head.StartsWith("<") && head.Contains("