case 4727 - logo uploads checked by content, change rights, served sandboxed

Logo upload and delete need change rights on Global (was read rights,
which BizAdminRestricted has). The image type is taken from the bytes:
PNG, JPEG, GIF, WebP or SVG, anything else refused; the claimed type is
not stored. Downloads send nosniff and a sandbox Content-Security-Policy,
so an SVG opened by its URL runs no script. F23 fixed (4727 step 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 20:56:56 -07:00
parent eb1deff2ba
commit 289058bad2
5 changed files with 50 additions and 10 deletions

View File

@@ -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) | | 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) | | 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) | | 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) | | 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** **From the port and earlier triage**

View File

@@ -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 | | 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) | | 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 | | 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 | | 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 | | 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) | | 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 no SVG); serve with `X-Content-Type-Options: nosniff` and a restrictive
`Content-Security-Policy` (pass 9 looks at headers generally). `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, `<img>` 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) ### F24 Translation import without a role check (pass 5, from code)
`TranslationController.UploadAsync` (`POST translation/upload`) and `TranslationBiz.ImportAsync` `TranslationController.UploadAsync` (`POST translation/upload`) and `TranslationBiz.ImportAsync`

View File

@@ -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 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. 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 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. 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.

View File

@@ -28,6 +28,7 @@ IN PROGRESS
- Server: removed an unused internal performance profiling component. - 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: 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: 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: 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: 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. - 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.

View File

@@ -82,21 +82,49 @@ namespace AyaNova.Api.Controllers
case "small": case "small":
if (logo.Small == null) if (logo.Small == null)
return NotFound(); return NotFound();
return new FileStreamResult(new MemoryStream(logo.Small), Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse(logo.SmallType)); return LogoFile(logo.Small);
case "medium": case "medium":
if (logo.Medium == null) if (logo.Medium == null)
return NotFound(); return NotFound();
return new FileStreamResult(new MemoryStream(logo.Medium), Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse(logo.MediumType)); return LogoFile(logo.Medium);
case "large": case "large":
if (logo.Large == null) if (logo.Large == null)
return NotFound(); 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)); 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 <img> 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("<svg", System.StringComparison.OrdinalIgnoreCase))
return "image/svg+xml";
return null;
}
/// <summary> /// <summary>
@@ -118,7 +146,8 @@ namespace AyaNova.Api.Controllers
if (!serverState.IsOpen) if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.Global)) //case 4727 (F23): change rights (was read rights, which BizAdminRestricted has)
if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.Global))
return StatusCode(403, new ApiNotAuthorizedResponse()); return StatusCode(403, new ApiNotAuthorizedResponse());
if (string.IsNullOrWhiteSpace(size)) if (string.IsNullOrWhiteSpace(size))
@@ -145,19 +174,22 @@ namespace AyaNova.Api.Controllers
await file.CopyToAsync(memoryStream); await file.CopyToAsync(memoryStream);
if (memoryStream.Length > ServerBootConfig.MAX_LOGO_UPLOAD_BYTES) if (memoryStream.Length > ServerBootConfig.MAX_LOGO_UPLOAD_BYTES)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_LENGTH_EXCEEDED, null, $"Logo files must be smaller than {ServerBootConfig.MAX_LOGO_UPLOAD_BYTES} maximum")); return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_LENGTH_EXCEEDED, null, $"Logo files must be smaller than {ServerBootConfig.MAX_LOGO_UPLOAD_BYTES} maximum"));
var imageType = ImageTypeOrNull(memoryStream.ToArray());
if (imageType == null)
return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, null, "Logo files must be PNG, JPEG, GIF, WebP or SVG images"));
switch (size) switch (size)
{ {
case "small": case "small":
logo.Small = memoryStream.ToArray(); logo.Small = memoryStream.ToArray();
logo.SmallType = file.ContentType; logo.SmallType = imageType;
break; break;
case "medium": case "medium":
logo.Medium = memoryStream.ToArray(); logo.Medium = memoryStream.ToArray();
logo.MediumType = file.ContentType; logo.MediumType = imageType;
break; break;
case "large": case "large":
logo.Large = memoryStream.ToArray(); logo.Large = memoryStream.ToArray();
logo.LargeType = file.ContentType; logo.LargeType = imageType;
break; break;
} }
@@ -179,7 +211,8 @@ namespace AyaNova.Api.Controllers
if (!serverState.IsOpen) if (!serverState.IsOpen)
return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason)); return StatusCode(503, new ApiErrorResponse(serverState.ApiErrorCode, null, serverState.Reason));
if (!Authorized.HasReadFullRole(HttpContext.Items, AyaType.Global)) //case 4727 (F23): change rights (was read rights, which BizAdminRestricted has)
if (!Authorized.HasModifyRole(HttpContext.Items, AyaType.Global))
return StatusCode(403, new ApiNotAuthorizedResponse()); return StatusCode(403, new ApiNotAuthorizedResponse());
if (string.IsNullOrWhiteSpace(size)) if (string.IsNullOrWhiteSpace(size))