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

@@ -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 <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>
@@ -118,7 +146,8 @@ namespace AyaNova.Api.Controllers
if (!serverState.IsOpen)
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());
if (string.IsNullOrWhiteSpace(size))
@@ -145,19 +174,22 @@ namespace AyaNova.Api.Controllers
await file.CopyToAsync(memoryStream);
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"));
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)
{
case "small":
logo.Small = memoryStream.ToArray();
logo.SmallType = file.ContentType;
logo.SmallType = imageType;
break;
case "medium":
logo.Medium = memoryStream.ToArray();
logo.MediumType = file.ContentType;
logo.MediumType = imageType;
break;
case "large":
logo.Large = memoryStream.ToArray();
logo.LargeType = file.ContentType;
logo.LargeType = imageType;
break;
}
@@ -179,7 +211,8 @@ namespace AyaNova.Api.Controllers
if (!serverState.IsOpen)
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());
if (string.IsNullOrWhiteSpace(size))