fix(mobile): image upload fails due to unstripped metadata (#2185)

Signed-off-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-07-20 21:39:19 +00:00
committed by GitHub
co-authored by npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr
parent 8c77a0cf8f
commit 37f15b2001
16 changed files with 811 additions and 20 deletions
+45
View File
@@ -1087,6 +1087,51 @@ mod tests {
));
}
#[test]
fn test_ios_uikit_sanitizer_outputs_match_relay_contract() {
let config = test_config();
for (name, bytes, expected_mime) in [
(
"PNG",
include_bytes!("../tests/fixtures/ios/uikit-sanitized.png").as_slice(),
"image/png",
),
(
"JPEG",
include_bytes!("../tests/fixtures/ios/uikit-sanitized.jpg").as_slice(),
"image/jpeg",
),
] {
let actual = validate_content(bytes, &config).unwrap_or_else(|error| {
panic!("rejected iOS UIKit-sanitized {name} fixture: {error}")
});
assert_eq!(actual, expected_mime);
}
}
#[test]
fn test_ios_uikit_encoder_outputs_require_sanitization() {
let config = test_config();
for (name, bytes) in [
(
"PNG",
include_bytes!("../tests/fixtures/ios/uikit-encoded.png").as_slice(),
),
(
"JPEG",
include_bytes!("../tests/fixtures/ios/uikit-encoded.jpg").as_slice(),
),
] {
assert!(
matches!(
validate_content(bytes, &config),
Err(MediaError::MetadataForbidden)
),
"accepted unsanitized iOS UIKit {name} fixture"
);
}
}
#[test]
fn test_rejects_png_metadata_and_trailing_payload() {
let config = test_config();
+51
View File
@@ -0,0 +1,51 @@
# UIKit media fixtures
These 2 x 2 fixtures were produced on an iOS simulator with UIKit, not by a generic image encoder.
## Regeneration
1. Create a small source image and run this program against the simulator SDK:
```swift
import Foundation
import UIKit
let arguments = CommandLine.arguments
let source = try Data(contentsOf: URL(fileURLWithPath: arguments[1]))
guard
let image = UIImage(data: source),
let png = image.pngData(),
let jpeg = image.jpegData(compressionQuality: 1.0)
else {
fatalError("UIKit could not encode the source image")
}
try png.write(to: URL(fileURLWithPath: arguments[2]))
try jpeg.write(to: URL(fileURLWithPath: arguments[3]))
```
Compile and run it with the active Xcode toolchain:
```sh
SDK_PATH="$(xcrun --sdk iphonesimulator --show-sdk-path)"
xcrun --sdk iphonesimulator swiftc \
-sdk "$SDK_PATH" \
-target arm64-apple-ios16.0-simulator \
reencode.swift -o reencode
xcrun simctl spawn booted ./reencode \
source.png uikit-encoded.png uikit-encoded.jpg
```
2. Copy the encoded files into both fixture directories:
```sh
cp uikit-encoded.png mobile/ios/RunnerTests/Fixtures/UIKitEncoded.png
cp uikit-encoded.jpg mobile/ios/RunnerTests/Fixtures/UIKitEncoded.jpg
cp uikit-encoded.png crates/buzz-media/tests/fixtures/ios/
cp uikit-encoded.jpg crates/buzz-media/tests/fixtures/ios/
```
3. Add a temporary Runner test that loads `UIKitEncoded.png` and `UIKitEncoded.jpg`, calls `MediaSanitizer.scrubPng` and `MediaSanitizer.scrubJpeg`, and writes those outputs to `uikit-sanitized.png` and `uikit-sanitized.jpg`. Run it once, copy the files here, then remove the temporary test.
4. Run `cmp` on each encoded copy to confirm that the Runner and Rust fixtures are byte-identical.
5. Run `cargo test -p buzz-media test_ios_uikit` to verify that UIKit's encoded output is rejected and the matching sanitizer output is accepted by the relay contract.
Regenerate both encoded and sanitized pairs whenever UIKit encoding or `MediaSanitizer` changes. Do not update only the sanitized files, because the test is intended to cover the exact encoder-to-sanitizer boundary.
Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 B

+6
View File
@@ -1,6 +1,8 @@
PODS:
- app_badge_plus (1.2.10):
- Flutter
- app_links (6.4.1):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
@@ -25,6 +27,7 @@ PODS:
DEPENDENCIES:
- app_badge_plus (from `.symlinks/plugins/app_badge_plus/ios`)
- app_links (from `.symlinks/plugins/app_links/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
@@ -38,6 +41,8 @@ DEPENDENCIES:
EXTERNAL SOURCES:
app_badge_plus:
:path: ".symlinks/plugins/app_badge_plus/ios"
app_links:
:path: ".symlinks/plugins/app_links/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
@@ -59,6 +64,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
app_badge_plus: 09939f19a075cc742cc155d8ed85e6d8601f0104
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
@@ -9,6 +9,9 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C809A294A618700263BE5 /* MediaSanitizer.swift */; };
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */; };
@@ -47,6 +50,9 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C809A294A618700263BE5 /* MediaSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSanitizer.swift; sourceTree = "<group>"; };
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
@@ -93,10 +99,20 @@
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
331C80A0294A618700263BE5 /* Fixtures */,
);
path = RunnerTests;
sourceTree = "<group>";
};
331C80A0294A618700263BE5 /* Fixtures */ = {
isa = PBXGroup;
children = (
331C809E294A618700263BE5 /* UIKitEncoded.jpg */,
331C809C294A618700263BE5 /* UIKitEncoded.png */,
);
path = Fixtures;
sourceTree = "<group>";
};
746EA2D40EB8F35E67C48311 /* Pods */ = {
isa = PBXGroup;
children = (
@@ -153,6 +169,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
331C809A294A618700263BE5 /* MediaSanitizer.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -256,6 +273,8 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */,
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -381,6 +400,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
@@ -496,8 +516,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
COMPRESS_PNG_FILES = NO;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
STRIP_PNG_TEXT = NO;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).RunnerTests";
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -514,8 +536,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
COMPRESS_PNG_FILES = NO;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
STRIP_PNG_TEXT = NO;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).RunnerTests";
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -530,8 +554,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
COMPRESS_PNG_FILES = NO;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
STRIP_PNG_TEXT = NO;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).RunnerTests";
PRODUCT_NAME = "$(TARGET_NAME)";
+35 -20
View File
@@ -58,19 +58,19 @@ import UserNotifications
return
}
let sanitizedData: Data?
switch mimeType {
case "image/png":
sanitizedData = image.pngData()
case "image/jpeg":
sanitizedData = image.jpegData(compressionQuality: 1.0)
case "image/webp":
sanitizedData = image.pngData()
default:
sanitizedData = nil
}
guard let sanitizedData else {
do {
guard let sanitizedData = try MediaSanitizer.sanitizeImage(image, mimeType: mimeType) else {
result(
FlutterError(
code: "sanitize_failed",
message: "Unable to sanitize picked image.",
details: mimeType
)
)
return
}
result(FlutterStandardTypedData(bytes: sanitizedData))
} catch {
result(
FlutterError(
code: "sanitize_failed",
@@ -78,10 +78,7 @@ import UserNotifications
details: mimeType
)
)
return
}
result(FlutterStandardTypedData(bytes: sanitizedData))
case "transcodeImageToJpeg":
guard let typedData = call.arguments as? FlutterStandardTypedData else {
result(
@@ -94,9 +91,7 @@ import UserNotifications
return
}
guard let image = UIImage(data: typedData.data),
let jpegData = image.jpegData(compressionQuality: 1.0)
else {
guard let image = UIImage(data: typedData.data) else {
result(
FlutterError(
code: "transcode_failed",
@@ -107,7 +102,27 @@ import UserNotifications
return
}
result(FlutterStandardTypedData(bytes: jpegData))
do {
guard let jpegData = try MediaSanitizer.encodeJpeg(image) else {
result(
FlutterError(
code: "transcode_failed",
message: "Unable to convert picked image to JPEG.",
details: nil
)
)
return
}
result(FlutterStandardTypedData(bytes: jpegData))
} catch {
result(
FlutterError(
code: "transcode_failed",
message: "Unable to convert picked image to JPEG.",
details: nil
)
)
}
case "transcodeVideoToMp4":
guard let sourcePath = call.arguments as? String else {
result(
+199
View File
@@ -0,0 +1,199 @@
import Foundation
import UIKit
private enum MediaSanitizationError: Error {
case invalidPng
case invalidJpeg
}
enum MediaSanitizer {
private static let pngSignature = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
private static let allowedPngAncillaryChunks: Set<String> = [
"cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT",
]
static func sanitizeImage(_ image: UIImage, mimeType: String) throws -> Data? {
switch mimeType {
case "image/png":
guard let image = renderInSRGB(image), let encoded = image.pngData() else { return nil }
return try scrubPng(encoded)
case "image/jpeg":
return try encodeJpeg(image)
case "image/webp":
guard let image = renderInSRGB(image), let encoded = image.pngData() else { return nil }
return try scrubPng(encoded)
default:
return nil
}
}
static func encodeJpeg(_ image: UIImage) throws -> Data? {
guard
let image = renderInSRGB(image),
let encoded = image.jpegData(compressionQuality: 1.0)
else {
return nil
}
return try scrubJpeg(encoded)
}
private static func renderInSRGB(_ image: UIImage) -> UIImage? {
guard image.size.width > 0, image.size.height > 0 else { return nil }
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
format.opaque = false
format.preferredRange = .standard
return UIGraphicsImageRenderer(size: image.size, format: format).image { _ in
image.draw(in: CGRect(origin: .zero, size: image.size))
}
}
static func scrubPng(_ data: Data) throws -> Data {
let data = Data(data)
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
throw MediaSanitizationError.invalidPng
}
var output = pngSignature
var offset = pngSignature.count
while offset < data.count {
guard data.count - offset >= 12 else {
throw MediaSanitizationError.invalidPng
}
let payloadLengthValue = try readUInt32BigEndian(data, at: offset)
guard
let payloadLength = Int(exactly: payloadLengthValue),
payloadLength <= data.count - offset - 12
else {
throw MediaSanitizationError.invalidPng
}
let chunkLength = payloadLength + 12
let typeStart = offset + 4
let typeEnd = typeStart + 4
let typeBytes = data[typeStart..<typeEnd]
guard let type = String(bytes: typeBytes, encoding: .ascii) else {
throw MediaSanitizationError.invalidPng
}
let isAncillary = typeBytes[typeBytes.startIndex] & 0x20 != 0
if !isAncillary || allowedPngAncillaryChunks.contains(type) {
output.append(data[offset..<(offset + chunkLength)])
}
offset += chunkLength
if type == "IEND" {
return output
}
}
throw MediaSanitizationError.invalidPng
}
static func scrubJpeg(_ data: Data) throws -> Data {
let data = Data(data)
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
throw MediaSanitizationError.invalidJpeg
}
var output = Data([0xFF, 0xD8])
var offset = 2
var inScan = false
while offset < data.count {
if inScan, data[offset] != 0xFF {
let nextMarker = data[offset...].firstIndex(of: 0xFF) ?? data.endIndex
output.append(data[offset..<nextMarker])
offset = nextMarker
continue
}
guard data[offset] == 0xFF else {
throw MediaSanitizationError.invalidJpeg
}
let markerStart = offset
while offset < data.count, data[offset] == 0xFF {
offset += 1
}
guard offset < data.count else {
throw MediaSanitizationError.invalidJpeg
}
let marker = data[offset]
offset += 1
if inScan, marker == 0x00 {
output.append(data[markerStart..<offset])
continue
}
if (0xD0...0xD7).contains(marker) || marker == 0x01 {
output.append(data[markerStart..<offset])
continue
}
if marker == 0xD9 {
output.append(data[markerStart..<offset])
return output
}
guard marker != 0xD8, data.count - offset >= 2 else {
throw MediaSanitizationError.invalidJpeg
}
let segmentLength = try readUInt16BigEndian(data, at: offset)
guard segmentLength >= 2, Int(segmentLength) <= data.count - offset else {
throw MediaSanitizationError.invalidJpeg
}
let segmentEnd = offset + Int(segmentLength)
if shouldKeepJpegSegment(marker, data: data, payload: (offset + 2)..<segmentEnd) {
output.append(data[markerStart..<segmentEnd])
}
offset = segmentEnd
inScan = marker == 0xDA
}
throw MediaSanitizationError.invalidJpeg
}
private static func shouldKeepJpegSegment(
_ marker: UInt8,
data: Data,
payload: Range<Int>
) -> Bool {
switch marker {
case 0xE0:
guard
payload.count >= 14,
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
0x4A, 0x46, 0x49, 0x46, 0x00,
])
else {
return false
}
let thumbnailWidth = Int(data[payload.lowerBound + 12])
let thumbnailHeight = Int(data[payload.lowerBound + 13])
return payload.count == 14 + 3 * thumbnailWidth * thumbnailHeight
case 0xEE:
return payload.count == 12
&& data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
0x41, 0x64, 0x6F, 0x62, 0x65,
])
case 0xE1...0xED, 0xEF, 0xFE:
return false
default:
return true
}
}
private static func readUInt16BigEndian(_ data: Data, at offset: Int) throws -> UInt16 {
guard data.count - offset >= 2 else {
throw MediaSanitizationError.invalidJpeg
}
return UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
}
private static func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32 {
guard data.count - offset >= 4 else {
throw MediaSanitizationError.invalidPng
}
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

+322
View File
@@ -1,6 +1,7 @@
import Flutter
import UIKit
import XCTest
@testable import Buzz
class RunnerTests: XCTestCase {
@@ -51,4 +52,325 @@ class RunnerTests: XCTestCase {
XCTAssertNil(AppDelegate.clipboardImageData(from: pasteboard))
}
func testSanitizePngRemovesUIKitMetadataChunks() throws {
let fixture = try fixtureData(named: "UIKitEncoded", extension: "png")
XCTAssertEqual(
try pngChunkTypes(fixture),
[
"IHDR", "sRGB", "eXIf", "pHYs", "iDOT", "IDAT", "IDAT", "IEND",
])
let sanitized = try MediaSanitizer.scrubPng(fixture)
XCTAssertEqual(
try pngChunkTypes(sanitized),
[
"IHDR", "sRGB", "IDAT", "IDAT", "IEND",
])
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/png")
XCTAssertNotNil(UIImage(data: sanitized))
var withTrailingPayload = fixture
withTrailingPayload.append(Data("hidden location".utf8))
let scrubbedTrailingPayload = try MediaSanitizer.scrubPng(withTrailingPayload)
XCTAssertEqual(scrubbedTrailingPayload, sanitized)
}
func testSanitizePngSupportsDataSlices() throws {
let fixture = try fixtureData(named: "UIKitEncoded", extension: "png")
let padded = Data([0x00]) + fixture
let slice = padded.dropFirst()
XCTAssertNotEqual(slice.startIndex, 0)
let sanitized = try MediaSanitizer.scrubPng(slice)
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/png")
XCTAssertNotNil(UIImage(data: sanitized))
}
func testSanitizeJpegRemovesUIKitMetadataSegments() throws {
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
XCTAssertEqual(try jpegMetadataMarkers(fixture), [0xE0, 0xE1, 0xED])
let sanitized = try MediaSanitizer.scrubJpeg(fixture)
XCTAssertEqual(try jpegMetadataMarkers(sanitized), [0xE0])
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/jpeg")
XCTAssertNotNil(UIImage(data: sanitized))
var withTrailingPayload = fixture
withTrailingPayload.append(Data("hidden location".utf8))
let scrubbedTrailingPayload = try MediaSanitizer.scrubJpeg(withTrailingPayload)
XCTAssertEqual(scrubbedTrailingPayload, sanitized)
}
func testSanitizeJpegSupportsDataSlices() throws {
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
let padded = Data([0x00]) + fixture
let slice = padded.dropFirst()
XCTAssertNotEqual(slice.startIndex, 0)
let sanitized = try MediaSanitizer.scrubJpeg(slice)
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/jpeg")
XCTAssertNotNil(UIImage(data: sanitized))
}
func testEncodeJpegScrubsUIKitOutput() throws {
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
let image = try XCTUnwrap(UIImage(data: fixture))
let encoded = try XCTUnwrap(MediaSanitizer.encodeJpeg(image))
try assertMatchesRelayImageMetadataPolicy(encoded, mimeType: "image/jpeg")
XCTAssertNotNil(UIImage(data: encoded))
}
func testSanitizeDisplayP3ImagePreservesRenderedColorInSRGB() throws {
let image = try displayP3Image(red: 0.9, green: 0.2, blue: 0.1)
let expectedColor = try sRGBPixel(from: image)
let mimeTypesAndAccuracy: [(mimeType: String, accuracy: UInt8)] = [
("image/png", 0), ("image/jpeg", 1),
]
for (mimeType, accuracy) in mimeTypesAndAccuracy {
let sanitized = try XCTUnwrap(
MediaSanitizer.sanitizeImage(image, mimeType: mimeType),
"Failed to sanitize Display-P3 image as \(mimeType)"
)
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: mimeType)
let decoded = try XCTUnwrap(UIImage(data: sanitized))
XCTAssertEqual(
decoded.cgImage?.colorSpace?.name,
CGColorSpace(name: CGColorSpace.sRGB)?.name
)
let actualColor = try sRGBPixel(from: decoded)
XCTAssertEqual(actualColor.count, expectedColor.count)
for (actual, expected) in zip(actualColor, expectedColor) {
XCTAssertLessThanOrEqual(
actual > expected ? actual - expected : expected - actual,
accuracy
)
}
}
}
private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage {
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
let context = try XCTUnwrap(
CGContext(
data: nil,
width: 1,
height: 1,
bitsPerComponent: 8,
bytesPerRow: 4,
space: colorSpace,
bitmapInfo: bitmapInfo.rawValue
)
)
context.setFillColor(
try XCTUnwrap(CGColor(colorSpace: colorSpace, components: [red, green, blue, 1]))
)
context.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
return UIImage(cgImage: try XCTUnwrap(context.makeImage()))
}
private func sRGBPixel(from image: UIImage) throws -> [UInt8] {
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.sRGB))
var bytes = [UInt8](repeating: 0, count: 4)
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
let context = try bytes.withUnsafeMutableBytes { bytes in
try XCTUnwrap(
CGContext(
data: bytes.baseAddress,
width: 1,
height: 1,
bitsPerComponent: 8,
bytesPerRow: 4,
space: colorSpace,
bitmapInfo: bitmapInfo.rawValue
)
)
}
context.interpolationQuality = .none
context.draw(try XCTUnwrap(image.cgImage), in: CGRect(x: 0, y: 0, width: 1, height: 1))
return bytes
}
private func fixtureData(named name: String, extension fileExtension: String) throws -> Data {
let url = try XCTUnwrap(
Bundle(for: RunnerTests.self).url(forResource: name, withExtension: fileExtension))
return try Data(contentsOf: url)
}
}
private enum RelayImagePolicyError: Error {
case invalidPng
case invalidJpeg
case metadataForbidden
}
private let pngSignature = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
private let allowedPngAncillaryChunks: Set<String> = [
"cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT",
]
private func assertMatchesRelayImageMetadataPolicy(_ data: Data, mimeType: String) throws {
switch mimeType {
case "image/png":
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
throw RelayImagePolicyError.invalidPng
}
var offset = pngSignature.count
while offset < data.count {
guard data.count - offset >= 12 else { throw RelayImagePolicyError.invalidPng }
let payloadLength = Int(try readUInt32BigEndian(data, at: offset))
guard payloadLength <= data.count - offset - 12 else {
throw RelayImagePolicyError.invalidPng
}
let typeBytes = data[(offset + 4)..<(offset + 8)]
guard let type = String(bytes: typeBytes, encoding: .ascii) else {
throw RelayImagePolicyError.invalidPng
}
let chunkEnd = offset + payloadLength + 12
let isAncillary = typeBytes[typeBytes.startIndex] & 0x20 != 0
if isAncillary, !allowedPngAncillaryChunks.contains(type) {
throw RelayImagePolicyError.metadataForbidden
}
offset = chunkEnd
if type == "IEND" {
guard offset == data.count else { throw RelayImagePolicyError.metadataForbidden }
return
}
}
throw RelayImagePolicyError.invalidPng
case "image/jpeg":
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
throw RelayImagePolicyError.invalidJpeg
}
var offset = 2
var inScan = false
while offset < data.count {
if inScan, data[offset] != 0xFF {
offset += 1
continue
}
guard data[offset] == 0xFF else { throw RelayImagePolicyError.invalidJpeg }
while offset < data.count, data[offset] == 0xFF { offset += 1 }
guard offset < data.count else { throw RelayImagePolicyError.invalidJpeg }
let marker = data[offset]
offset += 1
if inScan, marker == 0x00 { continue }
if (0xD0...0xD7).contains(marker) || marker == 0x01 { continue }
if marker == 0xD9 {
guard offset == data.count else { throw RelayImagePolicyError.metadataForbidden }
return
}
guard marker != 0xD8, data.count - offset >= 2 else {
throw RelayImagePolicyError.invalidJpeg
}
let length = Int(try readUInt16BigEndian(data, at: offset))
guard length >= 2, length <= data.count - offset else {
throw RelayImagePolicyError.invalidJpeg
}
let payload = (offset + 2)..<(offset + length)
if marker == 0xE0 {
guard
payload.count >= 14,
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
0x4A, 0x46, 0x49, 0x46, 0x00,
]),
payload.count
== 14 + 3 * Int(data[payload.lowerBound + 12]) * Int(data[payload.lowerBound + 13])
else {
throw RelayImagePolicyError.metadataForbidden
}
} else if marker == 0xEE {
guard
payload.count == 12,
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
0x41, 0x64, 0x6F, 0x62, 0x65,
])
else {
throw RelayImagePolicyError.metadataForbidden
}
} else if (0xE1...0xED).contains(marker) || marker == 0xEF || marker == 0xFE {
throw RelayImagePolicyError.metadataForbidden
}
offset += length
inScan = marker == 0xDA
}
throw RelayImagePolicyError.invalidJpeg
default:
XCTFail("Unsupported test MIME type: \(mimeType)")
}
}
private func pngChunkTypes(_ data: Data) throws -> [String] {
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
throw RelayImagePolicyError.invalidPng
}
var result: [String] = []
var offset = pngSignature.count
while offset < data.count {
guard data.count - offset >= 12 else { throw RelayImagePolicyError.invalidPng }
let payloadLength = Int(try readUInt32BigEndian(data, at: offset))
guard payloadLength <= data.count - offset - 12 else { throw RelayImagePolicyError.invalidPng }
guard let type = String(bytes: data[(offset + 4)..<(offset + 8)], encoding: .ascii) else {
throw RelayImagePolicyError.invalidPng
}
result.append(type)
offset += payloadLength + 12
if type == "IEND" { return result }
}
throw RelayImagePolicyError.invalidPng
}
private func jpegMetadataMarkers(_ data: Data) throws -> [UInt8] {
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
throw RelayImagePolicyError.invalidJpeg
}
var result: [UInt8] = []
var offset = 2
var inScan = false
while offset < data.count {
if inScan, data[offset] != 0xFF {
offset += 1
continue
}
guard data[offset] == 0xFF else { throw RelayImagePolicyError.invalidJpeg }
while offset < data.count, data[offset] == 0xFF { offset += 1 }
guard offset < data.count else { throw RelayImagePolicyError.invalidJpeg }
let marker = data[offset]
offset += 1
if inScan, marker == 0x00 { continue }
if (0xD0...0xD7).contains(marker) || marker == 0x01 { continue }
if marker == 0xD9 { return result }
guard marker != 0xD8, data.count - offset >= 2 else {
throw RelayImagePolicyError.invalidJpeg
}
let length = Int(try readUInt16BigEndian(data, at: offset))
guard length >= 2, length <= data.count - offset else {
throw RelayImagePolicyError.invalidJpeg
}
if (0xE0...0xEF).contains(marker) || marker == 0xFE {
result.append(marker)
}
offset += length
inScan = marker == 0xDA
}
throw RelayImagePolicyError.invalidJpeg
}
private func readUInt16BigEndian(_ data: Data, at offset: Int) throws -> UInt16 {
guard data.count - offset >= 2 else { throw RelayImagePolicyError.invalidJpeg }
return UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
}
private func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32 {
guard data.count - offset >= 4 else { throw RelayImagePolicyError.invalidPng }
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
}
+13
View File
@@ -47,6 +47,7 @@ const _unsupportedAnimatedPngUploadMessage =
'Animated PNG uploads are not supported on mobile yet';
const _unsupportedAnimatedWebpUploadMessage =
'Animated WebP uploads are not supported on mobile yet';
const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload.";
typedef PickGalleryImage = Future<XFile?> Function();
typedef PickGalleryVideo = Future<XFile?> Function();
@@ -56,6 +57,13 @@ typedef TranscodeImageToJpeg = Future<Uint8List> Function(Uint8List bytes);
typedef TranscodeVideoToMp4 = Future<String> Function(String filePath);
typedef ReadClipboardImage = Future<Uint8List?> Function();
class MediaPolicyUploadException implements Exception {
const MediaPolicyUploadException();
@override
String toString() => _mediaPolicyUploadMessage;
}
@immutable
class _PreparedUploadImage {
final Uint8List bytes;
@@ -256,6 +264,11 @@ class MediaUploadService {
response = await http.Response.fromStream(streamed);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
if (_allowedImageMimeTypes.contains(mimeType) &&
(response.statusCode == HttpStatus.unsupportedMediaType ||
response.statusCode == HttpStatus.unprocessableEntity)) {
throw const MediaPolicyUploadException();
}
throw Exception(
'upload failed (${response.statusCode}): ${response.body}',
);
@@ -810,6 +810,54 @@ void main() {
expect(find.textContaining('upload failed'), findsOneWidget);
});
for (final statusCode in [
HttpStatus.unsupportedMediaType,
HttpStatus.unprocessableEntity,
]) {
testWidgets('shows friendly copy for a $statusCode upload response', (
tester,
) async {
final keychain = nostr.Keys.generate();
final uploadService = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: keychain.nsec,
httpClient: http_testing.MockClient(
(request) async => http.Response(
'{"error":"media contains metadata or a non-canonical metadata channel"}',
statusCode,
),
),
pickGalleryVideo: () async => null,
pickGalleryImage: () async =>
XFile.fromData(_pngBytes, name: 'tiny.png'),
);
await tester.pumpWidget(
_buildComposeBar(
uploadService: uploadService,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await tester.tap(find.byIcon(LucideIcons.paperclip));
await tester.pumpAndSettle();
await tester.tap(find.text('Photo'));
await tester.pumpAndSettle();
expect(
find.text("We couldn't prepare this image for upload."),
findsOneWidget,
);
expect(find.textContaining('media contains metadata'), findsNothing);
expect(find.textContaining('$statusCode'), findsNothing);
});
}
testWidgets('shows a clean error when a GIF is picked', (tester) async {
final keychain = nostr.Keys.generate();
final nsec = keychain.nsec;
@@ -438,6 +438,72 @@ void main() {
},
);
for (final statusCode in [
HttpStatus.unsupportedMediaType,
HttpStatus.unprocessableEntity,
]) {
test(
'maps $statusCode media policy responses to friendly copy',
() async {
final service = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
httpClient: http_testing.MockClient(
(request) async => http.Response(
'{"error":"media contains metadata"}',
statusCode,
),
),
pickGalleryVideo: () async => null,
pickGalleryImage: () async => null,
);
await expectLater(
service.uploadBytes(_pngBytes, mimeType: 'image/png'),
throwsA(
isA<MediaPolicyUploadException>().having(
(error) => error.toString(),
'message',
"We couldn't prepare this image for upload.",
),
),
);
},
);
}
test('preserves video policy response details', () async {
final service = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
httpClient: http_testing.MockClient(
(request) async => http.Response(
'{"error":"unsupported video codec"}',
HttpStatus.unprocessableEntity,
),
),
pickGalleryVideo: () async => null,
pickGalleryImage: () async => null,
);
await expectLater(
service.uploadBytes(Uint8List(0), mimeType: 'video/mp4'),
throwsA(
isA<Exception>()
.having(
(error) => error,
'type',
isNot(isA<MediaPolicyUploadException>()),
)
.having(
(error) => error.toString(),
'message',
contains('unsupported video codec'),
),
),
);
});
test(
'checks clipboard image availability through the platform channel',
() async {