Compare commits

...

3 Commits

Author SHA1 Message Date
timonrieger fa4c912863 lint 2026-05-15 22:08:28 +02:00
timonrieger 801ae1b4ee fix: cleanup nestjs-zod properties 2026-05-15 20:59:36 +02:00
Santo Shakil 01d6a244d8 fix(mobile): cronet buffer overflow on compressed thumbnails (#28439)
CronetImageFetcher sized the response buffer from Content-Length, which is
the compressed wire size. Cronet auto-decompresses gzip/br responses and
writes decompressed bytes into the buffer, exceeding it and throwing
IllegalArgumentException: ByteBuffer is already full on the next read. Use
the growable path; Content-Length becomes an initial alloc hint only,
capped at 128 MB so an untrusted server can't overflow Int.MAX_VALUE or
OOM us upfront. Reuse Cronet's ByteBuffer between reads when no grow is
needed.
2026-05-15 14:48:23 -04:00
3 changed files with 27 additions and 45 deletions
@@ -23,6 +23,8 @@ import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
private class RemoteRequest(val cancellationSignal: CancellationSignal)
class RemoteImagesImpl(context: Context) : RemoteImageApi {
@@ -228,7 +230,6 @@ private class CronetImageFetcher : ImageFetcher {
private val onComplete: () -> Unit,
) : UrlRequest.Callback() {
private var buffer: NativeByteBuffer? = null
private var wrapped: ByteBuffer? = null
private var error: Exception? = null
override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) {
@@ -242,15 +243,16 @@ private class CronetImageFetcher : ImageFetcher {
}
try {
// Content-Length is a size hint only. With Content-Encoding (gzip/br/...),
// Cronet auto-decompresses and writes decompressed bytes to our buffer, which
// may exceed the wire/compressed Content-Length. Always use the growable
// buffer path so we can't overflow.
val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0
if (contentLength > 0) {
buffer = NativeByteBuffer(contentLength + 1)
wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1)
request.read(wrapped)
} else {
buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE)
request.read(buffer!!.wrapRemaining())
}
// Cap the up-front alloc: Content-Length is untrusted and can be huge or near
// Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over.
val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE
buffer = NativeByteBuffer(initialSize)
request.read(buffer!!.wrapRemaining())
} catch (e: Exception) {
error = e
return request.cancel()
@@ -263,14 +265,18 @@ private class CronetImageFetcher : ImageFetcher {
byteBuffer: ByteBuffer
) {
try {
val buf = if (wrapped == null) {
buffer!!.run {
advance(byteBuffer.position())
ensureHeadroom()
wrapRemaining()
}
val b = buffer!!
b.advance(byteBuffer.position())
// Reuse the caller-supplied ByteBuffer as long as we don't need to grow.
// It already points at our native memory with position advanced past the
// written bytes — Cronet can keep writing into the remaining tail.
// Only when the buffer is full do we grow (which may realloc + move the
// native pointer) and need a fresh wrap.
val buf = if (b.offset == b.capacity) {
b.ensureHeadroom()
b.wrapRemaining()
} else {
wrapped
byteBuffer
}
request.read(buf)
} catch (e: Exception) {
@@ -280,7 +286,6 @@ private class CronetImageFetcher : ImageFetcher {
}
override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) {
wrapped?.let { buffer!!.advance(it.position()) }
onSuccess(buffer!!)
onComplete()
}
-21
View File
@@ -11,9 +11,6 @@
"required": true,
"in": "query",
"description": "Album ID",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -25,9 +22,6 @@
"required": false,
"in": "query",
"description": "Asset ID (if activity is for an asset)",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -38,9 +32,6 @@
"name": "level",
"required": false,
"in": "query",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"$ref": "#/components/schemas/ReactionLevel"
}
@@ -49,9 +40,6 @@
"name": "type",
"required": false,
"in": "query",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"$ref": "#/components/schemas/ReactionType"
}
@@ -61,9 +49,6 @@
"required": false,
"in": "query",
"description": "Filter by user ID",
"x-nestjs_zod-parent-metadata": {
"description": "Activity search"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -187,9 +172,6 @@
"required": true,
"in": "query",
"description": "Album ID",
"x-nestjs_zod-parent-metadata": {
"description": "Activity"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -201,9 +183,6 @@
"required": false,
"in": "query",
"description": "Asset ID (if activity is for an asset)",
"x-nestjs_zod-parent-metadata": {
"description": "Activity"
},
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
+5 -7
View File
@@ -36,18 +36,16 @@ const ActivityStatisticsResponseSchema = z
})
.meta({ id: 'ActivityStatisticsResponseDto' });
const ActivitySchema = z
.object({
albumId: z.uuidv4().describe('Album ID'),
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
})
.describe('Activity');
const ActivitySchema = z.object({
albumId: z.uuidv4().describe('Album ID'),
assetId: z.uuidv4().optional().describe('Asset ID (if activity is for an asset)'),
});
const ActivitySearchSchema = ActivitySchema.extend({
type: ReactionTypeSchema.optional(),
level: ReactionLevelSchema.optional(),
userId: z.uuidv4().optional().describe('Filter by user ID'),
}).describe('Activity search');
});
const ActivityCreateSchema = ActivitySchema.extend({
type: ReactionTypeSchema,