Skip to content

Commit 9447c0f

Browse files
bkudiessCopilotCopilot
authored
Harden screen snapshot format handling (#823)
Validate screen.snapshot image formats before invoking the capture backend, normalize jpg to jpeg, and derive the response data URI MIME type from the validated format instead of the backend echo. Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ff5cafb commit 9447c0f

3 files changed

Lines changed: 150 additions & 7 deletions

File tree

src/OpenClaw.Shared/Capabilities/ScreenCapability.cs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,14 @@ public override async Task<NodeInvokeResponse> ExecuteAsync(NodeInvokeRequest re
4646

4747
private async Task<NodeInvokeResponse> HandleCaptureAsync(NodeInvokeRequest request)
4848
{
49-
var format = GetStringArg(request.Args, "format", "png");
49+
// The format is interpolated into the data URI, so validate it before
50+
// invoking the capture backend.
51+
var requestedFormat = GetStringArg(request.Args, "format", "png");
52+
if (!TryNormalizeSnapshotFormat(requestedFormat, out var format))
53+
{
54+
return Error("Unsupported screen snapshot format. Supported formats: png, jpeg.");
55+
}
56+
5057
var maxWidth = Clamp(GetIntArg(request.Args, "maxWidth", 1920), MinDimension, MaxScreenWidth);
5158
var quality = Clamp(GetIntArg(request.Args, "quality", 80), MinQuality, MaxQuality);
5259
var monitor = GetIntArg(request.Args, "monitor", 0);
@@ -64,17 +71,18 @@ private async Task<NodeInvokeResponse> HandleCaptureAsync(NodeInvokeRequest requ
6471
{
6572
var result = await CaptureRequested(new ScreenCaptureArgs
6673
{
67-
Format = format ?? "png",
74+
Format = format,
6875
MaxWidth = maxWidth,
6976
Quality = quality,
7077
MonitorIndex = screenIndex,
7178
IncludePointer = includePointer
7279
});
73-
74-
var image = $"data:image/{result.Format.ToLowerInvariant()};base64,{result.Base64}";
80+
81+
// Use the validated format for the MIME type instead of the backend echo.
82+
var image = $"data:image/{format};base64,{result.Base64}";
7583
return Success(new
7684
{
77-
format = result.Format,
85+
format,
7886
width = result.Width,
7987
height = result.Height,
8088
base64 = result.Base64,
@@ -88,6 +96,30 @@ private async Task<NodeInvokeResponse> HandleCaptureAsync(NodeInvokeRequest requ
8896
}
8997
}
9098

99+
// Keep encoded bytes and advertised MIME type aligned.
100+
internal static bool TryNormalizeSnapshotFormat(string? requested, out string normalized)
101+
{
102+
if (string.IsNullOrWhiteSpace(requested))
103+
{
104+
normalized = "png";
105+
return true;
106+
}
107+
108+
switch (requested.Trim().ToLowerInvariant())
109+
{
110+
case "png":
111+
normalized = "png";
112+
return true;
113+
case "jpeg":
114+
case "jpg":
115+
normalized = "jpeg";
116+
return true;
117+
default:
118+
normalized = "png";
119+
return false;
120+
}
121+
}
122+
91123
private async Task<NodeInvokeResponse> HandleRecordAsync(NodeInvokeRequest request)
92124
{
93125
var format = GetStringArg(request.Args, "format", "mp4");
@@ -196,4 +228,3 @@ public class ScreenRecordResult
196228
public int Height { get; set; }
197229
public bool HasAudio { get; set; }
198230
}
199-

tests/OpenClaw.Shared.Tests/CapabilityTests.cs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2560,6 +2560,114 @@ public async Task Capture_UsesMonitorAlias_ForScreenIndex()
25602560
Assert.Equal(2, receivedArgs!.MonitorIndex);
25612561
}
25622562

2563+
[Fact]
2564+
public async Task Capture_RejectsUnsupportedFormat()
2565+
{
2566+
// Reject before the capture handler runs so a caller-supplied format
2567+
// cannot reach the data URI MIME type.
2568+
var cap = new ScreenCapability(NullLogger.Instance);
2569+
var handlerCalled = false;
2570+
cap.CaptureRequested += (_) =>
2571+
{
2572+
handlerCalled = true;
2573+
return Task.FromResult(new ScreenCaptureResult { Format = "png", Base64 = "x" });
2574+
};
2575+
2576+
var req = new NodeInvokeRequest
2577+
{
2578+
Id = "sfmt1",
2579+
Command = "screen.snapshot",
2580+
Args = Parse("""{"format":"svg+xml"}""")
2581+
};
2582+
2583+
var res = await cap.ExecuteAsync(req);
2584+
Assert.False(res.Ok);
2585+
Assert.False(handlerCalled);
2586+
Assert.Contains("Unsupported screen snapshot format", res.Error);
2587+
}
2588+
2589+
[Fact]
2590+
public async Task Capture_NormalizesJpgToJpeg()
2591+
{
2592+
// Normalize the alias before invoking the capture handler.
2593+
var cap = new ScreenCapability(NullLogger.Instance);
2594+
ScreenCaptureArgs? received = null;
2595+
cap.CaptureRequested += (args) =>
2596+
{
2597+
received = args;
2598+
return Task.FromResult(new ScreenCaptureResult { Format = args.Format, Width = 10, Height = 10, Base64 = "data" });
2599+
};
2600+
2601+
var req = new NodeInvokeRequest
2602+
{
2603+
Id = "sfmt2",
2604+
Command = "screen.snapshot",
2605+
Args = Parse("""{"format":"jpg"}""")
2606+
};
2607+
2608+
var res = await cap.ExecuteAsync(req);
2609+
Assert.True(res.Ok);
2610+
Assert.NotNull(received);
2611+
Assert.Equal("jpeg", received!.Format);
2612+
2613+
var json = JsonSerializer.Serialize(res.Payload);
2614+
using var doc = JsonDocument.Parse(json);
2615+
var root = doc.RootElement;
2616+
Assert.Equal("jpeg", root.GetProperty("format").GetString());
2617+
Assert.StartsWith("data:image/jpeg;base64,", root.GetProperty("image").GetString());
2618+
}
2619+
2620+
[Fact]
2621+
public async Task Capture_DataUri_IgnoresHandlerEchoedFormat()
2622+
{
2623+
// The response MIME type comes from the validated request format.
2624+
var cap = new ScreenCapability(NullLogger.Instance);
2625+
cap.CaptureRequested += (_) => Task.FromResult(new ScreenCaptureResult
2626+
{
2627+
Format = "svg+xml\";base64,evil",
2628+
Width = 1,
2629+
Height = 1,
2630+
Base64 = "abc123"
2631+
});
2632+
2633+
var req = new NodeInvokeRequest
2634+
{
2635+
Id = "sfmt3",
2636+
Command = "screen.snapshot",
2637+
Args = Parse("""{"format":"png"}""")
2638+
};
2639+
2640+
var res = await cap.ExecuteAsync(req);
2641+
Assert.True(res.Ok);
2642+
2643+
var json = JsonSerializer.Serialize(res.Payload);
2644+
using var doc = JsonDocument.Parse(json);
2645+
var root = doc.RootElement;
2646+
Assert.Equal("png", root.GetProperty("format").GetString());
2647+
Assert.Equal("data:image/png;base64,abc123", root.GetProperty("image").GetString());
2648+
}
2649+
2650+
[Fact]
2651+
public void TryNormalizeSnapshotFormat_AllowsKnownFormats_RejectsOthers()
2652+
{
2653+
Assert.True(ScreenCapability.TryNormalizeSnapshotFormat("png", out var png));
2654+
Assert.Equal("png", png);
2655+
Assert.True(ScreenCapability.TryNormalizeSnapshotFormat("PNG", out var pngUpper));
2656+
Assert.Equal("png", pngUpper);
2657+
Assert.True(ScreenCapability.TryNormalizeSnapshotFormat("jpeg", out var jpeg));
2658+
Assert.Equal("jpeg", jpeg);
2659+
Assert.True(ScreenCapability.TryNormalizeSnapshotFormat(" JPG ", out var jpg));
2660+
Assert.Equal("jpeg", jpg);
2661+
Assert.True(ScreenCapability.TryNormalizeSnapshotFormat(null, out var def));
2662+
Assert.Equal("png", def);
2663+
Assert.True(ScreenCapability.TryNormalizeSnapshotFormat("", out var empty));
2664+
Assert.Equal("png", empty);
2665+
2666+
Assert.False(ScreenCapability.TryNormalizeSnapshotFormat("webp", out _));
2667+
Assert.False(ScreenCapability.TryNormalizeSnapshotFormat("gif", out _));
2668+
Assert.False(ScreenCapability.TryNormalizeSnapshotFormat("png;base64,x", out _));
2669+
}
2670+
25632671
[Fact]
25642672
public async Task Record_ReturnsError_WhenNoHandler()
25652673
{

tests/OpenClaw.Shared.Tests/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,16 @@ dotnet test --filter "FullyQualifiedName~AgentActivityTests"
6868
- ✅ device.status returns Mac-compatible status payload
6969
- ✅ Unknown command returns error
7070

71-
#### ScreenCapabilityTests (9 tests)
71+
#### ScreenCapabilityTests (13 tests)
7272
- ✅ CanHandle screen.snapshot/screen.record and rejects non-gateway screen.capture/screen.list/start/stop commands
7373
- ✅ Capture returns error when no handler
7474
- ✅ Capture calls handler with parsed args (format, maxWidth, quality, screenIndex)
7575
- ✅ Capture returns error when handler throws
7676
- ✅ Capture includes data URI response
77+
- ✅ Capture rejects unsupported format (e.g. svg+xml) before invoking handler
78+
- ✅ Capture normalizes jpg → jpeg so encoded bytes and MIME type cannot diverge
79+
- ✅ Capture data URI derives MIME from validated format, not handler echo
80+
- ✅ TryNormalizeSnapshotFormat allows png/jpeg/jpg, rejects others
7781
- ✅ Record returns error when no handler
7882
- ✅ Record calls handler with Mac-compatible args
7983
- ✅ Record rejects unsupported non-mp4 format

0 commit comments

Comments
 (0)