# Polymorph & SignalR Type-binding Security — Implementation Plan

Working notes summarizing the design discussion for replacing wire-supplied
`Type.GetType(AQN)` calls with a registry-gated `IAcTypeBinder` mechanism. Covers
the AcBinary polymorph path (`ObjectWithTypeName` marker) and the
`AyCodeBinaryHubProtocol` `HasType` header. Single binder, two consumers.

> Related: `BINARY_ISSUES.md` (no entry yet — to be opened on landing),
> `../../../AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md#accore-sbp-i-r8k3`
> (the originating Critical-severity issue).

## Motivation

The wire format currently emits `Type.AssemblyQualifiedName` for polymorph
property values (AcBinary `ObjectWithTypeName` marker) and for the SignalR
data-arg type header (`AyCodeBinaryHubProtocol.WriteHeader` HasType flag). The
deserializer calls `Type.GetType(typeName)` to resolve — wire-supplied input
controls type instantiation, the textbook deserialization-gadget pattern that
caused `BinaryFormatter` deprecation.

### Threat-model realism

| Deployment | Realistic risk |
|---|---|
| Authenticated SignalR + TLS + first-party DTOs | Low — attacker must already control wire bytes (= compromised endpoint or TLS-MITM), at which point AQN-injection is the *N*-th problem, not the first. |
| Open hub or unauthenticated endpoint | Higher — any sender can choose AQN, classic gadget attack. |
| NuGet-public-API consumer (unknown deployments) | Unknown — defense-in-depth required as a baseline contract. |

The current production deployment (authenticated, TLS, first-party graph) sits
firmly in the *low* category. The fix matters anyway because:

1. **NuGet-public-API context**: `AcBinarySerializer` is planned as a public
   NuGet package. Consumer deployments are unknown — security defaults must
   handle the worst case.
2. **Industry expectation**: no-controlled-AQN-deserialization is the standard
   contract for any serializer post-`BinaryFormatter`. Auditors will flag it
   regardless of the current deployment context.
3. **Cost is negligible**: a single dictionary lookup at deserialize-time on a
   rare path (polymorph or SignalR HasType). Zero hot-path impact.

### What this prevents

- `Type.GetType("System.Diagnostics.Process, ...")` → injection of dangerous
  BCL types into the deserialization graph
- `Type.GetType("Attacker.UploadedAssembly.Gadget, ...")` → loading
  attacker-deposited assemblies via static-ctor side effects

### What this does NOT prevent (out of scope — by design)

- Wire-data value tampering (transport-layer concern: TLS).
- **Structural type-confusion within the allowlist** — closes itself: the SGen
  and runtime deserializers do strongly-typed property assignment, so a
  wire-supplied subtype that does not satisfy the target's assignability rules
  fails with `InvalidCastException` at assign time, regardless of binder
  membership. **Semantic** type-confusion (correct base, attacker-preferred
  but legitimate subtype with side-effecting setters) is the application's
  validation concern, not the serializer's.
- Authenticated insider exploitation of the serialize-graph (auth-layer concern).

### Auto-feed scope is process-wide

The implicit `BinaryDeserializeTypeMetadata` ctor-feed produces a **single
process-wide trust set**: the polymorph allowlist becomes the union of every
type that has ever been deserialized in this process, not a per-polymorph-site
filter. For most apps this is the desired behavior (less ceremony, identical
to "what was ever expected on the wire").

If a narrower polymorph surface is required (e.g. a specific `object`-typed
property must only ever accept `Dog` / `Cat` and never any other allowed
type), the application disables the implicit feed and seeds the binder only
through explicit `Allow<T>()`. Per-property polymorph-subset enforcement
remains an application-level concern — the binder is a flat allowlist, not a
per-site union-type discriminator.

## Security tiers — runtime gate to compile-out

The binder gate is the baseline defense. Two stronger tiers layer on top using
the **same SGen feature-flag mechanism** that already gates the perf features
(interning / ref-handling / metadata) — no new infrastructure.

| Tier | Mechanism | Where the AQN→Type lookup lives |
|---|---|---|
| **Default** | Binder allowlist gate (auto-feed via deserialize-metadata + explicit `Allow<>()`) | Runtime, in `IAcTypeBinder.TryResolve` |
| **Strict** | Auto-feed disabled, only explicit `Allow<>()` (narrower polymorph surface) | Runtime, in `IAcTypeBinder.TryResolve` (fewer entries) |
| **Paranoid** | `[AcBinarySerializable(enablePolymorphDetectFeature: false)]` on the class — SGen does not emit the polymorph dispatch code at all, AND `ACBIN002` raises a compile error if any `object`-typed property is declared on the type | **Does not exist** — no AQN→Type machinery in the binary |

The Paranoid tier is built on the **same compile-out mechanism** that powers
the perf-feature flags (`enableIdTrackingFeature`, `enableInternStringFeature`,
`enableRefHandlingFeature` — each strip their respective code paths when set
to `false`). "Pay only for what you use" doubles as "shrink the attack
surface": one flag system, two payoffs.

Use the Paranoid tier when a class **provably** has no polymorph property and
the developer wants that guarantee at compile time, not just runtime config —
the deserialization code that could ever resolve a wire-supplied AQN simply
does not exist in the assembly for that type.

### Differentiation vs other binary serializers

Structural compile-out of polymorph dispatch **is also available** in
MemoryPack (`[MemoryPackUnion]`) and MessagePack-CSharp (`[Union]`) — the
Paranoid tier is not a unique AcBinary capability. What differs is the **shape
of the runtime polymorph support** that the other two tiers (Default / Strict)
provide:

- **MemoryPack / MessagePack** — rigid compile-time enumeration. The base
  class lists every concrete subtype upfront via `[MemoryPackUnion(0, typeof(Cat))]`,
  `[MemoryPackUnion(1, typeof(Dog))]`, etc. Adding a new subtype = modifying
  the base class's attribute and rebuilding every dependent project. Open
  `object`-typed properties are not supported through this mechanism.
- **AcBinary** — runtime allowlist via the binder. `object`-typed properties
  are supported, new subtypes can be added by registering them with the
  binder (no base-class modification), implicit feed via deserialize-metadata
  auto-trusts whatever the application actually deserializes. The cost is
  that the security gate is runtime (`binder.TryResolve`), not structurally
  absent (as it would be in MemoryPack's tag-on-wire format where no AQN→Type
  machinery exists in the first place).

The future tag-on-wire format (post-W9F1) brings AcBinary's runtime tier
**structurally up to MemoryPack's level** while keeping the open-`object` /
runtime-config / implicit-feed flexibility — that combination is the
differentiator, not compile-out elimination per se.

## Design — single `IAcTypeBinder`, two layers

The NuGet `AcBinary` package exposes a small interface. Both the AcBinary
polymorph reader and the `AyCodeBinaryHubProtocol` HasType reader use the same
binder instance — single source of truth.

### Public NuGet contract

```csharp
namespace AyCode.Core.Serializers.Binaries;

public interface IAcTypeBinder
{
    /// <summary>Register a type into the trusted set.</summary>
    void Register(Type type);

    /// <summary>
    /// Resolve a wire-supplied AQN against the trusted set. Single lookup, no <c>Type.GetType</c>.
    /// <c>null</c> or empty AQN returns <c>false</c> (the wire path that emits the marker should never
    /// produce an empty string, but the API guards against malformed wire defensively).
    /// </summary>
    bool TryResolve(string? assemblyQualifiedName, out Type type);

    /// <summary>Fluent allowlist for types not naturally fed via deserialize-metadata creation.</summary>
    IAcTypeBinder Allow<T>();
    IAcTypeBinder Allow(Type type);
}

public sealed class AcTypeBinder : IAcTypeBinder
{
    private readonly ConcurrentDictionary<string, Type> _trusted = new(StringComparer.Ordinal);

    public void Register(Type type)
        => _trusted.TryAdd(NormalizeAqn(type.AssemblyQualifiedName!), type);

    public bool TryResolve(string? aqn, out Type type)
    {
        if (string.IsNullOrEmpty(aqn)) { type = null!; return false; }
        return _trusted.TryGetValue(NormalizeAqn(aqn), out type!);
    }

    public IAcTypeBinder Allow<T>() { Register(typeof(T)); return this; }
    public IAcTypeBinder Allow(Type type) { Register(type); return this; }
}
```

### AQN normalization — cross-version rolling deploys

The binder stores and looks up types under a **normalized AQN** form that strips
`Version=…`, `Culture=…`, `PublicKeyToken=…` segments — both at the outer
assembly reference AND at every embedded assembly reference inside closed
generic type arguments. Result keeps just `"Namespace.TypeName, AssemblyName"`
(recursive for generics).

```csharp
// Before normalize:
//   System.Collections.Generic.List`1[[FruitBank.Common.Dtos.OrderDto,
//       FruitBank.Common, Version=1.2.3.0, Culture=neutral, PublicKeyToken=null]],
//       System.Private.CoreLib, Version=9.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
//
// After normalize:
//   System.Collections.Generic.List`1[[FruitBank.Common.Dtos.OrderDto,
//       FruitBank.Common]], System.Private.CoreLib
```

**Why:** without normalization, a NuGet-version-bump or framework upgrade
shifts the AQN string → registry lookup miss → polymorph throw on previously
working wires. Strict version-pinned AQN matching is too brittle for rolling
deploys. The normalize form trades cross-version tolerance for the (extremely
narrow) risk that two assemblies with the same simple-name but different
strong-name keys would collide — practically never in first-party DTO graphs.

Impl note: a single `Regex.Replace(aqn, ", Version=…, Culture=…, PublicKeyToken=…", "")`
with the precompiled regex against the documented AQN grammar handles both the
outer and the nested-generic cases (the comma-separated segments share the
same form regardless of nesting).

public class AcBinarySerializerOptions
{
    /// <summary>
    /// Type-binder for resolving wire-supplied AQN strings (polymorph paths).
    /// <c>null</c> (default): polymorph wire-content throws.
    /// </summary>
    public IAcTypeBinder? TypeBinder { get; set; }
}
```

### Feed sources

Three additive sources populate the binder, all merging into the same
`ConcurrentDictionary<string, Type>`:

| Source | Trigger | What it adds |
|---|---|---|
| **Implicit: deserialize-metadata ctor** | First-time `BinaryDeserializeTypeMetadata` build for a type (cache miss) | The type being prepared for deserialization — has gone through developer-intentional deserialize-graph-walking, so it is trusted |
| **Explicit: `binder.Allow<T>()`** | App startup | Types that won't naturally appear via deserialize-metadata (rare polymorph-only branches, 3rd-party non-attributable types) |
| **Framework helper: `SignalRTagBindings.ExtractTypes`** | App startup | Types declared via `[SignalRTagBinding(typeof(T))]` on SignalR tag constants — feeds into the binder via `Allow` |

#### Closed-generic types on the wire

An `object`-typed property may carry a closed generic at runtime
(`List<OrderDto>`, `Dictionary<string, ProductDto>`, `OrderDto[]`) — the wire
emits the **closed-generic AQN** (the runtime type's AQN, including
type-argument AQNs). Handling:

- **Implicit feed covers the typical case**: if the app also deserializes
  `List<OrderDto>` through a non-polymorph (binder-typed) call site, the
  closed generic's `BinaryDeserializeTypeMetadata` ctor fires → auto-registers.
  Most real graphs hit this path.
- **Explicit `Allow<List<X>>()`** for closed generics that **only** appear on
  the polymorph path (no non-polymorph deserialize-call ever instantiates
  them) — register the exact closed form.
- **Future (v1.x): `AllowOpenGeneric(typeof(List<>))`** — one open-generic
  declaration covers every closed form. Implementation: decompose wire-AQN
  into open-generic + type-argument-AQNs, validate each separately against
  the binder, then `Type.MakeGenericType` on validated components. Safer than
  raw `Type.GetType(aqn)` because every composable piece is registry-checked
  before instantiation. Tracked as a follow-up — not part of the initial fix.

#### Why deserialize-only feed (NOT serialize)

The `BinarySerializeTypeMetadata` ctor does **NOT** feed the binder. Reason:
the serializer side never calls `Type.GetType` — the runtime type is known
locally (`obj.GetType()`). There is no security-relevant lookup to gate.

Asymmetric trust per process role is the correct model:

- A **server** that only sends `OrderDto` outward (never deserializes one) does
  not need `OrderDto` in its trusted-set. If an attacker injects `OrderDto`
  AQN on a client→server wire to the same server, deserialize would also need
  to build deserialize-metadata for it — which would either succeed (legitimate
  graph) or fail (the server never expected to deserialize that type).
- The "what I deserialize" history is the correct trust signal, not "what I
  serialize".

### Polymorph resolve flow

```csharp
// AcBinaryDeserializer ObjectWithTypeName handler:
internal static Type ResolvePolymorphType(string aqn, AcBinarySerializerOptions options)
{
    if (options.TypeBinder?.TryResolve(aqn, out var t) == true)
        return t;

    throw new AcBinaryDeserializationException(
        $"Polymorph type '{aqn}' is not in the trusted binder. Either the type must " +
        $"pass through deserialize-metadata creation (e.g. be part of a regular deserialize graph) " +
        $"or be explicitly added via TypeBinder.Allow<T>().",
        position: -1);
}
```

**No `Type.GetType(aqn)` call**: AQN is looked up directly against the
pre-registered dictionary. Side-effects of `Type.GetType` (assembly load,
static-ctor execution) are eliminated from the happy path.

### Two call-sites — same binder

| Call-site | Current | New |
|---|---|---|
| `AcBinaryDeserializer` polymorph (`ObjectWithTypeName` marker handler) | `Type.GetType(typeName)` | `options.TypeBinder.TryResolve(typeName)` |
| `AyCodeBinaryHubProtocol.ReadHeader:114` | `Type.GetType(typeName)` | `Options.SerializerOptions.TypeBinder.TryResolve(typeName)` |

Both layers share the same `IAcTypeBinder` instance via DI:

```csharp
services.AddSingleton<IAcTypeBinder>(binder);
services.Configure<AcBinaryHubProtocolOptions>((opts, sp) =>
    opts.SerializerOptions.TypeBinder = sp.GetRequiredService<IAcTypeBinder>());
```

## Framework helper — `SignalRTagBindings`

> **Note** — the `[SignalRTagBinding(typeof(T))]` attribute pulls double duty:
> it is the binder-feed source today (AQN-on-wire path), AND it is the
> foundation of the future **tag-on-wire** evolution (post-W9F1). The same
> attribute will be the canonical tag→type registry once the wire-format
> switches from AQN string to compact `int` tags. The interim measure
> (`ExtractTypes` feeding the binder) is therefore not throwaway — it is the
> first step of the long-term solution, with the same source-of-truth surface.


The `AyCodeBinaryHubProtocol` framework defines an attribute on SignalR tag
constants. A single helper method extracts the referenced types into a list
that the app feeds to the binder. **No separate tag-registry, no separate
lookup mechanism** — the tag-binding attribute is just a convenience source
for binder seeding.

```csharp
namespace AyCode.Services.SignalRs;

[AttributeUsage(AttributeTargets.Field)]
public sealed class SignalRTagBindingAttribute : Attribute
{
    public Type ExpectedType { get; }
    public SignalRTagBindingAttribute(Type expectedType) => ExpectedType = expectedType;
}

public static class SignalRTagBindings
{
    /// <summary>
    /// Reflects out all [SignalRTagBinding(typeof(T))] references from the given tag-container
    /// classes (public static int const fields). Returned list seeds an IAcTypeBinder at startup.
    /// </summary>
    public static List<Type> ExtractTypes(params Type[] tagContainers)
    {
        var result = new List<Type>();
        foreach (var c in tagContainers)
            foreach (var f in c.GetFields(BindingFlags.Public | BindingFlags.Static))
            {
                if (f.FieldType != typeof(int) || !f.IsLiteral) continue;
                if (f.GetCustomAttribute<SignalRTagBindingAttribute>() is { } b)
                    result.Add(b.ExpectedType);
            }
        return result;
    }
}
```

### Consumer-side attribute usage

```csharp
public static class SignalRTags
{
    [SignalRTagBinding(typeof(List<OrderDto>))]
    public const int GetAllOrderDtos = 111;

    [SignalRTagBinding(typeof(List<OrderDto>))]
    public const int GetPendingOrderDtosForMeasuring = 116;

    // No attribute → no binder feed via this tag. If the type still flows
    // through regular deserialize-graph, it's auto-trusted via metadata ctor.
}
```

## Configuration

### Program.cs (DI integration)

```csharp
// 1. Build one binder, feed multiple sources:
var binder = new AcTypeBinder();

// Source A: SignalR tag-bindings extracted from the app's tag-container class(es)
foreach (var t in SignalRTagBindings.ExtractTypes(typeof(SignalRTags)))
    binder.Allow(t);

// Source B: 3rd-party explicit (types that won't naturally appear via
//   deserialize-metadata-graph, but need to be polymorph-allowable)
binder.Allow<Nop.Core.Domain.Customers.Customer>();

// 2. Register as singleton — same instance reachable from anywhere via DI:
services.AddSingleton<IAcTypeBinder>(binder);

// 3. Wire it into AcBinaryHubProtocol options so the protocol's deserialize path uses it:
services.Configure<AcBinaryHubProtocolOptions>((opts, sp) =>
    opts.SerializerOptions.TypeBinder = sp.GetRequiredService<IAcTypeBinder>());
```

### Standalone (no DI, pure NuGet AcBinary use)

```csharp
var options = new AcBinarySerializerOptions
{
    TypeBinder = new AcTypeBinder()
        .Allow<MyPolymorphSubtype1>()
        .Allow<MyPolymorphSubtype2>()
};
var bytes = AcBinarySerializer.Serialize(myDto, options);
var roundTrip = AcBinaryDeserializer.Deserialize<MyDto>(bytes, options);
```

For non-polymorph use (no `object`-typed properties, no `ObjectWithTypeName`
marker on the wire), the `TypeBinder` can stay `null` — the path is never
entered, no security gate fires, no behavior change.

## Performance impact

| Path | Cost |
|---|---|
| Hot serialize/deserialize (no polymorph) | **Zero** — binder never consulted |
| Polymorph deserialize (rare) | One `ConcurrentDictionary<string, Type>.TryGetValue` + one AQN-normalize regex `Replace` per polymorph wire-AQN occurrence — linear in polymorph-density on the wire, not fixed overhead. Typical graphs have zero or near-zero density; AQN-rich graphs scale proportionally. |
| SignalR HasType wire (per message at most once) | One same-dictionary lookup |
| Binder seeding (startup, once) | Reflection-walk of tag-container classes (~µs) |

The polymorph path is by definition rare — most application graphs do not
trigger `ObjectWithTypeName` markers. The cost is invisible against the
deserialize work surrounding it.

## Migration

### Backward compat

Default `options.TypeBinder = null` → polymorph wire-content **throws**.
Existing non-polymorph consumers are unaffected (no `ObjectWithTypeName`
markers, no `HasType` flag → no binder lookup → no behavior change).

Polymorph consumers must configure a binder before deserializing polymorph
wire — explicit opt-in to enable the path.

### Legacy `Type.GetType(aqn)` fallback (optional, deprecated)

For migration windows where the binder cannot yet be fully populated, an
opt-in flag re-enables the legacy unsafe path:

```csharp
public class AcBinarySerializerOptions
{
    /// <summary>
    /// LEGACY: falls back to <c>Type.GetType(aqn)</c> when TypeBinder lookup misses.
    /// <b>UNSAFE — enables RCE-gadget attack surface. Migration shim only.</b>
    /// </summary>
    [Obsolete("Configure TypeBinder explicitly. AllowUnsafeTypeResolve will be removed in v2.0.")]
    public bool AllowUnsafeTypeResolve { get; set; }  // default: false
}
```

Removed in v2.0. No silent fallback at any point — explicit opt-in only.

## Files affected

| File | Change |
|---|---|
| `AyCode.Core/Serializers/Binaries/IAcTypeBinder.cs` (new) | Public NuGet interface (~10 lines) |
| `AyCode.Core/Serializers/Binaries/AcTypeBinder.cs` (new) | Default impl (~30 lines) |
| `AyCode.Core/Serializers/Binaries/AcBinarySerializerOptions.cs` | `TypeBinder` property (+3 lines) |
| `AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.BinaryDeserializeTypeMetadata.cs` ctor | `options.TypeBinder?.Register(targetType);` (+1 line) |
| `AyCode.Core/Serializers/Binaries/AcBinaryDeserializer.cs` polymorph-path | `Type.GetType` → `options.TypeBinder.TryResolve` (~5 lines) |
| `AyCode.Services/SignalRs/SignalRTagBindingAttribute.cs` (new) | Framework-side attribute (~10 lines) |
| `AyCode.Services/SignalRs/SignalRTagBindings.cs` (new) | `ExtractTypes` helper (~20 lines) |
| `AyCode.Services/SignalRs/AyCodeBinaryHubProtocol.cs` line 110-117 | `Type.GetType` → `Options.SerializerOptions.TypeBinder.TryResolve` (~5 lines) |
| Consumer `SignalRTags.cs` | Add `[SignalRTagBinding(typeof(...))]` per tag (per tag) |
| Consumer `Program.cs` | Build & register binder (~10 lines) |

Estimated effort: half a day for the core change, plus per-consumer
attribute-decoration time. Low blast radius — all changes in localized files.

## Cross-references

- `../../../AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md` — originating `ACCORE-SBP-I-R8K3` issue
- `BINARY_TODO.md#accore-bin-t-w9f1` — compile-time metadata generation (independent perf task; once it lands, SGen ModuleInit can auto-feed the binder via the same `BinaryDeserializeTypeMetadata` ctor path, eliminating most need for explicit `Allow<T>()` calls)
- `BINARY_OPTIONS.md` — for the `TypeBinder` property docs once added

## Open questions / future work

- **Tag-based wire format (post-W9F1)**: replace AQN-strings on the wire with
  registry-stable type-indices (VarInt). Reduces wire size 50-150× and
  eliminates AQN-string parsing entirely. Builds on the **same** registry the
  `[SignalRTagBinding]` attribute feeds today — the evolution becomes "switch
  resolve from name to index" rather than new infrastructure. Wire-format
  breaking change — schedule with another wire-format evolution.
- **Open-generic allowlist (`AllowOpenGeneric(typeof(List<>))`)**: covers all
  closed-form `List<X>` AQN occurrences with a single declaration, decomposes
  the wire-AQN into open-generic + type-argument-AQNs (each registry-checked
  separately), then `Type.MakeGenericType` on validated components. Safer
  than raw `Type.GetType(aqn)` because every piece is allowlisted.
- **Diagnostic logging**: optional `ILogger`-backed audit of binder
  resolve-failures — production-deployments may want to track attempted
  AQN-injections.
- **`AllowUnsafeTypeResolve` v2.0 removal timing**: schedule with the wire-format
  evolution that retires AQN-on-wire.

### Rejected directions

- **Default deny-list bundled in NuGet** (BCL-gadget pre-block): redundant
  alongside an exhaustive binder allowlist. The polymorph path is the only
  AQN→object route, and the binder's contents are fully developer-controlled —
  a deny-list either duplicates the allowlist's negation (useless) or attempts
  to enumerate dangerous types (inherently incomplete, false-security signal).
  The allowlist itself is the real defense; a deny-list would only matter on
  a misconfigured (permissive) allowlist, which would be a deployment bug to
  fix directly.
