From 021c83367861f53a16ccaa2c42d3d13930f9c06e Mon Sep 17 00:00:00 2001 From: Dean Sheather <dean@deansheather.com> Date: Sun, 17 Dec 2023 02:16:21 +1000 Subject: [PATCH] fix: copy thumbnail data to avoid buffer race --- lib/thumbnailer/cache.go | 20 +++++++++++++------- lib/thumbnailer/errors.go | 3 +++ lib/thumbnailer/transform.go | 27 ++++++++++++++++++--------- objects.sql | 32 +++++++++++++++++--------------- 4 files changed, 51 insertions(+), 31 deletions(-) diff --git a/lib/thumbnailer/cache.go b/lib/thumbnailer/cache.go index 38f5130..412ff9c 100644 --- a/lib/thumbnailer/cache.go +++ b/lib/thumbnailer/cache.go @@ -25,6 +25,9 @@ func NewThumbnailCache(directory, thumbnailerURL string) *ThumbnailCache { // GetThumbnail returns a thumbnail that is cached. If no cached copy exists, a // exists, a NoCachedCopy error is returned. func (c *ThumbnailCache) GetThumbnail(key string) (io.ReadCloser, error) { + if key == "" { + return nil, NoKeySpecified + } path := filepath.Join(c.Directory, key) data, err := os.Open(path) if os.IsNotExist(err) { @@ -35,27 +38,30 @@ func (c *ThumbnailCache) GetThumbnail(key string) (io.ReadCloser, error) { // SetThumbnail stores a thumbnail with the specified key. func (c *ThumbnailCache) SetThumbnail(key string, data io.Reader) error { + if key == "" { + return NoKeySpecified + } path := filepath.Join(c.Directory, key) file, err := os.Create(path) - defer file.Close() if err != nil { return err } + defer file.Close() _, err = io.Copy(file, data) + if err != nil { + _ = os.Remove(path) + } return err } // Transform generates a thumbnail and caches it. func (c *ThumbnailCache) Transform(key string, contentType string, data io.Reader) error { + if key == "" { + return NoKeySpecified + } outputImage, err := Transform(c.ThumbnailerURL, contentType, data) if err != nil { return err } return c.SetThumbnail(key, outputImage) } - -// DeleteThumbnail deletes a thumbnail from the cache. -func (c *ThumbnailCache) DeleteThumbnail(key string) error { - path := filepath.Join(c.Directory, key) - return os.Remove(path) -} diff --git a/lib/thumbnailer/errors.go b/lib/thumbnailer/errors.go index 2c655c6..6a2f1be 100644 --- a/lib/thumbnailer/errors.go +++ b/lib/thumbnailer/errors.go @@ -14,3 +14,6 @@ var NoCachedCopy error = &thumbnailerError{"no cached copy of the thumbnail requ // InputTooLarge means that the pixel size of the input image is too big to be thumbnailed. var InputTooLarge error = &thumbnailerError{"the input size in pixels is too large"} + +// NoKeySpecified means that no key was specified. +var NoKeySpecified error = &thumbnailerError{"no key specified"} diff --git a/lib/thumbnailer/transform.go b/lib/thumbnailer/transform.go index a5b4456..ff91cf8 100644 --- a/lib/thumbnailer/transform.go +++ b/lib/thumbnailer/transform.go @@ -11,10 +11,10 @@ import ( // Accepted MIME types for thumbnails in map for easy checking var thumbnailMIMETypes = map[string]struct{}{ - "image/gif": struct{}{}, - "image/jpeg": struct{}{}, - "image/png": struct{}{}, - "image/webp": struct{}{}, + "image/gif": {}, + "image/jpeg": {}, + "image/png": {}, + "image/webp": {}, } // AcceptedMIMEType checks if a MIME type is suitable for thumbnailing. @@ -27,15 +27,19 @@ func AcceptedMIMEType(mime string) bool { // Transform takes an image io.Reader and sends it to the thumbnailer service // to be transcoded into a thumbnail. func Transform(thumbnailerURL, contentType string, data io.Reader) (*bytes.Buffer, error) { - // Set request and response + if !AcceptedMIMEType(contentType) { + return nil, errors.Errorf("invalid MIME type: %s", contentType) + } + req := fasthttp.AcquireRequest() res := fasthttp.AcquireResponse() defer func() { fasthttp.ReleaseRequest(req) fasthttp.ReleaseResponse(res) }() - req.Reset() + res.Reset() + req.Header.SetMethod("POST") req.SetRequestURI(thumbnailerURL) req.Header.Set("Content-Type", contentType) @@ -43,9 +47,7 @@ func Transform(thumbnailerURL, contentType string, data io.Reader) (*bytes.Buffe if err != nil { return nil, errors.Wrap(err, "failed to copy data to request") } - res.Reset() - // Do request err = fasthttp.Do(req, res) if err != nil { return nil, errors.Wrap(err, "failed to make request to thumbnailer service") @@ -54,5 +56,12 @@ func Transform(thumbnailerURL, contentType string, data io.Reader) (*bytes.Buffe return nil, errors.Errorf("thumbnailer service failed to create thumbnail: %s", string(res.Body())) } - return bytes.NewBuffer(res.Body()), nil + // Copy the response body to a buffer so we can return it safely. + // ReleaseResponse will return the buffer to the pool. + buf := bytes.NewBuffer(nil) + err = res.BodyWriteTo(buf) + if err != nil { + return nil, errors.Wrap(err, "failed to copy response body to buffer") + } + return buf, nil } diff --git a/objects.sql b/objects.sql index 62a6fd3..6ed6333 100644 --- a/objects.sql +++ b/objects.sql @@ -1,19 +1,21 @@ -- "objects" table schema -CREATE TABLE IF NOT EXISTS objects ( - bucket_key VARCHAR(1088) NOT NULL UNIQUE, -- bucket + key (unique) - bucket VARCHAR(20) NOT NULL, -- uint64 bucket ID ("public" for public bucket) - "key" VARCHAR(1024) NOT NULL, -- Full bucket path to file (including directory) - random_key VARCHAR(1024), -- random key if used - dir VARCHAR(1024) NOT NULL, -- Directory of file (with trailing slash) - "type" integer NOT NULL DEFAULT 0, -- Object type enumerable (0 = file, 1 = redirect) - dest_url VARCHAR(1024) DEFAULT NULL, -- Destination URL for redirect object (only when object.type == 1) - content_type VARCHAR(255) DEFAULT 'application/octet-stream', -- Content-Type of file - content_length INT DEFAULT NULL, -- Content-Length of file - associated_user VARCHAR(36) DEFAULT NULL, -- ID of user who uploaded file - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- File creation timestamp - deleted_at TIMESTAMP DEFAULT NULL, -- Deletion timestamp - delete_reason VARCHAR(256) DEFAULT NULL, -- Deletion reason - md5_hash VARCHAR(32) DEFAULT NULL -- MD5 hash of file contents (or destination URL) +CREATE TABLE objects ( + bucket_key character varying(1088) NOT NULL, -- ${bucket}/${key} (unique) + bucket character varying(20) NOT NULL, -- Bucket ID ("public" for public bucket) + key character varying(1024) NOT NULL, -- Full bucket path to file (including directory) + dir character varying(1024) NOT NULL, -- Directory of file (with trailing slash) + type integer DEFAULT 0 NOT NULL, -- Object type enumerable (0 = file, 1 = redirect) + backend_file_id character varying(33) DEFAULT NULL, -- ID of file in backend storage + dest_url character varying(4096) DEFAULT NULL, -- Destination URL for redirect object (only when object.type == 1) + content_type character varying(255) DEFAULT 'application/octet-stream', -- Content-Type of file + content_length integer, -- Content-Length of file + created_at timestamp without time zone DEFAULT now() NOT NULL, -- File creation timestamp + random_key character varying(1024) DEFAULT NULL, -- Random key if used + associated_user character varying(36) DEFAULT NULL, -- ID of user who uploaded file + deleted_at timestamp without time zone, -- Deletion timestamp + delete_reason character varying(256) DEFAULT NULL::character varying, -- Deletion reason + sha256_hash bytea, -- SHA256 hash of file contents (or destination URL) + md5_hash bytea -- MD5 hash of file contents (or destination URL) ); -- Test file object: /index.md -- GitLab