Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
*.userosscache
*.sln.docstates

# Agent coordination marker (never committed)
.workongoing

# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs

Expand Down
185 changes: 185 additions & 0 deletions docs/killbits.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 33 additions & 1 deletion src/Imageflow.Server/ImageJobInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Imageflow.Fluent;

Check failure on line 8 in src/Imageflow.Server/ImageJobInfo.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-24-arm-16gb, ubuntu-24-arm-16gb, true)

The type or namespace name 'Fluent' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 8 in src/Imageflow.Server/ImageJobInfo.cs

View workflow job for this annotation

GitHub Actions / build (win-x86_64, windows-2022, false)

The type or namespace name 'Fluent' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 8 in src/Imageflow.Server/ImageJobInfo.cs

View workflow job for this annotation

GitHub Actions / build (macos-15-intel, macos-15-intel, true)

The type or namespace name 'Fluent' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 8 in src/Imageflow.Server/ImageJobInfo.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, ubuntu-latest, true)

The type or namespace name 'Fluent' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 8 in src/Imageflow.Server/ImageJobInfo.cs

View workflow job for this annotation

GitHub Actions / build (windows-11-arm, windows-11-arm, true)

The type or namespace name 'Fluent' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 8 in src/Imageflow.Server/ImageJobInfo.cs

View workflow job for this annotation

GitHub Actions / build (macos-14-arm, macos-14, true)

The type or namespace name 'Fluent' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 8 in src/Imageflow.Server/ImageJobInfo.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-22.04, ubuntu-22.04, true, true)

The type or namespace name 'Fluent' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)
using Imazen.Common.Helpers;
using Imazen.Common.Instrumentation;
using Imazen.Common.Storage;
Expand Down Expand Up @@ -430,11 +430,14 @@
}

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)
Expand Down Expand Up @@ -466,6 +469,35 @@
}
}
}

/// <summary>
/// Combine the legacy per-job <see cref="SecurityOptions"/> with the
/// narrowing form derived from
/// <see cref="SecurityPolicyOptions"/>. 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).
/// </summary>
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{
Expand Down
23 changes: 22 additions & 1 deletion src/Imageflow.Server/Imageflow.Server.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,28 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Imageflow.AllPlatforms" Version="0.15.1" />
<!--
DEV-TIME PIN (feat/killbits-server-integration):
Pin to a local ProjectReference on the feat/killbits-dotnet-surface
branch of imageflow-dotnet so the killbits surface types
(SecurityOptions.Formats / .Codecs, JobContext.SetPolicy,
KillbitsDeniedException, etc.) resolve during development.

Native runtime for dev: pair with Imageflow.NativeRuntime.All 2.3.1-rc01
or later (the `imageflow2`-branch rc), which carries
v1/context/set_policy + v1/context/get_net_support. Older runtimes will
throw ImageflowException with InvalidMessageEndpoint at startup.

RELEASE PIN (apply before merge):
Replace this ProjectReference with the published prerelease once
imageflow#720 + imageflow-dotnet#68 ship:
<PackageReference Include="Imageflow.AllPlatforms"
Version="[3.0.0-alpha.0, 4.0.0)" />
The accept range starts at 3.0.0-alpha.0 so v3 prereleases satisfy the
constraint, capped below v4 to catch future major bumps.
-->
<ProjectReference Include="..\..\..\imageflow-dotnet\src\Imageflow\Imageflow.Net.csproj" />
<PackageReference Include="Imageflow.NativeRuntime.All" Version="[2.3.1-rc01, 4.0.0)" />
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
Expand Down
83 changes: 83 additions & 0 deletions src/Imageflow.Server/ImageflowMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Imageflow.Bindings;

Check failure on line 11 in src/Imageflow.Server/ImageflowMiddleware.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-24-arm-16gb, ubuntu-24-arm-16gb, true)

The type or namespace name 'Bindings' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 11 in src/Imageflow.Server/ImageflowMiddleware.cs

View workflow job for this annotation

GitHub Actions / build (win-x86_64, windows-2022, false)

The type or namespace name 'Bindings' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 11 in src/Imageflow.Server/ImageflowMiddleware.cs

View workflow job for this annotation

GitHub Actions / build (macos-15-intel, macos-15-intel, true)

The type or namespace name 'Bindings' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 11 in src/Imageflow.Server/ImageflowMiddleware.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, ubuntu-latest, true)

The type or namespace name 'Bindings' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 11 in src/Imageflow.Server/ImageflowMiddleware.cs

View workflow job for this annotation

GitHub Actions / build (windows-11-arm, windows-11-arm, true)

The type or namespace name 'Bindings' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 11 in src/Imageflow.Server/ImageflowMiddleware.cs

View workflow job for this annotation

GitHub Actions / build (macos-14-arm, macos-14, true)

The type or namespace name 'Bindings' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)

Check failure on line 11 in src/Imageflow.Server/ImageflowMiddleware.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-22.04, ubuntu-22.04, true, true)

The type or namespace name 'Bindings' does not exist in the namespace 'Imageflow' (are you missing an assembly reference?)
using Imazen.Common.Extensibility.ClassicDiskCache;
using Imazen.Common.Extensibility.StreamCache;
using Imazen.Common.Instrumentation;
Expand Down Expand Up @@ -82,6 +82,19 @@

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);
Expand Down Expand Up @@ -219,6 +232,11 @@
{
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;
Expand All @@ -238,6 +256,71 @@
}
}

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.";
Expand Down
Loading
Loading