diff --git a/.gitignore b/.gitignore index bbad74e..e5ad82e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ *.userosscache *.sln.docstates +# Agent coordination marker (never committed) +.workongoing + # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/docs/killbits.md b/docs/killbits.md new file mode 100644 index 0000000..92ca9b9 --- /dev/null +++ b/docs/killbits.md @@ -0,0 +1,185 @@ +# Killbits — format and codec gating + +Imageflow Server ships a three-layer killbits system that lets operators narrow which image formats and named codecs can decode or encode on their server. + +The three layers are: + +1. **Compile ceiling** — set by which features the native imageflow runtime was built with. Can only deny; never widens. +2. **Trusted policy** — set once at server startup from `Imageflow:Security` in configuration. Narrows the compile ceiling. +3. **Per-job security** — attached to each request before it crosses to the native side. Can only narrow further; can never widen. + +A format or codec is only usable when **every** layer allows it. + +## Default behavior + +By default, Imageflow Server denies **AVIF encode** and **JXL encode** even when the native build supports them. Operators must explicitly opt in. This is a deliberate change from previous releases, where anything the native build included was enabled. + +You can see the default list without reading source: `Imageflow.Server.SecurityPolicyOptions.ServerRiskyEncodeDefault`. + +Decode is not restricted by the server default — only encode. + +## Enabling the killbits surface + +Add a `Security` block under `Imageflow` in `appsettings.json`: + +```jsonc +{ + "Imageflow": { + "Security": { + "MaxDecodeSize": { "W": 8000, "H": 8000, "Megapixels": 50 }, + "MaxInputFileBytes": 268435456, + "MaxJsonBytes": 67108864, + + "Formats": { + "OptInEncode": [ "avif", "jxl" ] + }, + + "Codecs": { + "DenyEncoders": [ "mozjpeg_encoder" ] + } + } + } +} +``` + +Bind it into the middleware options once at startup: + +```csharp +var policy = SecurityPolicyConfigurationBinder.Bind( + configuration.GetSection(SecurityPolicyConfigurationBinder.DefaultSectionPath)); + +app.UseImageflow(new ImageflowMiddlewareOptions() + // ... existing config ... + .SetSecurityPolicyOptions(policy)); +``` + +If `SecurityPolicyOptions` is `null` (no `Imageflow:Security` section), the server behaves exactly like previous releases — no policy is sent to the native runtime and no server-default deny list is applied. + +## Turning AVIF / JXL encoding on + +List the formats you want to allow under `Formats:OptInEncode`: + +```jsonc +{ + "Imageflow": { + "Security": { + "Formats": { + "OptInEncode": [ "avif" ] + } + } + } +} +``` + +AVIF encode will now be allowed. JXL encode stays denied (because it's still on the server's risky-encode list and not opted into). The opposite is also fine: + +```jsonc +"OptInEncode": [ "jxl" ] +``` + +allows JXL while AVIF remains denied. + +## Expressing policy via allow-list, deny-list, or table + +Three mutually-exclusive shapes are accepted under `Formats`: + +### Allow-list — only these formats are usable + +```jsonc +"Formats": { + "AllowEncode": [ "jpeg", "png", "webp" ], + "AllowDecode": [ "jpeg", "png", "webp", "gif" ] +} +``` + +When you use an allow-list, the server default does **not** apply — you've taken explicit control. Anything not on your list is denied. + +### Deny-list — everything except these + +```jsonc +"Formats": { + "DenyEncode": [ "webp" ] +} +``` + +The server default (AVIF, JXL) is merged into your list: the final deny list becomes `[avif, jxl, webp]`. Use `OptInEncode` to subtract from the server default: + +```jsonc +"Formats": { + "OptInEncode": [ "avif" ], + "DenyEncode": [ "webp" ] +} +``` + +Effective deny list: `[jxl, webp]`. AVIF is opted in; JXL is still denied. + +### Table form — explicit per-format decode/encode flags + +```jsonc +"Formats": { + "Formats": { + "avif": { "Decode": true, "Encode": true }, + "jxl": { "Decode": true, "Encode": false } + } +} +``` + +Any format you mention is operator-controlled — the server default no longer applies to it. Formats you don't mention inherit the server default; unmentioned risky formats are injected into the table as encode-false entries. + +## Named codec gating + +Use `Codecs` to allow or deny specific named encoder / decoder backends: + +```jsonc +"Codecs": { + "DenyEncoders": [ "mozjpeg_encoder" ] +} +``` + +The codec names are snake_case and match the native imageflow enum. Use `allow_*` / `deny_*` pairs: + +- `AllowEncoders` / `DenyEncoders` — mutually exclusive +- `AllowDecoders` / `DenyDecoders` — mutually exclusive + +## Diagnosing "why not WebP?" + +On startup, the server logs a single structured line tagged `killbits-policy` summarizing the effective grid: + +``` +killbits-policy server_risky_encode=[avif,jxl] decode_allowed=[jpeg,png,gif,webp,avif,jxl,bmp,tiff,pnm] encode_allowed=[jpeg,png,gif,webp] trusted_policy_set=True +``` + +`grep killbits-policy` in your log to answer "which codecs are enabled" without reading source. + +For deeper diagnostics, enable the response header: + +```csharp +options.SetExposeNetSupportHeader(true); +``` + +Responses will carry `X-Imageflow-Net-Support: formats=jpeg,png,webp,...`. Off by default — the grid is operator-diagnostic information and may leak build details to end users. + +## Error shape + +When a killbits denial blocks a request, the server returns HTTP 422 with a JSON body: + +```json +{ + "error": "encode_not_available", + "format": "avif", + "reasons": [ "denied_by_trusted_policy" ] +} +``` + +The `error` tag is one of `codec_not_available`, `decode_not_available`, or `encode_not_available`. Clients can distinguish these from other 4xx responses (auth, missing file) and surface an appropriate message. + +## Invalid configuration + +Mutual-exclusion violations (`AllowEncode` + `DenyEncode`, list-form + table-form, etc.) are caught at startup binding time and raise `SecurityPolicyValidationException`. The message points at the offending config path. The server does **not** start in a degraded mode — invalid config means the server doesn't run. + +## Reference + +- `Imageflow.Server.SecurityPolicyOptions` — the policy shape. +- `Imageflow.Server.SecurityPolicyConfigurationBinder` — binds from `IConfiguration`. +- `Imageflow.Server.SecurityPolicyValidator` — the startup validator (used internally). +- `Imageflow.Server.ImageflowMiddlewareOptions.SetSecurityPolicyOptions` — opt in. diff --git a/src/Imageflow.Server/ImageJobInfo.cs b/src/Imageflow.Server/ImageJobInfo.cs index 2c118f1..830875c 100644 --- a/src/Imageflow.Server/ImageJobInfo.cs +++ b/src/Imageflow.Server/ImageJobInfo.cs @@ -430,11 +430,14 @@ public async Task ProcessUncached() } using var buildJob = new ImageJob(); + var effectiveSecurity = MergeEffectiveSecurity( + options.JobSecurityOptions, + options.SecurityPolicyValidator?.EffectiveJobSecurity); var jobResult = await buildJob.BuildCommandString( blobs[0].GetMemorySource(), new BytesDestination(), CommandString, watermarks) .Finish() - .SetSecurityOptions(options.JobSecurityOptions) + .SetSecurityOptions(effectiveSecurity) .InProcessAsync(); GlobalPerf.Singleton.JobComplete(new ImageJobInstrumentation(jobResult) @@ -466,6 +469,35 @@ public async Task ProcessUncached() } } } + + /// + /// Combine the legacy per-job with the + /// narrowing form derived from + /// . The killbits side always + /// wins when both sides specify something (killbits is a hard cap; + /// legacy scalar limits fall back only for the fields killbits + /// doesn't set). + /// + internal static SecurityOptions MergeEffectiveSecurity( + SecurityOptions legacy, + SecurityOptions fromPolicy) + { + if (fromPolicy == null) return legacy; + if (legacy == null) return fromPolicy; + + var merged = new SecurityOptions + { + MaxDecodeSize = fromPolicy.MaxDecodeSize ?? legacy.MaxDecodeSize, + MaxFrameSize = fromPolicy.MaxFrameSize ?? legacy.MaxFrameSize, + MaxEncodeSize = fromPolicy.MaxEncodeSize ?? legacy.MaxEncodeSize, + MaxInputFileBytes = fromPolicy.MaxInputFileBytes ?? legacy.MaxInputFileBytes, + MaxJsonBytes = fromPolicy.MaxJsonBytes ?? legacy.MaxJsonBytes, + MaxTotalFilePixels = fromPolicy.MaxTotalFilePixels ?? legacy.MaxTotalFilePixels, + Formats = fromPolicy.Formats, + Codecs = fromPolicy.Codecs, + }; + return merged; + } } internal static class PerformanceDetailsExtensions{ diff --git a/src/Imageflow.Server/Imageflow.Server.csproj b/src/Imageflow.Server/Imageflow.Server.csproj index e061041..523ebcb 100644 --- a/src/Imageflow.Server/Imageflow.Server.csproj +++ b/src/Imageflow.Server/Imageflow.Server.csproj @@ -19,7 +19,28 @@ - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Imageflow.Server/ImageflowMiddleware.cs b/src/Imageflow.Server/ImageflowMiddleware.cs index 61790ef..e0ced4a 100644 --- a/src/Imageflow.Server/ImageflowMiddleware.cs +++ b/src/Imageflow.Server/ImageflowMiddleware.cs @@ -82,6 +82,19 @@ public ImageflowMiddleware( options.Licensing.Initialize(this.options); + // If the operator configured a three-layer killbits policy, + // validate it once on a scratch context, cache the resulting + // net-support grid for diagnostics, and stash a narrowing-only + // effective SecurityOptions to apply per job. Any failure here + // takes down startup — a silent degraded mode would defeat the + // point of the killbits surface. + if (options.SecurityPolicyOptions != null) + { + var validator = new SecurityPolicyValidator(); + validator.Apply(options.SecurityPolicyOptions, this.logger); + options.SecurityPolicyValidator = validator; + } + blobProvider = new BlobProvider(providers, mappedPaths); diagnosticsPage = new DiagnosticsPage(options, env, this.logger, streamCache, this.diskCache, providers); licensePage = new LicensePage(options); @@ -219,6 +232,11 @@ public async Task Invoke(HttpContext context) { await NotFound(context, e); } + catch (KillbitsDeniedException e) + { + GlobalPerf.Singleton.IncrementCounter("middleware_killbits_denied"); + await KillbitsDenied(context, e); + } catch (Exception e) { var errorName = e.GetType().Name; @@ -238,6 +256,71 @@ public async Task Invoke(HttpContext context) } } + private async Task KillbitsDenied(HttpContext context, KillbitsDeniedException e) + { + // The native side embeds a JSON envelope in the error message + // with a structured reasons grid; surface it as HTTP 422 so + // clients can distinguish "your format isn't allowed" from + // "server is broken". + var body = new StringBuilder(); + body.Append("{\"error\":\""); + body.Append(e.DenialKind switch + { + KillbitsDenialKind.CodecNotAvailable => "codec_not_available", + KillbitsDenialKind.DecodeNotAvailable => "decode_not_available", + KillbitsDenialKind.EncodeNotAvailable => "encode_not_available", + _ => "killbits_denied", + }); + body.Append('"'); + if (!string.IsNullOrEmpty(e.Codec)) + { + body.Append(",\"codec\":\"").Append(e.Codec).Append('"'); + } + if (!string.IsNullOrEmpty(e.Format)) + { + body.Append(",\"format\":\"").Append(e.Format).Append('"'); + } + if (e.Reasons.Count > 0) + { + body.Append(",\"reasons\":["); + for (var i = 0; i < e.Reasons.Count; i++) + { + if (i > 0) body.Append(','); + body.Append('"').Append(e.Reasons[i]).Append('"'); + } + body.Append(']'); + } + body.Append('}'); + + context.Response.StatusCode = 422; + context.Response.ContentType = "application/json; charset=utf-8"; + context.Response.Headers[HeaderNames.CacheControl] = "no-store"; + MaybeEmitNetSupportHeader(context); + var bytes = Encoding.UTF8.GetBytes(body.ToString()); + context.Response.ContentLength = bytes.Length; + await context.Response.Body.WriteAsync(bytes, 0, bytes.Length); + } + + private void MaybeEmitNetSupportHeader(HttpContext context) + { + if (!options.ExposeNetSupportHeader) return; + var grid = options.SecurityPolicyValidator?.EffectiveNetSupport; + if (grid == null) return; + + // Compact form: "formats=jpeg,png,webp;encoders=zen_jpeg_encoder,..." + var sb = new StringBuilder(); + sb.Append("formats="); + var first = true; + foreach (var kv in grid.Formats) + { + if (!kv.Value.Encode) continue; + if (!first) sb.Append(','); + sb.Append(kv.Key); + first = false; + } + context.Response.Headers["X-Imageflow-Net-Support"] = sb.ToString(); + } + private async Task NotAuthorized(HttpContext context, string detail) { var s = "You are not authorized to access the given resource."; diff --git a/src/Imageflow.Server/ImageflowMiddlewareOptions.cs b/src/Imageflow.Server/ImageflowMiddlewareOptions.cs index c8fb742..0f05d02 100644 --- a/src/Imageflow.Server/ImageflowMiddlewareOptions.cs +++ b/src/Imageflow.Server/ImageflowMiddlewareOptions.cs @@ -44,6 +44,31 @@ public class ImageflowMiddlewareOptions public RequestSignatureOptions RequestSignatureOptions { get; set; } public SecurityOptions JobSecurityOptions { get; set; } + + /// + /// Three-layer killbits policy (applied once at startup via + /// v1/context/set_policy for validation, then mirrored into + /// every job as narrowing per-job security). Separate from + /// , which attaches only the scalar + /// limits from the old shape to each + /// job verbatim. + /// + /// + /// When this is null, the server behaves exactly as pre-killbits + /// releases — no SetPolicy call is issued and no narrowing + /// mask is applied. Set a value to turn the killbits surface on. + /// + public SecurityPolicyOptions SecurityPolicyOptions { get; set; } + + internal SecurityPolicyValidator SecurityPolicyValidator { get; set; } + + /// + /// Enable the X-Imageflow-Net-Support response header. Off by + /// default — the grid is operator-diagnostic information and may leak + /// build details to end users. Turn on only for closed-deployment + /// debugging. + /// + public bool ExposeNetSupportHeader { get; set; } internal readonly List>> Rewrite = new List>>(); @@ -179,6 +204,27 @@ public ImageflowMiddlewareOptions SetJobSecurityOptions(SecurityOptions security JobSecurityOptions = securityOptions; return this; } + + /// + /// Install a three-layer killbits policy. The server validates it at + /// startup via v1/context/set_policy on a scratch context and + /// applies its effective narrowing form to every job. + /// + public ImageflowMiddlewareOptions SetSecurityPolicyOptions(SecurityPolicyOptions policyOptions) + { + SecurityPolicyOptions = policyOptions; + return this; + } + + /// + /// Enable the X-Imageflow-Net-Support response header. Off by + /// default. + /// + public ImageflowMiddlewareOptions SetExposeNetSupportHeader(bool value) + { + ExposeNetSupportHeader = value; + return this; + } /// /// If true, query strings will be discarded except for their preset key/value. Query strings without a preset key will throw an error. diff --git a/src/Imageflow.Server/PublicAPI.Unshipped.txt b/src/Imageflow.Server/PublicAPI.Unshipped.txt index e69de29..89f9304 100644 --- a/src/Imageflow.Server/PublicAPI.Unshipped.txt +++ b/src/Imageflow.Server/PublicAPI.Unshipped.txt @@ -0,0 +1,65 @@ +#nullable enable +Imageflow.Server.CodecOptions +Imageflow.Server.CodecOptions.AllowDecoders.get -> System.Collections.Generic.IList? +Imageflow.Server.CodecOptions.AllowDecoders.set -> void +Imageflow.Server.CodecOptions.AllowEncoders.get -> System.Collections.Generic.IList? +Imageflow.Server.CodecOptions.AllowEncoders.set -> void +Imageflow.Server.CodecOptions.CodecOptions() -> void +Imageflow.Server.CodecOptions.DenyDecoders.get -> System.Collections.Generic.IList? +Imageflow.Server.CodecOptions.DenyDecoders.set -> void +Imageflow.Server.CodecOptions.DenyEncoders.get -> System.Collections.Generic.IList? +Imageflow.Server.CodecOptions.DenyEncoders.set -> void +Imageflow.Server.FormatOptions +Imageflow.Server.FormatOptions.AllowDecode.get -> System.Collections.Generic.IList? +Imageflow.Server.FormatOptions.AllowDecode.set -> void +Imageflow.Server.FormatOptions.AllowEncode.get -> System.Collections.Generic.IList? +Imageflow.Server.FormatOptions.AllowEncode.set -> void +Imageflow.Server.FormatOptions.DenyDecode.get -> System.Collections.Generic.IList? +Imageflow.Server.FormatOptions.DenyDecode.set -> void +Imageflow.Server.FormatOptions.DenyEncode.get -> System.Collections.Generic.IList? +Imageflow.Server.FormatOptions.DenyEncode.set -> void +Imageflow.Server.FormatOptions.FormatOptions() -> void +Imageflow.Server.FormatOptions.Formats.get -> System.Collections.Generic.IDictionary? +Imageflow.Server.FormatOptions.Formats.set -> void +Imageflow.Server.FormatOptions.OptInEncode.get -> System.Collections.Generic.IList? +Imageflow.Server.FormatOptions.OptInEncode.set -> void +Imageflow.Server.ImageflowMiddlewareOptions.ExposeNetSupportHeader.get -> bool +Imageflow.Server.ImageflowMiddlewareOptions.ExposeNetSupportHeader.set -> void +~Imageflow.Server.ImageflowMiddlewareOptions.SecurityPolicyOptions.get -> Imageflow.Server.SecurityPolicyOptions +~Imageflow.Server.ImageflowMiddlewareOptions.SecurityPolicyOptions.set -> void +~Imageflow.Server.ImageflowMiddlewareOptions.SetExposeNetSupportHeader(bool value) -> Imageflow.Server.ImageflowMiddlewareOptions +~Imageflow.Server.ImageflowMiddlewareOptions.SetSecurityPolicyOptions(Imageflow.Server.SecurityPolicyOptions policyOptions) -> Imageflow.Server.ImageflowMiddlewareOptions +Imageflow.Server.SecurityPolicyConfigurationBinder +Imageflow.Server.SecurityPolicyOptions +Imageflow.Server.SecurityPolicyOptions.BuildEffectiveSecurityOptions() -> Imageflow.Fluent.SecurityOptions! +Imageflow.Server.SecurityPolicyOptions.Codecs.get -> Imageflow.Server.CodecOptions? +Imageflow.Server.SecurityPolicyOptions.Codecs.set -> void +Imageflow.Server.SecurityPolicyOptions.Formats.get -> Imageflow.Server.FormatOptions? +Imageflow.Server.SecurityPolicyOptions.Formats.set -> void +Imageflow.Server.SecurityPolicyOptions.MaxDecodeSize.get -> Imageflow.Fluent.FrameSizeLimit? +Imageflow.Server.SecurityPolicyOptions.MaxDecodeSize.set -> void +Imageflow.Server.SecurityPolicyOptions.MaxEncodeSize.get -> Imageflow.Fluent.FrameSizeLimit? +Imageflow.Server.SecurityPolicyOptions.MaxEncodeSize.set -> void +Imageflow.Server.SecurityPolicyOptions.MaxFrameSize.get -> Imageflow.Fluent.FrameSizeLimit? +Imageflow.Server.SecurityPolicyOptions.MaxFrameSize.set -> void +Imageflow.Server.SecurityPolicyOptions.MaxInputFileBytes.get -> ulong? +Imageflow.Server.SecurityPolicyOptions.MaxInputFileBytes.set -> void +Imageflow.Server.SecurityPolicyOptions.MaxJsonBytes.get -> ulong? +Imageflow.Server.SecurityPolicyOptions.MaxJsonBytes.set -> void +Imageflow.Server.SecurityPolicyOptions.MaxTotalFilePixels.get -> ulong? +Imageflow.Server.SecurityPolicyOptions.MaxTotalFilePixels.set -> void +Imageflow.Server.SecurityPolicyOptions.SecurityPolicyOptions() -> void +Imageflow.Server.SecurityPolicyOptions.ServerRiskyEncode.get -> System.Collections.Generic.IReadOnlyList! +Imageflow.Server.SecurityPolicyOptions.ServerRiskyEncode.set -> void +Imageflow.Server.SecurityPolicyOptions.Validate() -> void +Imageflow.Server.SecurityPolicyValidationException +Imageflow.Server.SecurityPolicyValidationException.SecurityPolicyValidationException(string! message) -> void +Imageflow.Server.SecurityPolicyValidationException.SecurityPolicyValidationException(string! message, System.Exception! inner) -> void +Imageflow.Server.SecurityPolicyValidator +Imageflow.Server.SecurityPolicyValidator.Apply(Imageflow.Server.SecurityPolicyOptions! options, Microsoft.Extensions.Logging.ILogger? logger = null) -> void +Imageflow.Server.SecurityPolicyValidator.EffectiveJobSecurity.get -> Imageflow.Fluent.SecurityOptions? +Imageflow.Server.SecurityPolicyValidator.EffectiveNetSupport.get -> Imageflow.Bindings.NetSupportResponse? +Imageflow.Server.SecurityPolicyValidator.SecurityPolicyValidator() -> void +const Imageflow.Server.SecurityPolicyConfigurationBinder.DefaultSectionPath = "Imageflow:Security" -> string! +static Imageflow.Server.SecurityPolicyConfigurationBinder.Bind(Microsoft.Extensions.Configuration.IConfiguration! section) -> Imageflow.Server.SecurityPolicyOptions? +static readonly Imageflow.Server.SecurityPolicyOptions.ServerRiskyEncodeDefault -> System.Collections.Generic.IReadOnlyList! diff --git a/src/Imageflow.Server/SecurityPolicyConfigurationBinder.cs b/src/Imageflow.Server/SecurityPolicyConfigurationBinder.cs new file mode 100644 index 0000000..601b030 --- /dev/null +++ b/src/Imageflow.Server/SecurityPolicyConfigurationBinder.cs @@ -0,0 +1,375 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using Imageflow.Fluent; +using Microsoft.Extensions.Configuration; + +namespace Imageflow.Server +{ + /// + /// Binds from an + /// section (typically + /// Imageflow:Security). + /// + /// + /// This is deliberately hand-rolled instead of using + /// Configuration.Bind. The built-in binder can't easily express + /// dictionaries keyed by an enum plus a strongly-typed struct value + /// (), and silent binder failures would + /// defeat the "fail loudly" goal of the startup validator. + /// + /// Recognized JSON shape: + /// + /// { + /// "Imageflow": { + /// "Security": { + /// "MaxDecodeSize": { "W": 8000, "H": 8000, "Megapixels": 50 }, + /// "MaxInputFileBytes": 268435456, + /// "MaxJsonBytes": 67108864, + /// "Formats": { + /// "OptInEncode": [ "avif", "jxl" ], + /// "DenyEncode": [ "webp" ], + /// "AllowDecode": null, + /// "DenyDecode": null + /// }, + /// "Codecs": { + /// "DenyEncoders": [ "mozjpeg_encoder" ] + /// } + /// } + /// } + /// } + /// + /// + public static class SecurityPolicyConfigurationBinder + { + /// Default section path: Imageflow:Security. + public const string DefaultSectionPath = "Imageflow:Security"; + + /// Bind from . + /// null when the section doesn't exist (meaning: no security policy configured). + /// when the config is malformed or inconsistent. + public static SecurityPolicyOptions? Bind(IConfiguration section) + { + if (section == null) + { + throw new ArgumentNullException(nameof(section)); + } + + var opts = new SecurityPolicyOptions(); + var anyValue = false; + + if (TryReadSize(section.GetSection("MaxDecodeSize"), out var maxDecode)) + { + opts.MaxDecodeSize = maxDecode; anyValue = true; + } + if (TryReadSize(section.GetSection("MaxFrameSize"), out var maxFrame)) + { + opts.MaxFrameSize = maxFrame; anyValue = true; + } + if (TryReadSize(section.GetSection("MaxEncodeSize"), out var maxEncode)) + { + opts.MaxEncodeSize = maxEncode; anyValue = true; + } + if (TryReadUlong(section["MaxInputFileBytes"], nameof(SecurityPolicyOptions.MaxInputFileBytes), out var mibb)) + { + opts.MaxInputFileBytes = mibb; anyValue = true; + } + if (TryReadUlong(section["MaxJsonBytes"], nameof(SecurityPolicyOptions.MaxJsonBytes), out var mjb)) + { + opts.MaxJsonBytes = mjb; anyValue = true; + } + if (TryReadUlong(section["MaxTotalFilePixels"], nameof(SecurityPolicyOptions.MaxTotalFilePixels), out var mtfp)) + { + opts.MaxTotalFilePixels = mtfp; anyValue = true; + } + + var formatsSection = section.GetSection("Formats"); + if (formatsSection.Exists()) + { + opts.Formats = BindFormatOptions(formatsSection); + anyValue = true; + } + + var codecsSection = section.GetSection("Codecs"); + if (codecsSection.Exists()) + { + opts.Codecs = BindCodecOptions(codecsSection); + anyValue = true; + } + + // ServerRiskyEncode override — rare, but surfaced so specialized + // deployments can change the defaults without replacing + // SecurityPolicyOptions wholesale. + var riskyValues = ReadFormatList(section.GetSection("ServerRiskyEncode"), "ServerRiskyEncode"); + if (riskyValues != null) + { + opts.ServerRiskyEncode = riskyValues; + anyValue = true; + } + + if (!anyValue) + { + return null; + } + + try + { + opts.Validate(); + } + catch (SecurityPolicyValidationException) + { + throw; + } + + return opts; + } + + private static FormatOptions BindFormatOptions(IConfiguration section) + { + var opts = new FormatOptions + { + OptInEncode = ReadFormatList(section.GetSection("OptInEncode"), "Formats:OptInEncode"), + AllowDecode = ReadFormatList(section.GetSection("AllowDecode"), "Formats:AllowDecode"), + DenyDecode = ReadFormatList(section.GetSection("DenyDecode"), "Formats:DenyDecode"), + AllowEncode = ReadFormatList(section.GetSection("AllowEncode"), "Formats:AllowEncode"), + DenyEncode = ReadFormatList(section.GetSection("DenyEncode"), "Formats:DenyEncode"), + }; + + var tableSection = section.GetSection("Formats"); + if (tableSection.Exists()) + { + var table = new Dictionary(); + foreach (var entry in tableSection.GetChildren()) + { + if (!ImageFormatParser.TryParse(entry.Key, out var format)) + { + throw new SecurityPolicyValidationException( + $"Imageflow:Security:Formats:Formats — unknown format '{entry.Key}'. " + + "Expected one of: jpeg, png, gif, webp, avif, jxl, heic, bmp, tiff, pnm."); + } + var decode = ReadBool(entry["Decode"], $"Formats:Formats:{entry.Key}:Decode"); + var encode = ReadBool(entry["Encode"], $"Formats:Formats:{entry.Key}:Encode"); + table[format] = new FormatPermissions(decode ?? true, encode ?? true); + } + if (table.Count > 0) + { + opts.Formats = table; + } + } + + return opts; + } + + private static CodecOptions BindCodecOptions(IConfiguration section) + { + return new CodecOptions + { + AllowEncoders = ReadEncoderList(section.GetSection("AllowEncoders"), "Codecs:AllowEncoders"), + DenyEncoders = ReadEncoderList(section.GetSection("DenyEncoders"), "Codecs:DenyEncoders"), + AllowDecoders = ReadDecoderList(section.GetSection("AllowDecoders"), "Codecs:AllowDecoders"), + DenyDecoders = ReadDecoderList(section.GetSection("DenyDecoders"), "Codecs:DenyDecoders"), + }; + } + + private static List? ReadFormatList(IConfigurationSection section, string pathForError) + { + if (!section.Exists()) + { + return null; + } + var list = new List(); + foreach (var child in section.GetChildren()) + { + var raw = child.Value; + if (string.IsNullOrEmpty(raw)) + { + continue; + } + if (!ImageFormatParser.TryParse(raw!.ToLowerInvariant(), out var format)) + { + throw new SecurityPolicyValidationException( + $"Imageflow:Security:{pathForError} — unknown format '{raw}'. " + + "Expected one of: jpeg, png, gif, webp, avif, jxl, heic, bmp, tiff, pnm."); + } + list.Add(format); + } + return list.Count == 0 ? null : list; + } + + private static List? ReadEncoderList(IConfigurationSection section, string pathForError) + { + if (!section.Exists()) + { + return null; + } + var list = new List(); + foreach (var child in section.GetChildren()) + { + var raw = child.Value; + if (string.IsNullOrEmpty(raw)) + { + continue; + } + if (!NamedCodecParsers.TryParseEncoder(raw!.ToLowerInvariant(), out var enc)) + { + throw new SecurityPolicyValidationException( + $"Imageflow:Security:{pathForError} — unknown encoder '{raw}'. " + + "Use snake_case (e.g. 'mozjpeg_encoder')."); + } + list.Add(enc); + } + return list.Count == 0 ? null : list; + } + + private static List? ReadDecoderList(IConfigurationSection section, string pathForError) + { + if (!section.Exists()) + { + return null; + } + var list = new List(); + foreach (var child in section.GetChildren()) + { + var raw = child.Value; + if (string.IsNullOrEmpty(raw)) + { + continue; + } + if (!NamedCodecParsers.TryParseDecoder(raw!.ToLowerInvariant(), out var dec)) + { + throw new SecurityPolicyValidationException( + $"Imageflow:Security:{pathForError} — unknown decoder '{raw}'. " + + "Use snake_case (e.g. 'zen_png_decoder')."); + } + list.Add(dec); + } + return list.Count == 0 ? null : list; + } + + private static bool TryReadSize(IConfigurationSection section, out FrameSizeLimit? limit) + { + limit = null; + if (!section.Exists()) + { + return false; + } + var w = TryUint(section["W"]) ?? TryUint(section["Width"]) ?? 0u; + var h = TryUint(section["H"]) ?? TryUint(section["Height"]) ?? 0u; + var mpRaw = section["Megapixels"] ?? section["MP"]; + if (!float.TryParse(mpRaw, NumberStyles.Float, CultureInfo.InvariantCulture, out var mp)) + { + mp = 0f; + } + limit = new FrameSizeLimit(w, h, mp); + return true; + } + + private static uint? TryUint(string? s) => + uint.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : (uint?)null; + + private static bool TryReadUlong(string? raw, string fieldName, out ulong? value) + { + value = null; + if (string.IsNullOrEmpty(raw)) + { + return false; + } + if (!ulong.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) + { + throw new SecurityPolicyValidationException( + $"Imageflow:Security:{fieldName} — expected a non-negative integer, got '{raw}'."); + } + value = parsed; + return true; + } + + private static bool? ReadBool(string? raw, string pathForError) + { + if (string.IsNullOrEmpty(raw)) + { + return null; + } + if (!bool.TryParse(raw, out var b)) + { + throw new SecurityPolicyValidationException( + $"Imageflow:Security:{pathForError} — expected true/false, got '{raw}'."); + } + return b; + } + } + + /// snake_case parser for . Mirrors the internal table in imageflow-dotnet. + internal static class ImageFormatParser + { + public static bool TryParse(string? snakeCase, out ImageFormat format) + { + switch (snakeCase) + { + case "jpeg": format = ImageFormat.Jpeg; return true; + case "png": format = ImageFormat.Png; return true; + case "gif": format = ImageFormat.Gif; return true; + case "webp": format = ImageFormat.Webp; return true; + case "avif": format = ImageFormat.Avif; return true; + case "jxl": format = ImageFormat.Jxl; return true; + case "heic": format = ImageFormat.Heic; return true; + case "bmp": format = ImageFormat.Bmp; return true; + case "tiff": format = ImageFormat.Tiff; return true; + case "pnm": format = ImageFormat.Pnm; return true; + default: format = default; return false; + } + } + } + + /// + /// Name parsers for / . + /// Exists because the per-enum TryParse helpers on the + /// imageflow-dotnet side are internal — we re-declare the tables here to + /// keep the Imageflow.Server project's only dependency on the public API. + /// + internal static class NamedCodecParsers + { + public static bool TryParseEncoder(string? snakeCase, out NamedEncoderName encoder) + { + switch (snakeCase) + { + case "mozjpeg_encoder": encoder = NamedEncoderName.MozjpegEncoder; return true; + case "zen_jpeg_encoder": encoder = NamedEncoderName.ZenJpegEncoder; return true; + case "mozjpeg_rs_encoder": encoder = NamedEncoderName.MozjpegRsEncoder; return true; + case "libpng_encoder": encoder = NamedEncoderName.LibpngEncoder; return true; + case "lodepng_encoder": encoder = NamedEncoderName.LodepngEncoder; return true; + case "pngquant_encoder": encoder = NamedEncoderName.PngquantEncoder; return true; + case "zen_png_encoder": encoder = NamedEncoderName.ZenPngEncoder; return true; + case "webp_encoder": encoder = NamedEncoderName.WebpEncoder; return true; + case "zen_webp_encoder": encoder = NamedEncoderName.ZenWebpEncoder; return true; + case "gif_encoder": encoder = NamedEncoderName.GifEncoder; return true; + case "zen_gif_encoder": encoder = NamedEncoderName.ZenGifEncoder; return true; + case "zen_avif_encoder": encoder = NamedEncoderName.ZenAvifEncoder; return true; + case "zen_jxl_encoder": encoder = NamedEncoderName.ZenJxlEncoder; return true; + case "zen_bmp_encoder": encoder = NamedEncoderName.ZenBmpEncoder; return true; + default: encoder = default; return false; + } + } + + public static bool TryParseDecoder(string? snakeCase, out NamedDecoderName decoder) + { + switch (snakeCase) + { + case "mozjpeg_rs_decoder": decoder = NamedDecoderName.MozjpegRsDecoder; return true; + case "image_rs_jpeg_decoder":decoder = NamedDecoderName.ImageRsJpegDecoder; return true; + case "zen_jpeg_decoder": decoder = NamedDecoderName.ZenJpegDecoder; return true; + case "libpng_decoder": decoder = NamedDecoderName.LibpngDecoder; return true; + case "image_rs_png_decoder": decoder = NamedDecoderName.ImageRsPngDecoder; return true; + case "zen_png_decoder": decoder = NamedDecoderName.ZenPngDecoder; return true; + case "gif_rs_decoder": decoder = NamedDecoderName.GifRsDecoder; return true; + case "zen_gif_decoder": decoder = NamedDecoderName.ZenGifDecoder; return true; + case "webp_decoder": decoder = NamedDecoderName.WebpDecoder; return true; + case "zen_webp_decoder": decoder = NamedDecoderName.ZenWebpDecoder; return true; + case "zen_avif_decoder": decoder = NamedDecoderName.ZenAvifDecoder; return true; + case "zen_jxl_decoder": decoder = NamedDecoderName.ZenJxlDecoder; return true; + case "zen_bmp_decoder": decoder = NamedDecoderName.ZenBmpDecoder; return true; + default: decoder = default; return false; + } + } + } +} diff --git a/src/Imageflow.Server/SecurityPolicyOptions.cs b/src/Imageflow.Server/SecurityPolicyOptions.cs new file mode 100644 index 0000000..2b6598a --- /dev/null +++ b/src/Imageflow.Server/SecurityPolicyOptions.cs @@ -0,0 +1,356 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Imageflow.Fluent; + +namespace Imageflow.Server +{ + /// + /// Server-layer representation of the three-layer killbits policy, plus + /// resource-scalar limits. Binds from Imageflow:Security in + /// appsettings.json, or can be constructed programmatically. + /// + /// + /// Two things live together here: + /// + /// Trusted-policy scalars + killbits (what the + /// server signs off on globally). Allow-list forms are permitted at + /// this layer. + /// Server-default risky-encode list + /// (AVIF / JXL by default). Operators must opt in explicitly for + /// those formats to be usable. + /// + /// Call to produce a + /// narrowing-only suitable for + /// Build001.security / Execute001.security. The same helper + /// is used by + /// at startup for trusted-context + /// validation. + /// + public sealed class SecurityPolicyOptions + { + /// Server-default risky encode formats. See . + /// + /// Replacing this list is an explicit operator choice. Prefer + /// leaving it alone and toggling . + /// + public IReadOnlyList ServerRiskyEncode { get; set; } = ServerRiskyEncodeDefault; + + /// + /// The default set of formats the server denies encode for unless the + /// operator opts in. Currently {AVIF, JXL}. Exposed as a constant so + /// operators can see what's blocked without reading source. + /// + public static readonly IReadOnlyList ServerRiskyEncodeDefault = new[] + { + ImageFormat.Avif, + ImageFormat.Jxl, + }; + + // --- Scalar resource limits (forwarded verbatim to trusted policy) --- + + /// Max per-frame decode dimensions. null leaves the native default. + public FrameSizeLimit? MaxDecodeSize { get; set; } + + /// Max per-frame working buffer. null leaves the native default. + public FrameSizeLimit? MaxFrameSize { get; set; } + + /// Max encode dimensions. null leaves the native default. + public FrameSizeLimit? MaxEncodeSize { get; set; } + + /// Bytes cap on a single codec input stream. null leaves native default (256 MiB). + public ulong? MaxInputFileBytes { get; set; } + + /// JSON payload size cap. null leaves native default (64 MiB). + public ulong? MaxJsonBytes { get; set; } + + /// Total decoded pixels across every frame. null leaves native default. + public ulong? MaxTotalFilePixels { get; set; } + + // --- Killbits --- + + /// Per-format decode/encode permissions. Supports allow-list, deny-list, or table forms. + public FormatOptions? Formats { get; set; } + + /// Per-codec permissions. Supports allow-list or deny-list forms. + public CodecOptions? Codecs { get; set; } + + /// + /// Run mutual-exclusion validation. Call this at config-bind time so + /// misconfiguration surfaces before the server tries to talk to + /// imageflow. + /// + /// when invariants are violated. + public void Validate() + { + try + { + Formats?.Validate(); + Codecs?.Validate(); + } + catch (ArgumentException ex) + { + throw new SecurityPolicyValidationException( + $"Imageflow:Security — {ex.Message}", ex); + } + } + + /// + /// Compute the narrowing-only the server + /// should apply to every job. Merges: + /// + /// Server-default risky encode list (AVIF+JXL), + /// minus any operator opt-ins. + /// Operator-supplied allow/deny/table forms. + /// Scalar limits. + /// + /// If the operator supplied + /// or mentioned one of the risky formats explicitly in + /// , the server default is + /// disabled for that format (the operator took control). + /// + public SecurityOptions BuildEffectiveSecurityOptions() + { + Validate(); + + var effective = new SecurityOptions + { + MaxDecodeSize = MaxDecodeSize, + MaxFrameSize = MaxFrameSize, + MaxEncodeSize = MaxEncodeSize, + MaxInputFileBytes = MaxInputFileBytes, + MaxJsonBytes = MaxJsonBytes, + MaxTotalFilePixels = MaxTotalFilePixels, + }; + + var formats = Formats ?? new FormatOptions(); + var codecs = Codecs ?? new CodecOptions(); + + // ---- Merge format killbits ---- + + // Figure out whether the operator took explicit control over + // encode gating. In that case the server default does not apply + // and mutual-exclusion forbids us from layering a deny list on + // top of an allow list. + var usesAllowEncode = formats.AllowEncode != null; + var usesTableForm = formats.Formats != null; + var operatorControlledEncode = BuildOperatorControlledEncodeSet(formats); + var effectiveRiskyDefault = ServerRiskyEncode + .Where(f => !operatorControlledEncode.Contains(f)) + .Where(f => formats.OptInEncode == null || !formats.OptInEncode.Contains(f)) + .ToList(); + + var formatKillbits = new FormatKillbits + { + AllowDecode = formats.AllowDecode?.ToList(), + AllowEncode = formats.AllowEncode?.ToList(), + Formats = formats.Formats == null + ? null + : new Dictionary(formats.Formats), + }; + + // DenyDecode merges operator's DenyDecode verbatim (no server default). + if (formats.DenyDecode != null && formats.DenyDecode.Count > 0) + { + formatKillbits.DenyDecode = formats.DenyDecode.ToList(); + } + + if (usesTableForm) + { + // Table form: allow/deny lists are forbidden (mutual exclusion + // on the native side). Push unmentioned risky defaults into + // the table as explicit encode-false entries. + formatKillbits.AllowEncode = null; + formatKillbits.AllowDecode = null; + formatKillbits.DenyEncode = null; + formatKillbits.DenyDecode = null; + var mergedTable = new Dictionary(); + foreach (var kv in formats.Formats!) + { + mergedTable[kv.Key] = kv.Value; + } + foreach (var risky in effectiveRiskyDefault) + { + if (!mergedTable.ContainsKey(risky)) + { + mergedTable[risky] = new FormatPermissions(decode: true, encode: false); + } + } + formatKillbits.Formats = mergedTable; + } + else if (usesAllowEncode) + { + // Allow-list form: we cannot add a deny list (mutual exclusion). + // The operator took control; server default does not apply. + formatKillbits.DenyEncode = null; + } + else + { + // Deny-list form (default path): merge operator's DenyEncode + // with the effective risky default. + var denyEncode = new List(); + if (effectiveRiskyDefault.Count > 0) + { + denyEncode.AddRange(effectiveRiskyDefault); + } + if (formats.DenyEncode != null) + { + foreach (var f in formats.DenyEncode) + { + if (!denyEncode.Contains(f)) + { + denyEncode.Add(f); + } + } + } + if (denyEncode.Count > 0) + { + formatKillbits.DenyEncode = denyEncode; + } + } + + // Skip sending empty killbits — the native side treats a missing + // block and an empty block identically, but a missing block keeps + // the wire payload small and avoids confusing operators reading + // the startup log. + if (HasAnyFormatEntry(formatKillbits)) + { + effective.Formats = formatKillbits; + } + + // ---- Merge codec killbits ---- + + var codecKillbits = new CodecKillbits + { + AllowEncoders = codecs.AllowEncoders?.ToList(), + DenyEncoders = codecs.DenyEncoders?.ToList(), + AllowDecoders = codecs.AllowDecoders?.ToList(), + DenyDecoders = codecs.DenyDecoders?.ToList(), + }; + + if (HasAnyCodecEntry(codecKillbits)) + { + effective.Codecs = codecKillbits; + } + + return effective; + } + + private static HashSet BuildOperatorControlledEncodeSet(FormatOptions formats) + { + var set = new HashSet(); + if (formats.AllowEncode != null) + { + foreach (var f in formats.AllowEncode) + { + set.Add(f); + } + } + if (formats.DenyEncode != null) + { + foreach (var f in formats.DenyEncode) + { + set.Add(f); + } + } + if (formats.Formats != null) + { + foreach (var kv in formats.Formats) + { + set.Add(kv.Key); + } + } + return set; + } + + private static bool HasAnyFormatEntry(FormatKillbits k) => + k.AllowDecode != null || k.DenyDecode != null || + k.AllowEncode != null || k.DenyEncode != null || + k.Formats != null; + + private static bool HasAnyCodecEntry(CodecKillbits k) => + k.AllowEncoders != null || k.DenyEncoders != null || + k.AllowDecoders != null || k.DenyDecoders != null; + } + + /// + /// Operator-facing shape for per-format gating. Matches the + /// mutual-exclusion rules but adds + /// — the server-layer concept that turns on + /// AVIF/JXL encode without requiring the operator to spell out the full + /// deny-list. + /// + public sealed class FormatOptions + { + /// Opt in to formats that the server denies by default (currently AVIF and JXL). + public IList? OptInEncode { get; set; } + + /// Allow only these formats for decode. Mutually exclusive with and . + public IList? AllowDecode { get; set; } + + /// Deny these formats for decode. Mutually exclusive with . + public IList? DenyDecode { get; set; } + + /// Allow only these formats for encode. Mutually exclusive with and . + public IList? AllowEncode { get; set; } + + /// Deny these formats for encode (merged with the server's risky default). Mutually exclusive with . + public IList? DenyEncode { get; set; } + + /// Per-format table form. Mutually exclusive with any of the list forms. + public IDictionary? Formats { get; set; } + + internal void Validate() + { + if (AllowDecode != null && DenyDecode != null) + { + throw new ArgumentException( + "Formats: pick allow or deny for decode, not both", nameof(AllowDecode)); + } + if (AllowEncode != null && DenyEncode != null) + { + throw new ArgumentException( + "Formats: pick allow or deny for encode, not both", nameof(AllowEncode)); + } + var hasList = AllowDecode != null || DenyDecode != null || + AllowEncode != null || DenyEncode != null; + if (hasList && Formats != null) + { + throw new ArgumentException( + "Formats: pick a single form (allow/deny lists OR table), not a mix", + nameof(Formats)); + } + } + } + + /// Operator-facing shape for per-codec gating. + public sealed class CodecOptions + { + public IList? AllowEncoders { get; set; } + public IList? DenyEncoders { get; set; } + public IList? AllowDecoders { get; set; } + public IList? DenyDecoders { get; set; } + + internal void Validate() + { + if (AllowEncoders != null && DenyEncoders != null) + { + throw new ArgumentException( + "Codecs: pick allow or deny for encoders, not both", nameof(AllowEncoders)); + } + if (AllowDecoders != null && DenyDecoders != null) + { + throw new ArgumentException( + "Codecs: pick allow or deny for decoders, not both", nameof(AllowDecoders)); + } + } + } + + /// Thrown at startup when is internally inconsistent. + public sealed class SecurityPolicyValidationException : Exception + { + public SecurityPolicyValidationException(string message) : base(message) { } + public SecurityPolicyValidationException(string message, Exception inner) : base(message, inner) { } + } +} diff --git a/src/Imageflow.Server/SecurityPolicyValidator.cs b/src/Imageflow.Server/SecurityPolicyValidator.cs new file mode 100644 index 0000000..88c354e --- /dev/null +++ b/src/Imageflow.Server/SecurityPolicyValidator.cs @@ -0,0 +1,134 @@ +#nullable enable +using System; +using System.Linq; +using System.Text; +using Imageflow.Bindings; +using Imageflow.Fluent; +using Microsoft.Extensions.Logging; + +namespace Imageflow.Server +{ + /// + /// Applies a to a one-shot + /// at startup, captures the resulting + /// , and exposes it as a readonly + /// snapshot for the rest of the server process. + /// + /// + /// + /// We don't keep the alive after startup — + /// creates a fresh context + /// per job, so there's no persistent context to hold a trusted policy + /// on. Instead, the server expresses the same policy as a narrowing + /// per-job security block on every request. + /// + /// + /// The one-shot SetPolicy call at startup serves two purposes: + /// + /// + /// It surfaces bad configuration loudly (the + /// native side validates mutual exclusion and reports + /// invalid_policy), failing fast rather than silently + /// degrading at first request. + /// It captures the + /// grid operators need in order to diagnose "why not WebP?". The + /// cached snapshot is published through + /// and summarized on a single startup log line tagged + /// killbits-policy. + /// + /// + public sealed class SecurityPolicyValidator + { + private NetSupportResponse? _netSupport; + private SecurityOptions? _effective; + + /// + /// Narrowing-only computed from the + /// operator's . Safe to attach to + /// every job via FinishJobBuilder.SetSecurityOptions. + /// null until has run. + /// + public SecurityOptions? EffectiveJobSecurity => _effective; + + /// + /// Grid captured at startup after the trusted policy was installed + /// on a one-shot context. null until has + /// run. + /// + public NetSupportResponse? EffectiveNetSupport => _netSupport; + + /// + /// Validate the policy, send it to imageflow on a scratch context, + /// and record the resulting . + /// Logs a single structured line (search log for + /// killbits-policy) summarizing the effective grid. + /// + /// + /// When the native side rejects the policy (malformed, missing + /// endpoint, or unknown format/codec name). + /// + public void Apply(SecurityPolicyOptions options, ILogger? logger = null) + { + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + options.Validate(); + _effective = options.BuildEffectiveSecurityOptions(); + + // Spin up a one-shot context solely to validate the policy and + // grab the grid. Any failure here should take down startup. + try + { + using var ctx = new JobContext(); + var response = ctx.SetPolicy(_effective); + _netSupport = response.NetSupport; + } + catch (ImageflowException ex) + { + throw new SecurityPolicyValidationException( + "Imageflow rejected the killbits policy at startup. " + + "Check Imageflow:Security in configuration. Underlying error: " + + ex.Message, ex); + } + + LogSummary(options, logger); + } + + private void LogSummary(SecurityPolicyOptions options, ILogger? logger) + { + if (logger == null || _netSupport == null) + { + return; + } + + var allowedFormatsDecode = new StringBuilder(); + var allowedFormatsEncode = new StringBuilder(); + foreach (var kv in _netSupport.Formats) + { + if (kv.Value.Decode) + { + if (allowedFormatsDecode.Length > 0) allowedFormatsDecode.Append(','); + allowedFormatsDecode.Append(kv.Key); + } + if (kv.Value.Encode) + { + if (allowedFormatsEncode.Length > 0) allowedFormatsEncode.Append(','); + allowedFormatsEncode.Append(kv.Key); + } + } + + var riskyStr = options.ServerRiskyEncode.Count == 0 + ? "(none)" + : string.Join(",", options.ServerRiskyEncode.Select(f => f.ToString().ToLowerInvariant())); + + logger.LogInformation( + "killbits-policy server_risky_encode=[{Risky}] decode_allowed=[{Decode}] encode_allowed=[{Encode}] trusted_policy_set={Trusted}", + riskyStr, + allowedFormatsDecode.ToString(), + allowedFormatsEncode.ToString(), + _netSupport.TrustedPolicySet); + } + } +} diff --git a/src/Imageflow.Server/packages.lock.json b/src/Imageflow.Server/packages.lock.json index 0290cd0..a9259b3 100644 --- a/src/Imageflow.Server/packages.lock.json +++ b/src/Imageflow.Server/packages.lock.json @@ -2,14 +2,19 @@ "version": 1, "dependencies": { "net6.0": { - "Imageflow.AllPlatforms": { + "Imageflow.NativeRuntime.All": { "type": "Direct", - "requested": "[0.15.1, )", - "resolved": "0.15.1", - "contentHash": "Oiaeaz+0ZJip+n+0LD05boBiHKHBWVO79Wnitms3GYzjm9kxYqcSnPtQ9QRdUrwlP39dq8vurYtL4dAZaRmliQ==", + "requested": "[2.3.1-rc01, 4.0.0)", + "resolved": "2.3.1-rc01", + "contentHash": "JfHaGqSChFOZcyXH8cUXMF/fm1Bn32BBjgkLY+OSCWpVlL82lrsYr/Ue5Llf74q+tEEIIO7U7Xrw1NbugGKB8w==", "dependencies": { - "Imageflow.NativeRuntime.All": "2.3.1-rc01", - "Imageflow.Net": "0.15.1" + "Imageflow.NativeRuntime.linux-arm64": "2.3.1-rc01", + "Imageflow.NativeRuntime.linux-x64": "2.3.1-rc01", + "Imageflow.NativeRuntime.osx-arm64": "2.3.1-rc01", + "Imageflow.NativeRuntime.osx-x86_64": "2.3.1-rc01", + "Imageflow.NativeRuntime.win-arm64": "2.3.1-rc01", + "Imageflow.NativeRuntime.win-x86": "2.3.1-rc01", + "Imageflow.NativeRuntime.win-x86_64": "2.3.1-rc01" } }, "Microsoft.CodeAnalysis.PublicApiAnalyzers": { @@ -38,20 +43,6 @@ "System.Text.Encodings.Web": "8.0.0" } }, - "Imageflow.NativeRuntime.All": { - "type": "Transitive", - "resolved": "2.3.1-rc01", - "contentHash": "JfHaGqSChFOZcyXH8cUXMF/fm1Bn32BBjgkLY+OSCWpVlL82lrsYr/Ue5Llf74q+tEEIIO7U7Xrw1NbugGKB8w==", - "dependencies": { - "Imageflow.NativeRuntime.linux-arm64": "2.3.1-rc01", - "Imageflow.NativeRuntime.linux-x64": "2.3.1-rc01", - "Imageflow.NativeRuntime.osx-arm64": "2.3.1-rc01", - "Imageflow.NativeRuntime.osx-x86_64": "2.3.1-rc01", - "Imageflow.NativeRuntime.win-arm64": "2.3.1-rc01", - "Imageflow.NativeRuntime.win-x86": "2.3.1-rc01", - "Imageflow.NativeRuntime.win-x86_64": "2.3.1-rc01" - } - }, "Imageflow.NativeRuntime.linux-arm64": { "type": "Transitive", "resolved": "2.3.1-rc01", @@ -87,15 +78,6 @@ "resolved": "2.3.1-rc01", "contentHash": "rkUNn0BVgwobB05obVXEtrX4CvOpcIJ2A8InvO2vPJkRYgqjr5+GIKT0ItgCd90K30ozkWIyvG70KxDs/OL/dw==" }, - "Imageflow.Net": { - "type": "Transitive", - "resolved": "0.15.1", - "contentHash": "A+gdXZ4gxl++sjGEQIEh1r4CiRMBM5KD2Y0/993weNSPbfkUu+jrP4nGZ/nAOvjM/eRXFYP/gNnazaxDA2QeeQ==", - "dependencies": { - "Microsoft.IO.RecyclableMemoryStream": "[3.0.1, 4.0.0)", - "System.Text.Json": "8.0.6" - } - }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", "resolved": "1.1.1", @@ -175,6 +157,13 @@ "System.Runtime.CompilerServices.Unsafe": "6.0.0" } }, + "imageflow.net": { + "type": "Project", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[3.*, 4.0.0)", + "System.Text.Json": "[8.0.6, )" + } + }, "imazen.common": { "type": "Project", "dependencies": { @@ -183,14 +172,19 @@ } }, "net8.0": { - "Imageflow.AllPlatforms": { + "Imageflow.NativeRuntime.All": { "type": "Direct", - "requested": "[0.15.1, )", - "resolved": "0.15.1", - "contentHash": "Oiaeaz+0ZJip+n+0LD05boBiHKHBWVO79Wnitms3GYzjm9kxYqcSnPtQ9QRdUrwlP39dq8vurYtL4dAZaRmliQ==", + "requested": "[2.3.1-rc01, 4.0.0)", + "resolved": "2.3.1-rc01", + "contentHash": "JfHaGqSChFOZcyXH8cUXMF/fm1Bn32BBjgkLY+OSCWpVlL82lrsYr/Ue5Llf74q+tEEIIO7U7Xrw1NbugGKB8w==", "dependencies": { - "Imageflow.NativeRuntime.All": "2.3.1-rc01", - "Imageflow.Net": "0.15.1" + "Imageflow.NativeRuntime.linux-arm64": "2.3.1-rc01", + "Imageflow.NativeRuntime.linux-x64": "2.3.1-rc01", + "Imageflow.NativeRuntime.osx-arm64": "2.3.1-rc01", + "Imageflow.NativeRuntime.osx-x86_64": "2.3.1-rc01", + "Imageflow.NativeRuntime.win-arm64": "2.3.1-rc01", + "Imageflow.NativeRuntime.win-x86": "2.3.1-rc01", + "Imageflow.NativeRuntime.win-x86_64": "2.3.1-rc01" } }, "Microsoft.CodeAnalysis.PublicApiAnalyzers": { @@ -215,20 +209,6 @@ "resolved": "8.0.6", "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==" }, - "Imageflow.NativeRuntime.All": { - "type": "Transitive", - "resolved": "2.3.1-rc01", - "contentHash": "JfHaGqSChFOZcyXH8cUXMF/fm1Bn32BBjgkLY+OSCWpVlL82lrsYr/Ue5Llf74q+tEEIIO7U7Xrw1NbugGKB8w==", - "dependencies": { - "Imageflow.NativeRuntime.linux-arm64": "2.3.1-rc01", - "Imageflow.NativeRuntime.linux-x64": "2.3.1-rc01", - "Imageflow.NativeRuntime.osx-arm64": "2.3.1-rc01", - "Imageflow.NativeRuntime.osx-x86_64": "2.3.1-rc01", - "Imageflow.NativeRuntime.win-arm64": "2.3.1-rc01", - "Imageflow.NativeRuntime.win-x86": "2.3.1-rc01", - "Imageflow.NativeRuntime.win-x86_64": "2.3.1-rc01" - } - }, "Imageflow.NativeRuntime.linux-arm64": { "type": "Transitive", "resolved": "2.3.1-rc01", @@ -264,14 +244,6 @@ "resolved": "2.3.1-rc01", "contentHash": "rkUNn0BVgwobB05obVXEtrX4CvOpcIJ2A8InvO2vPJkRYgqjr5+GIKT0ItgCd90K30ozkWIyvG70KxDs/OL/dw==" }, - "Imageflow.Net": { - "type": "Transitive", - "resolved": "0.15.1", - "contentHash": "A+gdXZ4gxl++sjGEQIEh1r4CiRMBM5KD2Y0/993weNSPbfkUu+jrP4nGZ/nAOvjM/eRXFYP/gNnazaxDA2QeeQ==", - "dependencies": { - "Microsoft.IO.RecyclableMemoryStream": "[3.0.1, 4.0.0)" - } - }, "Microsoft.Build.Tasks.Git": { "type": "Transitive", "resolved": "1.1.1", @@ -343,6 +315,12 @@ "resolved": "4.5.1", "contentHash": "Zh8t8oqolRaFa9vmOZfdQm/qKejdqz0J9kr7o2Fu0vPeoH3BL1EOXipKWwkWtLT1JPzjByrF19fGuFlNbmPpiw==" }, + "imageflow.net": { + "type": "Project", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[3.*, 4.0.0)" + } + }, "imazen.common": { "type": "Project", "dependencies": { diff --git a/tests/Imageflow.Server.Tests/Imageflow.Server.Tests.csproj b/tests/Imageflow.Server.Tests/Imageflow.Server.Tests.csproj index 8e2e6ef..4b649fc 100644 --- a/tests/Imageflow.Server.Tests/Imageflow.Server.Tests.csproj +++ b/tests/Imageflow.Server.Tests/Imageflow.Server.Tests.csproj @@ -9,10 +9,13 @@ + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Imageflow.Server.Tests/KillbitsIntegrationTest.cs b/tests/Imageflow.Server.Tests/KillbitsIntegrationTest.cs new file mode 100644 index 0000000..1aac33c --- /dev/null +++ b/tests/Imageflow.Server.Tests/KillbitsIntegrationTest.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using Imageflow.Fluent; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Imageflow.Server.Tests +{ + /// + /// Integration tests for the three-layer killbits surface. These spin up + /// the full middleware and run actual image jobs. + /// + /// + /// These tests require a native runtime that implements + /// v1/context/set_policy + v1/context/get_net_support + /// (imageflow PR #720 and later). On older builds the server's startup + /// call to throws + /// with InvalidMessageEndpoint, so these tests are gated behind + /// the environment variable + /// IMAGEFLOW_TESTS_KILLBITS_NATIVE=1. The caller (CI workflow / + /// justfile) decides when to set it — the test body never silently + /// skips itself (per imazen global test policy). + /// + [Trait("Category", "Killbits")] + [Trait("RequiresNativeCapability", "killbits")] + public class KillbitsIntegrationTest + { + private static bool NativeAvailable() + { + var flag = Environment.GetEnvironmentVariable("IMAGEFLOW_TESTS_KILLBITS_NATIVE"); + return flag == "1" || string.Equals(flag, "true", StringComparison.OrdinalIgnoreCase); + } + + [SkippableFact] + public async Task Default_AvifEncodeRequest_Returns422() + { + SkipIfNoKillbitsNative(); + using var contentRoot = new TempContentRoot() + .AddResource("images/fire.jpg", "TestFiles.fire-umbrella-small.jpg"); + + var hostBuilder = new HostBuilder().ConfigureWebHost(webHost => + { + webHost.UseTestServer(); + webHost.Configure(app => + { + app.UseImageflow(new ImageflowMiddlewareOptions() + .SetMapWebRoot(false) + .MapPath("/", Path.Combine(contentRoot.PhysicalPath, "images")) + .SetSecurityPolicyOptions(new SecurityPolicyOptions())); // server defaults → AVIF denied + }); + }); + + using var host = await hostBuilder.StartAsync(); + using var client = host.GetTestClient(); + + using var resp = await client.GetAsync("/fire.jpg?format=avif"); + Assert.Equal((HttpStatusCode)422, resp.StatusCode); + var body = await resp.Content.ReadAsStringAsync(); + using var parsed = JsonDocument.Parse(body); + Assert.True(parsed.RootElement.TryGetProperty("error", out var err)); + Assert.Contains("not_available", err.GetString()); + } + + [SkippableFact] + public async Task OptInAvif_AvifEncodeRequest_Returns200() + { + SkipIfNoKillbitsNative(); + using var contentRoot = new TempContentRoot() + .AddResource("images/fire.jpg", "TestFiles.fire-umbrella-small.jpg"); + + var hostBuilder = new HostBuilder().ConfigureWebHost(webHost => + { + webHost.UseTestServer(); + webHost.Configure(app => + { + app.UseImageflow(new ImageflowMiddlewareOptions() + .SetMapWebRoot(false) + .MapPath("/", Path.Combine(contentRoot.PhysicalPath, "images")) + .SetSecurityPolicyOptions(new SecurityPolicyOptions + { + Formats = new FormatOptions + { + OptInEncode = new List { ImageFormat.Avif }, + }, + })); + }); + }); + + using var host = await hostBuilder.StartAsync(); + using var client = host.GetTestClient(); + + using var resp = await client.GetAsync("/fire.jpg?format=avif"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + } + + [SkippableFact] + public async Task InvalidPolicy_StartupFailsLoudly() + { + SkipIfNoKillbitsNative(); + using var contentRoot = new TempContentRoot() + .AddResource("images/fire.jpg", "TestFiles.fire-umbrella-small.jpg"); + + // Mutually-exclusive allow + deny should fail at validation time + // (Validate() is called before the native SetPolicy round-trip, so + // this test works even when the native lacks killbits endpoints — + // but we still gate it behind the capability flag because a fully + // configured server path makes the test meaningful for regression). + var hostBuilder = new HostBuilder().ConfigureWebHost(webHost => + { + webHost.UseTestServer(); + webHost.Configure(app => + { + app.UseImageflow(new ImageflowMiddlewareOptions() + .SetMapWebRoot(false) + .MapPath("/", Path.Combine(contentRoot.PhysicalPath, "images")) + .SetSecurityPolicyOptions(new SecurityPolicyOptions + { + Formats = new FormatOptions + { + AllowEncode = new List { ImageFormat.Jpeg }, + DenyEncode = new List { ImageFormat.Webp }, + }, + })); + }); + }); + + await Assert.ThrowsAsync(async () => + { + using var host = await hostBuilder.StartAsync(); + // The exception is raised inside the middleware constructor, + // which only runs on the first request. Issue a request to + // surface it. + using var client = host.GetTestClient(); + using var _ = await client.GetAsync("/fire.jpg"); + }); + } + + private static void SkipIfNoKillbitsNative() + { + Skip.IfNot(NativeAvailable(), + "IMAGEFLOW_TESTS_KILLBITS_NATIVE is not set; native runtime " + + "does not implement v1/context/set_policy. Enable this flag " + + "in CI once an imageflow runtime with PR #720 ships."); + } + } +} diff --git a/tests/Imageflow.Server.Tests/SecurityPolicyConfigurationBinderTests.cs b/tests/Imageflow.Server.Tests/SecurityPolicyConfigurationBinderTests.cs new file mode 100644 index 0000000..1c709a6 --- /dev/null +++ b/tests/Imageflow.Server.Tests/SecurityPolicyConfigurationBinderTests.cs @@ -0,0 +1,168 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using Imageflow.Fluent; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Imageflow.Server.Tests +{ + /// + /// Unit tests for . + /// Verifies JSON binding produces equivalent + /// and that malformed input surfaces as + /// . + /// + public class SecurityPolicyConfigurationBinderTests + { + private static IConfiguration BuildConfig(string json) + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + return new ConfigurationBuilder().AddJsonStream(stream).Build(); + } + + [Fact] + public void MissingSection_ReturnsNull() + { + var config = BuildConfig("{\"Something\": true}"); + var section = config.GetSection(SecurityPolicyConfigurationBinder.DefaultSectionPath); + var bound = SecurityPolicyConfigurationBinder.Bind(section); + Assert.Null(bound); + } + + [Fact] + public void FormatsOptInEncode_Binds() + { + const string json = "{" + + "\"Imageflow\":{\"Security\":{" + + " \"Formats\":{ \"OptInEncode\": [ \"avif\" ] }" + + "}}}"; + var config = BuildConfig(json); + var section = config.GetSection(SecurityPolicyConfigurationBinder.DefaultSectionPath); + var bound = SecurityPolicyConfigurationBinder.Bind(section); + Assert.NotNull(bound); + Assert.NotNull(bound!.Formats); + Assert.NotNull(bound.Formats!.OptInEncode); + Assert.Single(bound.Formats.OptInEncode!); + Assert.Equal(ImageFormat.Avif, bound.Formats.OptInEncode![0]); + } + + [Fact] + public void DenyLists_Bind() + { + const string json = "{" + + "\"Imageflow\":{\"Security\":{" + + " \"Formats\":{" + + " \"DenyEncode\": [ \"webp\" ]," + + " \"DenyDecode\": [ \"heic\" ]" + + " }" + + "}}}"; + var config = BuildConfig(json); + var section = config.GetSection(SecurityPolicyConfigurationBinder.DefaultSectionPath); + var bound = SecurityPolicyConfigurationBinder.Bind(section); + Assert.NotNull(bound); + Assert.Contains(ImageFormat.Webp, bound!.Formats!.DenyEncode!); + Assert.Contains(ImageFormat.Heic, bound.Formats.DenyDecode!); + } + + [Fact] + public void UnknownFormat_RaisesValidationException() + { + const string json = "{" + + "\"Imageflow\":{\"Security\":{" + + " \"Formats\":{ \"DenyEncode\": [ \"nonexistent_fmt\" ] }" + + "}}}"; + var config = BuildConfig(json); + var section = config.GetSection(SecurityPolicyConfigurationBinder.DefaultSectionPath); + var ex = Assert.Throws( + () => SecurityPolicyConfigurationBinder.Bind(section)); + Assert.Contains("nonexistent_fmt", ex.Message); + } + + [Fact] + public void CodecDenyEncoders_Bind() + { + const string json = "{" + + "\"Imageflow\":{\"Security\":{" + + " \"Codecs\":{ \"DenyEncoders\": [ \"mozjpeg_encoder\" ] }" + + "}}}"; + var config = BuildConfig(json); + var section = config.GetSection(SecurityPolicyConfigurationBinder.DefaultSectionPath); + var bound = SecurityPolicyConfigurationBinder.Bind(section); + Assert.NotNull(bound); + Assert.Contains(NamedEncoderName.MozjpegEncoder, bound!.Codecs!.DenyEncoders!); + } + + [Fact] + public void UnknownCodec_RaisesValidationException() + { + const string json = "{" + + "\"Imageflow\":{\"Security\":{" + + " \"Codecs\":{ \"DenyEncoders\": [ \"bogus_codec\" ] }" + + "}}}"; + var config = BuildConfig(json); + var section = config.GetSection(SecurityPolicyConfigurationBinder.DefaultSectionPath); + Assert.Throws( + () => SecurityPolicyConfigurationBinder.Bind(section)); + } + + [Fact] + public void ScalarLimits_Bind() + { + const string json = "{" + + "\"Imageflow\":{\"Security\":{" + + " \"MaxDecodeSize\":{ \"W\": 8000, \"H\": 8000, \"Megapixels\": 50 }," + + " \"MaxInputFileBytes\": 268435456," + + " \"MaxJsonBytes\": 67108864" + + "}}}"; + var config = BuildConfig(json); + var section = config.GetSection(SecurityPolicyConfigurationBinder.DefaultSectionPath); + var bound = SecurityPolicyConfigurationBinder.Bind(section); + Assert.NotNull(bound); + Assert.NotNull(bound!.MaxDecodeSize); + Assert.Equal(8000u, bound.MaxDecodeSize!.Value.MaxWidth); + Assert.Equal(8000u, bound.MaxDecodeSize.Value.MaxHeight); + Assert.Equal(50f, bound.MaxDecodeSize.Value.MaxMegapixels); + Assert.Equal(268435456UL, bound.MaxInputFileBytes); + Assert.Equal(67108864UL, bound.MaxJsonBytes); + } + + [Fact] + public void AllowAndDenyTogether_RaisesValidationException() + { + const string json = "{" + + "\"Imageflow\":{\"Security\":{" + + " \"Formats\":{" + + " \"AllowEncode\": [ \"jpeg\" ]," + + " \"DenyEncode\": [ \"webp\" ]" + + " }" + + "}}}"; + var config = BuildConfig(json); + var section = config.GetSection(SecurityPolicyConfigurationBinder.DefaultSectionPath); + Assert.Throws( + () => SecurityPolicyConfigurationBinder.Bind(section)); + } + + [Fact] + public void TableForm_Binds() + { + const string json = "{" + + "\"Imageflow\":{\"Security\":{" + + " \"Formats\":{" + + " \"Formats\":{" + + " \"avif\":{ \"Decode\": true, \"Encode\": true }," + + " \"jxl\": { \"Decode\": true, \"Encode\": false }" + + " }" + + " }" + + "}}}"; + var config = BuildConfig(json); + var section = config.GetSection(SecurityPolicyConfigurationBinder.DefaultSectionPath); + var bound = SecurityPolicyConfigurationBinder.Bind(section); + Assert.NotNull(bound); + Assert.NotNull(bound!.Formats!.Formats); + Assert.True(bound.Formats.Formats!.ContainsKey(ImageFormat.Avif)); + Assert.True(bound.Formats.Formats[ImageFormat.Avif].Encode); + Assert.False(bound.Formats.Formats[ImageFormat.Jxl].Encode); + } + } +} diff --git a/tests/Imageflow.Server.Tests/SecurityPolicyOptionsTests.cs b/tests/Imageflow.Server.Tests/SecurityPolicyOptionsTests.cs new file mode 100644 index 0000000..894ea71 --- /dev/null +++ b/tests/Imageflow.Server.Tests/SecurityPolicyOptionsTests.cs @@ -0,0 +1,235 @@ +using System.Collections.Generic; +using System.Linq; +using Imageflow.Fluent; +using Xunit; + +namespace Imageflow.Server.Tests +{ + /// + /// Unit tests for the three-layer killbits surface on + /// . Verifies: + /// - mutual-exclusion validation + /// - AVIF/JXL opt-in translation + /// - allow-list / deny-list / table form merges + /// - explicit operator control disables server default per format + /// + public class SecurityPolicyOptionsTests + { + [Fact] + public void Default_DeniesAvifAndJxlEncode() + { + var policy = new SecurityPolicyOptions(); + var effective = policy.BuildEffectiveSecurityOptions(); + Assert.NotNull(effective.Formats); + Assert.NotNull(effective.Formats!.DenyEncode); + Assert.Contains(ImageFormat.Avif, effective.Formats.DenyEncode!); + Assert.Contains(ImageFormat.Jxl, effective.Formats.DenyEncode!); + Assert.Null(effective.Formats.AllowEncode); + Assert.Null(effective.Formats.AllowDecode); + Assert.Null(effective.Formats.Formats); + } + + [Fact] + public void OptInAvif_StillDeniesJxl() + { + var policy = new SecurityPolicyOptions + { + Formats = new FormatOptions + { + OptInEncode = new List { ImageFormat.Avif }, + }, + }; + var effective = policy.BuildEffectiveSecurityOptions(); + Assert.NotNull(effective.Formats!.DenyEncode); + Assert.DoesNotContain(ImageFormat.Avif, effective.Formats.DenyEncode!); + Assert.Contains(ImageFormat.Jxl, effective.Formats.DenyEncode!); + } + + [Fact] + public void OptInBoth_DeniesNeither() + { + var policy = new SecurityPolicyOptions + { + Formats = new FormatOptions + { + OptInEncode = new List { ImageFormat.Avif, ImageFormat.Jxl }, + }, + }; + var effective = policy.BuildEffectiveSecurityOptions(); + // Nothing to deny beyond the opt-ins; no killbits required. + Assert.True(effective.Formats == null + || effective.Formats.DenyEncode == null + || (!effective.Formats.DenyEncode!.Contains(ImageFormat.Avif) + && !effective.Formats.DenyEncode!.Contains(ImageFormat.Jxl))); + } + + [Fact] + public void AllowEncodeList_DisablesServerDefaultForAvifAndJxl() + { + var policy = new SecurityPolicyOptions + { + Formats = new FormatOptions + { + AllowEncode = new List { ImageFormat.Jpeg, ImageFormat.Png }, + }, + }; + var effective = policy.BuildEffectiveSecurityOptions(); + Assert.NotNull(effective.Formats); + Assert.NotNull(effective.Formats!.AllowEncode); + Assert.Equal(2, effective.Formats.AllowEncode!.Count); + Assert.Contains(ImageFormat.Jpeg, effective.Formats.AllowEncode); + Assert.Contains(ImageFormat.Png, effective.Formats.AllowEncode); + // Allow-list and deny-list are mutually exclusive — no server-default deny list. + Assert.Null(effective.Formats.DenyEncode); + } + + [Fact] + public void TableFormMentionsAvif_DisablesServerDefaultForAvif() + { + // Explicit operator decision: AVIF encode allowed via table. + var policy = new SecurityPolicyOptions + { + Formats = new FormatOptions + { + Formats = new Dictionary + { + { ImageFormat.Avif, new FormatPermissions(decode: true, encode: true) }, + }, + }, + }; + var effective = policy.BuildEffectiveSecurityOptions(); + Assert.NotNull(effective.Formats); + Assert.NotNull(effective.Formats!.Formats); + Assert.True(effective.Formats.Formats![ImageFormat.Avif].Encode); + // JXL wasn't mentioned → server default still applies, but since + // we're in table form, the default is injected as a table entry. + Assert.True(effective.Formats.Formats.ContainsKey(ImageFormat.Jxl)); + Assert.False(effective.Formats.Formats[ImageFormat.Jxl].Encode); + Assert.Null(effective.Formats.AllowEncode); + Assert.Null(effective.Formats.DenyEncode); + } + + [Fact] + public void DenyEncodeList_MergesWithServerDefault() + { + var policy = new SecurityPolicyOptions + { + Formats = new FormatOptions + { + DenyEncode = new List { ImageFormat.Webp }, + }, + }; + var effective = policy.BuildEffectiveSecurityOptions(); + Assert.NotNull(effective.Formats!.DenyEncode); + Assert.Contains(ImageFormat.Webp, effective.Formats.DenyEncode!); + Assert.Contains(ImageFormat.Avif, effective.Formats.DenyEncode!); + Assert.Contains(ImageFormat.Jxl, effective.Formats.DenyEncode!); + } + + [Fact] + public void DenyEncodeList_DuplicatesAreFoldedOut() + { + var policy = new SecurityPolicyOptions + { + Formats = new FormatOptions + { + DenyEncode = new List { ImageFormat.Avif, ImageFormat.Webp }, + }, + }; + var effective = policy.BuildEffectiveSecurityOptions(); + var avifCount = effective.Formats!.DenyEncode!.Count(f => f == ImageFormat.Avif); + Assert.Equal(1, avifCount); + } + + [Fact] + public void AllowAndDenyForDecode_RaisesValidationException() + { + var policy = new SecurityPolicyOptions + { + Formats = new FormatOptions + { + AllowDecode = new List { ImageFormat.Jpeg }, + DenyDecode = new List { ImageFormat.Png }, + }, + }; + var ex = Assert.Throws(() => policy.Validate()); + Assert.Contains("decode", ex.Message); + } + + [Fact] + public void ListAndTable_RaisesValidationException() + { + var policy = new SecurityPolicyOptions + { + Formats = new FormatOptions + { + AllowDecode = new List { ImageFormat.Jpeg }, + Formats = new Dictionary + { + { ImageFormat.Webp, new FormatPermissions(true, true) }, + }, + }, + }; + var ex = Assert.Throws(() => policy.Validate()); + Assert.Contains("single form", ex.Message); + } + + [Fact] + public void CodecAllowAndDeny_RaisesValidationException() + { + var policy = new SecurityPolicyOptions + { + Codecs = new CodecOptions + { + AllowEncoders = new List { NamedEncoderName.ZenJpegEncoder }, + DenyEncoders = new List { NamedEncoderName.MozjpegEncoder }, + }, + }; + Assert.Throws(() => policy.Validate()); + } + + [Fact] + public void CustomRiskyEncodeList_TakesEffect() + { + var policy = new SecurityPolicyOptions + { + ServerRiskyEncode = new[] { ImageFormat.Webp }, + }; + var effective = policy.BuildEffectiveSecurityOptions(); + Assert.NotNull(effective.Formats!.DenyEncode); + Assert.Contains(ImageFormat.Webp, effective.Formats.DenyEncode!); + Assert.DoesNotContain(ImageFormat.Avif, effective.Formats.DenyEncode!); + } + + [Fact] + public void Scalars_PassThroughToEffective() + { + var policy = new SecurityPolicyOptions + { + MaxDecodeSize = new FrameSizeLimit(8000, 8000, 50f), + MaxInputFileBytes = 256 * 1024 * 1024, + MaxJsonBytes = 64 * 1024 * 1024, + }; + var effective = policy.BuildEffectiveSecurityOptions(); + Assert.NotNull(effective.MaxDecodeSize); + Assert.Equal(8000u, effective.MaxDecodeSize!.Value.MaxWidth); + Assert.Equal(256UL * 1024 * 1024, effective.MaxInputFileBytes); + Assert.Equal(64UL * 1024 * 1024, effective.MaxJsonBytes); + } + + [Fact] + public void CodecDenyEncoders_SurfacesInEffective() + { + var policy = new SecurityPolicyOptions + { + Codecs = new CodecOptions + { + DenyEncoders = new List { NamedEncoderName.MozjpegEncoder }, + }, + }; + var effective = policy.BuildEffectiveSecurityOptions(); + Assert.NotNull(effective.Codecs); + Assert.Contains(NamedEncoderName.MozjpegEncoder, effective.Codecs!.DenyEncoders!); + } + } +} diff --git a/tests/Imageflow.Server.Tests/packages.lock.json b/tests/Imageflow.Server.Tests/packages.lock.json index f38baa5..60c00e5 100644 --- a/tests/Imageflow.Server.Tests/packages.lock.json +++ b/tests/Imageflow.Server.Tests/packages.lock.json @@ -17,6 +17,29 @@ "System.IO.Pipelines": "4.7.1" } }, + "Microsoft.Extensions.Configuration": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "L89DLNuimOghjV3tLx0ArFDwVEJD6+uGB3BMCMX01kaLzXkaXHb2021xOMl2QOxUxbdePKUZsUY7n2UUkycjRg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "8.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "System.Text.Json": "8.0.5" + } + }, "Microsoft.Extensions.FileProviders.Embedded": { "type": "Direct", "requested": "[2.2.0, )", @@ -63,6 +86,16 @@ "resolved": "2.4.5", "contentHash": "OwHamvBdUKgqsXfBzWiCW/O98BTx81UKzx2bieIOQI7CZFE5NEQZGi8PBQGIKawDW96xeRffiNf20SjfC0x9hw==" }, + "Xunit.SkippableFact": { + "type": "Direct", + "requested": "[1.4.13, )", + "resolved": "1.4.13", + "contentHash": "IyzZNvJEtXGlXrzxDiSbtH5Lyxf4iJdRQADuyjGdDf00LjXRLJwIoezQNFhFGKTMtvk8IIgaSHxW4mAV4O7b8A==", + "dependencies": { + "Validation": "2.4.18", + "xunit.extensibility.execution": "2.4.0" + } + }, "AWSSDK.Core": { "type": "Transitive", "resolved": "3.7.103.11", @@ -125,15 +158,6 @@ "System.Xml.XmlDocument": "4.3.0" } }, - "Imageflow.AllPlatforms": { - "type": "Transitive", - "resolved": "0.15.1", - "contentHash": "Oiaeaz+0ZJip+n+0LD05boBiHKHBWVO79Wnitms3GYzjm9kxYqcSnPtQ9QRdUrwlP39dq8vurYtL4dAZaRmliQ==", - "dependencies": { - "Imageflow.NativeRuntime.All": "2.3.1-rc01", - "Imageflow.Net": "0.15.1" - } - }, "Imageflow.NativeRuntime.All": { "type": "Transitive", "resolved": "2.3.1-rc01", @@ -183,15 +207,6 @@ "resolved": "2.3.1-rc01", "contentHash": "rkUNn0BVgwobB05obVXEtrX4CvOpcIJ2A8InvO2vPJkRYgqjr5+GIKT0ItgCd90K30ozkWIyvG70KxDs/OL/dw==" }, - "Imageflow.Net": { - "type": "Transitive", - "resolved": "0.15.1", - "contentHash": "A+gdXZ4gxl++sjGEQIEh1r4CiRMBM5KD2Y0/993weNSPbfkUu+jrP4nGZ/nAOvjM/eRXFYP/gNnazaxDA2QeeQ==", - "dependencies": { - "Microsoft.IO.RecyclableMemoryStream": "[3.0.1, 4.0.0)", - "System.Text.Json": "8.0.6" - } - }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", "resolved": "1.1.1", @@ -204,10 +219,22 @@ }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", "dependencies": { - "Microsoft.Extensions.Primitives": "2.2.0" + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "EJzSNO9oaAXnTdtdNO6npPRsIIeZCBSNmdQ091VDO7fBiOtJAAeEq6dtrVXIi3ZyjC5XRSAtVvF8SzcneRHqKQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Physical": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" } }, "Microsoft.Extensions.DependencyInjection": { @@ -225,12 +252,27 @@ }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "resolved": "8.0.0", + "contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "UboiXxpPUpwulHvIAVE36Knq0VSHaAmfrFkegLyBZeaADuKezJ/AIXYAW8F5GBlGk/VaibN2k/Zn1ca8YAfVdA==", "dependencies": { - "Microsoft.Extensions.Primitives": "2.2.0" + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileSystemGlobbing": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" } }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OK+670i7esqlQrPjdIKRbsyMCe9g5kSLpRRQGSr4Q58AOYEe/hCnfLZprh7viNisSUUQZmMrbbuDaIrP+V1ebQ==" + }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", "resolved": "2.2.0", @@ -280,8 +322,11 @@ }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "cI/VWn9G1fghXrNDagX9nYaaB/nokkZn0HYAawGaELQrl8InSezfe9OnfPZLcJq3esXxygh3hkq2c3qoV3SDyQ==" + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", @@ -1393,6 +1438,11 @@ "System.Xml.ReaderWriter": "4.3.0" } }, + "Validation": { + "type": "Transitive", + "resolved": "2.4.18", + "contentHash": "NfvWJ1QeuZ1FQCkqgXTu1cOkRkbNCfxs4Tat+abXLwom6OXbULVhRGp34BTvVB4XPxj6VIAl7KfLfStXMt/Ehw==" + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -1438,10 +1488,18 @@ "xunit.extensibility.core": "[2.4.1]" } }, + "imageflow.net": { + "type": "Project", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[3.*, 4.0.0)", + "System.Text.Json": "[8.0.6, )" + } + }, "imageflow.server": { "type": "Project", "dependencies": { - "Imageflow.AllPlatforms": "[0.15.1, )", + "Imageflow.NativeRuntime.All": "[2.3.1-rc01, 4.0.0)", + "Imageflow.Net": "[0.1.0--notset, )", "Imazen.Common": "[0.1.0--notset, )", "System.Text.Json": "[8.0.6, )" } @@ -1526,6 +1584,28 @@ "System.IO.Pipelines": "4.7.1" } }, + "Microsoft.Extensions.Configuration": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "L89DLNuimOghjV3tLx0ArFDwVEJD6+uGB3BMCMX01kaLzXkaXHb2021xOMl2QOxUxbdePKUZsUY7n2UUkycjRg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "8.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0" + } + }, "Microsoft.Extensions.FileProviders.Embedded": { "type": "Direct", "requested": "[2.2.0, )", @@ -1572,6 +1652,16 @@ "resolved": "2.4.5", "contentHash": "OwHamvBdUKgqsXfBzWiCW/O98BTx81UKzx2bieIOQI7CZFE5NEQZGi8PBQGIKawDW96xeRffiNf20SjfC0x9hw==" }, + "Xunit.SkippableFact": { + "type": "Direct", + "requested": "[1.4.13, )", + "resolved": "1.4.13", + "contentHash": "IyzZNvJEtXGlXrzxDiSbtH5Lyxf4iJdRQADuyjGdDf00LjXRLJwIoezQNFhFGKTMtvk8IIgaSHxW4mAV4O7b8A==", + "dependencies": { + "Validation": "2.4.18", + "xunit.extensibility.execution": "2.4.0" + } + }, "AWSSDK.Core": { "type": "Transitive", "resolved": "3.7.103.11", @@ -1634,15 +1724,6 @@ "System.Xml.XmlDocument": "4.3.0" } }, - "Imageflow.AllPlatforms": { - "type": "Transitive", - "resolved": "0.15.1", - "contentHash": "Oiaeaz+0ZJip+n+0LD05boBiHKHBWVO79Wnitms3GYzjm9kxYqcSnPtQ9QRdUrwlP39dq8vurYtL4dAZaRmliQ==", - "dependencies": { - "Imageflow.NativeRuntime.All": "2.3.1-rc01", - "Imageflow.Net": "0.15.1" - } - }, "Imageflow.NativeRuntime.All": { "type": "Transitive", "resolved": "2.3.1-rc01", @@ -1692,14 +1773,6 @@ "resolved": "2.3.1-rc01", "contentHash": "rkUNn0BVgwobB05obVXEtrX4CvOpcIJ2A8InvO2vPJkRYgqjr5+GIKT0ItgCd90K30ozkWIyvG70KxDs/OL/dw==" }, - "Imageflow.Net": { - "type": "Transitive", - "resolved": "0.15.1", - "contentHash": "A+gdXZ4gxl++sjGEQIEh1r4CiRMBM5KD2Y0/993weNSPbfkUu+jrP4nGZ/nAOvjM/eRXFYP/gNnazaxDA2QeeQ==", - "dependencies": { - "Microsoft.IO.RecyclableMemoryStream": "[3.0.1, 4.0.0)" - } - }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", "resolved": "1.1.1", @@ -1712,10 +1785,22 @@ }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "65MrmXCziWaQFrI0UHkQbesrX5wTwf9XPjY5yFm/VkgJKFJ5gqvXRoXjIZcf2wLi5ZlwGz/oMYfyURVCWbM5iw==", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "EJzSNO9oaAXnTdtdNO6npPRsIIeZCBSNmdQ091VDO7fBiOtJAAeEq6dtrVXIi3ZyjC5XRSAtVvF8SzcneRHqKQ==", "dependencies": { - "Microsoft.Extensions.Primitives": "2.2.0" + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileProviders.Physical": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" } }, "Microsoft.Extensions.DependencyInjection": { @@ -1733,12 +1818,27 @@ }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "2.2.0", - "contentHash": "EcnaSsPTqx2MGnHrmWOD0ugbuuqVT8iICqSqPzi45V5/MA1LjUNb0kwgcxBGqizV1R+WeBK7/Gw25Jzkyk9bIw==", + "resolved": "8.0.0", + "contentHash": "ZbaMlhJlpisjuWbvXr4LdAst/1XxH3vZ6A0BsgTphZ2L4PGuxRLz7Jr/S7mkAAnOn78Vu0fKhEgNF5JO3zfjqQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "UboiXxpPUpwulHvIAVE36Knq0VSHaAmfrFkegLyBZeaADuKezJ/AIXYAW8F5GBlGk/VaibN2k/Zn1ca8YAfVdA==", "dependencies": { - "Microsoft.Extensions.Primitives": "2.2.0" + "Microsoft.Extensions.FileProviders.Abstractions": "8.0.0", + "Microsoft.Extensions.FileSystemGlobbing": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" } }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OK+670i7esqlQrPjdIKRbsyMCe9g5kSLpRRQGSr4Q58AOYEe/hCnfLZprh7viNisSUUQZmMrbbuDaIrP+V1ebQ==" + }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", "resolved": "2.2.0", @@ -1788,8 +1888,8 @@ }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "5.0.0", - "contentHash": "cI/VWn9G1fghXrNDagX9nYaaB/nokkZn0HYAawGaELQrl8InSezfe9OnfPZLcJq3esXxygh3hkq2c3qoV3SDyQ==" + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", @@ -2889,6 +2989,11 @@ "System.Xml.ReaderWriter": "4.3.0" } }, + "Validation": { + "type": "Transitive", + "resolved": "2.4.18", + "contentHash": "NfvWJ1QeuZ1FQCkqgXTu1cOkRkbNCfxs4Tat+abXLwom6OXbULVhRGp34BTvVB4XPxj6VIAl7KfLfStXMt/Ehw==" + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -2934,10 +3039,17 @@ "xunit.extensibility.core": "[2.4.1]" } }, + "imageflow.net": { + "type": "Project", + "dependencies": { + "Microsoft.IO.RecyclableMemoryStream": "[3.*, 4.0.0)" + } + }, "imageflow.server": { "type": "Project", "dependencies": { - "Imageflow.AllPlatforms": "[0.15.1, )", + "Imageflow.NativeRuntime.All": "[2.3.1-rc01, 4.0.0)", + "Imageflow.Net": "[0.1.0--notset, )", "Imazen.Common": "[0.1.0--notset, )", "System.Text.Json": "[8.0.6, )" } diff --git a/tests/api-surface/Imageflow.Server.txt b/tests/api-surface/Imageflow.Server.txt index 38d8053..6566b5a 100644 --- a/tests/api-surface/Imageflow.Server.txt +++ b/tests/api-surface/Imageflow.Server.txt @@ -11,12 +11,30 @@ namespace Imageflow.Server LocalHost = 1, AnyHost = 2, } + public sealed class CodecOptions + { + public CodecOptions() { } + public System.Collections.Generic.IList? AllowDecoders { get; set; } + public System.Collections.Generic.IList? AllowEncoders { get; set; } + public System.Collections.Generic.IList? DenyDecoders { get; set; } + public System.Collections.Generic.IList? DenyEncoders { get; set; } + } public enum EnforceLicenseWith { RedDotWatermark = 0, Http422Error = 1, Http402Error = 2, } + public sealed class FormatOptions + { + public FormatOptions() { } + public System.Collections.Generic.IList? AllowDecode { get; set; } + public System.Collections.Generic.IList? AllowEncode { get; set; } + public System.Collections.Generic.IList? DenyDecode { get; set; } + public System.Collections.Generic.IList? DenyEncode { get; set; } + public System.Collections.Generic.IDictionary? Formats { get; set; } + public System.Collections.Generic.IList? OptInEncode { get; set; } + } public class ImageflowMiddleware { public ImageflowMiddleware(Microsoft.AspNetCore.Http.RequestDelegate next, Microsoft.AspNetCore.Hosting.IWebHostEnvironment env, System.Collections.Generic.IEnumerable> logger, System.Collections.Generic.IEnumerable diskCaches, System.Collections.Generic.IEnumerable streamCaches, System.Collections.Generic.IEnumerable blobProviders, Imageflow.Server.ImageflowMiddlewareOptions options) { } @@ -33,12 +51,14 @@ namespace Imageflow.Server public bool AllowDiskCaching { get; set; } public bool ApplyDefaultCommandsToQuerylessUrls { get; set; } public string DefaultCacheControlString { get; set; } + public bool ExposeNetSupportHeader { get; set; } public Imageflow.Fluent.SecurityOptions JobSecurityOptions { get; set; } public bool MapWebRoot { get; set; } public System.Collections.Generic.IReadOnlyCollection MappedPaths { get; } public string MyOpenSourceProjectUrl { get; set; } public System.Collections.Generic.IReadOnlyCollection NamedWatermarks { get; } public Imageflow.Server.RequestSignatureOptions RequestSignatureOptions { get; set; } + public Imageflow.Server.SecurityPolicyOptions SecurityPolicyOptions { get; set; } public bool UsePresetsExclusively { get; set; } public Imageflow.Server.ImageflowMiddlewareOptions AddCommandDefault(string key, string value) { } public Imageflow.Server.ImageflowMiddlewareOptions AddPostRewriteAuthorizationHandler(string pathPrefix, System.Func handler) { } @@ -56,11 +76,13 @@ namespace Imageflow.Server public Imageflow.Server.ImageflowMiddlewareOptions SetDefaultCacheControlString(string cacheControlString) { } public Imageflow.Server.ImageflowMiddlewareOptions SetDiagnosticsPageAccess(Imageflow.Server.AccessDiagnosticsFrom accessDiagnosticsFrom) { } public Imageflow.Server.ImageflowMiddlewareOptions SetDiagnosticsPagePassword(string password) { } + public Imageflow.Server.ImageflowMiddlewareOptions SetExposeNetSupportHeader(bool value) { } public Imageflow.Server.ImageflowMiddlewareOptions SetJobSecurityOptions(Imageflow.Fluent.SecurityOptions securityOptions) { } public Imageflow.Server.ImageflowMiddlewareOptions SetLicenseKey(Imageflow.Server.EnforceLicenseWith enforcementMethod, string licenseKey) { } public Imageflow.Server.ImageflowMiddlewareOptions SetMapWebRoot(bool value) { } public Imageflow.Server.ImageflowMiddlewareOptions SetMyOpenSourceProjectUrl(string myOpenSourceProjectUrl) { } public Imageflow.Server.ImageflowMiddlewareOptions SetRequestSignatureOptions(Imageflow.Server.RequestSignatureOptions options) { } + public Imageflow.Server.ImageflowMiddlewareOptions SetSecurityPolicyOptions(Imageflow.Server.SecurityPolicyOptions policyOptions) { } public Imageflow.Server.ImageflowMiddlewareOptions SetUsePresetsExclusively(bool value) { } } public class NamedWatermark @@ -102,6 +124,39 @@ namespace Imageflow.Server public RequestSignatureOptions(Imageflow.Server.SignatureRequired defaultRequirement, System.Collections.Generic.IEnumerable defaultSigningKeys) { } public Imageflow.Server.RequestSignatureOptions ForPrefix(string prefix, System.StringComparison prefixComparison, Imageflow.Server.SignatureRequired requirement, System.Collections.Generic.IEnumerable signingKeys) { } } + public static class SecurityPolicyConfigurationBinder + { + public const string DefaultSectionPath = "Imageflow:Security"; + public static Imageflow.Server.SecurityPolicyOptions? Bind(Microsoft.Extensions.Configuration.IConfiguration section) { } + } + public sealed class SecurityPolicyOptions + { + public static readonly System.Collections.Generic.IReadOnlyList ServerRiskyEncodeDefault; + public SecurityPolicyOptions() { } + public Imageflow.Server.CodecOptions? Codecs { get; set; } + public Imageflow.Server.FormatOptions? Formats { get; set; } + public Imageflow.Fluent.FrameSizeLimit? MaxDecodeSize { get; set; } + public Imageflow.Fluent.FrameSizeLimit? MaxEncodeSize { get; set; } + public Imageflow.Fluent.FrameSizeLimit? MaxFrameSize { get; set; } + public ulong? MaxInputFileBytes { get; set; } + public ulong? MaxJsonBytes { get; set; } + public ulong? MaxTotalFilePixels { get; set; } + public System.Collections.Generic.IReadOnlyList ServerRiskyEncode { get; set; } + public Imageflow.Fluent.SecurityOptions BuildEffectiveSecurityOptions() { } + public void Validate() { } + } + public sealed class SecurityPolicyValidationException : System.Exception + { + public SecurityPolicyValidationException(string message) { } + public SecurityPolicyValidationException(string message, System.Exception inner) { } + } + public sealed class SecurityPolicyValidator + { + public SecurityPolicyValidator() { } + public Imageflow.Fluent.SecurityOptions? EffectiveJobSecurity { get; } + public Imageflow.Bindings.NetSupportResponse? EffectiveNetSupport { get; } + public void Apply(Imageflow.Server.SecurityPolicyOptions options, Microsoft.Extensions.Logging.ILogger? logger = null) { } + } public enum SignatureRequired { ForAllRequests = 0,