Files
Shutong Wu 4faf7a527b fix(asset-gen): security hardening + correctness fixes (+19 tests)
Security (from a dynamic security audit of the branch):
- H1: UnityWebRequestTransport disables auto-redirect on auth-bearing
  requests (redirectLimit=0) so a provider 3xx can't re-send the API key
  to a redirect host.
- H2/P8: per-kind result-extension allowlist in AssetGenJobManager.WriteFile
  (+ defense-in-depth in the audio/image import pipelines) — a provider
  can no longer land a .cs/.asmdef/.meta/.asset under Assets/ (Editor RCE).
- H3: ProviderHttp.RequireHost pins the submit URL and the provider-supplied
  response_url to https://queue.fal.run before the fal key is attached
  (both fal image + audio adapters).

Correctness (from code review):
- C1: Tripo image->3D now sends model_version.
- C2/C3/C6/C10: FalAudioAdapter.BuildBody is catalog-driven — duration-
  required models (CassetteAI SFX/Music, Stable Audio) send a default
  duration when the caller passes 0 (fixes the default-input 422),
  fractional durations floor to >=1, Lyria stays prompt-only and its GUI
  no longer advertises a duration it ignores, and the clamp ceilings come
  from the catalog (no more duplicated 190/30/180).
- C4: an unmapped fal poll status now fails fast instead of polling to the
  600s timeout (both fal adapters).
- C5: a stale/invalid selected-model pref is cleared on dropdown fallback.
- C7: the audio fal-key status refreshes when the shared 2D fal key changes.

Cleanup: extract AssetGenModelCatalog.ResolveModel (dedupes the model-
resolution chain across the three generate tools) + DefaultModelId no-alloc.

Verified: full EditMode suite 1166 tests, 0 failures (+19 new regression
tests); 34 Python asset-gen tests pass.

Claude-Session: https://claude.ai/code/session_015KYy51gwBuhDuLZXXoqc98
2026-07-13 00:29:48 -07:00

95 lines
3.4 KiB
C#

using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine.Networking;
namespace MCPForUnity.Editor.Services.AssetGen.Http
{
/// <summary>
/// Production <see cref="IHttpTransport"/> backed by UnityWebRequest. Must be invoked on the
/// Unity main thread (the asset-gen job manager guarantees this in Phase 3). The send is
/// awaited via a <see cref="TaskCompletionSource{T}"/> wired to the async op's completed
/// callback, so the call never blocks the editor loop.
/// </summary>
public sealed class UnityWebRequestTransport : IHttpTransport
{
public Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct)
{
if (spec == null) throw new ArgumentNullException(nameof(spec));
var tcs = new TaskCompletionSource<HttpResult>();
var request = new UnityWebRequest(spec.Url, spec.Method ?? UnityWebRequest.kHttpVerbGET)
{
downloadHandler = new DownloadHandlerBuffer()
};
if (spec.Body != null)
{
request.uploadHandler = new UploadHandlerRaw(spec.Body);
}
if (!string.IsNullOrEmpty(spec.ContentType))
{
request.SetRequestHeader("Content-Type", spec.ContentType);
}
if (spec.Headers != null)
{
foreach (var kv in spec.Headers)
{
request.SetRequestHeader(kv.Key, kv.Value);
}
}
// UnityWebRequest re-sends the Authorization header to a 3xx target by default. Never
// follow a redirect on an auth-bearing request — the key must not leak to the redirect
// host. No-auth downloads may still follow.
if (CarriesAuth(spec)) request.redirectLimit = 0;
CancellationTokenRegistration ctReg = default;
if (ct.CanBeCanceled)
{
ctReg = ct.Register(() =>
{
try { request.Abort(); } catch { /* ignore */ }
tcs.TrySetCanceled();
});
}
var op = request.SendWebRequest();
op.completed += _ =>
{
try
{
var result = new HttpResult
{
Status = (int)request.responseCode,
Body = request.downloadHandler?.data,
Text = request.downloadHandler?.text,
IsSuccess = request.result == UnityWebRequest.Result.Success
};
tcs.TrySetResult(result);
}
catch (Exception e)
{
tcs.TrySetException(e);
}
finally
{
ctReg.Dispose();
request.Dispose();
}
};
return tcs.Task;
}
/// <summary>True iff the request carries an Authorization header (case-insensitive key).</summary>
internal static bool CarriesAuth(HttpRequestSpec spec)
{
if (spec?.Headers == null) return false;
foreach (var kv in spec.Headers)
if (string.Equals(kv.Key, "Authorization", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
}