CVE-2022-31005: Integer Overflow or Wraparound (CWE-190)
value: Vapor is an HTTP web framework for Swift. Users of Vapor prior to version 4.60.3 with FileMiddleware enabled are vulnerable to an integer overflow vulnerability that can crash the application. Version 4.60.3 contains a patch for this issue. As a workaround, disable FileMiddleware and serve via a Content Delivery Network.
CWE description: The product performs a calculation that can
         produce an integer overflow or wraparound when the logic
         assumes that the resulting value will always be larger than
         the original value. This occurs when an integer value is
         incremented to a value that is too large to store in the
         associated representation. When this occurs, the value may
         become a very small or negative number.
CVSS v3 base score: 7.5
Published: 2022-05-31T20:15Z
Repository: https://github.com/vapor/vapor
Language: Swift
Files changed: Sources/Vapor/Utilities/FileIO.swift, Tests/VaporTests/FileTests.swift
Commit date: 2022-05-31 10:33:26 +0100
Commit message: Merge pull request from GHSA-vj2m-9f5j-mpr5

* Add failing test for crash

* Bounds check the range and add a couple more test cases

* Fail if range start is negative

* Safer parsing of integer additions

* Check requested size for range header

* Check for 0

* Log errors and catch invalid range headers

* One more log
Diff: diff --git a/Sources/Vapor/Utilities/FileIO.swift b/Sources/Vapor/Utilities/FileIO.swift
index 20b1d0e13..0fedb284c 100644
--- a/Sources/Vapor/Utilities/FileIO.swift
+++ b/Sources/Vapor/Utilities/FileIO.swift
@@ -1,6 +1,7 @@
 import NIO
+import Logging
 
 extension Request {
     public var fileio: FileIO {
         return .init(
             io: self.application.fileio,
@@ -136,10 +137,15 @@ public struct FileIO {
             if rangeFromHeaders.unit == .bytes && rangeFromHeaders.ranges.count == 1 {
                 contentRange = rangeFromHeaders
             } else {
                 contentRange = nil
             }
+        } else if request.headers.contains(name: .range) {
+            // Range header was supplied but could not be parsed i.e. it was invalid
+            request.logger.debug("Range header was provided in request but was invalid")
+            let response = Response(status: .badRequest)
+            return response
         } else {
             contentRange = nil
         }
         // Create empty headers array.
         var headers: HTTPHeaders = [:]
@@ -161,11 +167,16 @@ public struct FileIO {
             response.status = .partialContent
             response.headers.add(name: .accept, value: contentRange.unit.serialize())
             if let firstRange = contentRange.ranges.first {
                 let range = firstRange.asResponseContentRange(limit: fileSize)
                 response.headers.contentRange = HTTPHeaders.ContentRange(unit: contentRange.unit, range: range)
-                (offset, byteCount) = firstRange.asByteBufferBounds(withMaxSize: fileSize)
+                do {
+                    (offset, byteCount) = try firstRange.asByteBufferBounds(withMaxSize: fileSize, logger: request.logger)
+                } catch {
+                    let response = Response(status: .badRequest)
+                    return response
+                }
             } else {
                 offset = 0
                 byteCount = fileSize
             }
         } else {
@@ -250,16 +261,33 @@ public struct FileIO {
     }
 }
 
 extension HTTPHeaders.Range.Value {
     
-    fileprivate func asByteBufferBounds(withMaxSize size: Int) -> (offset: Int64, byteCount: Int) {
+    fileprivate func asByteBufferBounds(withMaxSize size: Int, logger: Logger) throws -> (offset: Int64, byteCount: Int) {
         switch self {
             case .start(let value):
+                guard value <= size, value >= 0 else {
+                    logger.debug("Requested range start was invalid: \(value)")
+                    throw Abort(.badRequest)
+                }
                 return (offset: numericCast(value), byteCount: size - value)
             case .tail(let value):
+                guard value <= size, value >= 0 else {
+                    logger.debug("Requested range end was invalid: \(value)")
+                    throw Abort(.badRequest)
+                }
                 return (offset: numericCast(size - value), byteCount: value)
             case .within(let start, let end):
-                return (offset: numericCast(start), byteCount: end - start + 1)
+                guard start >= 0, end >= 0, start < end else {
+                    logger.debug("Requested range was invalid: \(start)-\(end)")
+                    throw Abort(.badRequest)
+                }
+                let (byteCount, overflow) =  (end - start).addingReportingOverflow(1)
+                guard !overflow else {
+                    logger.debug("Requested range was invalid: \(start)-\(end)")
+                    throw Abort(.badRequest)
+                }
+                return (offset: numericCast(start), byteCount: byteCount)
         }
     }
 }
diff --git a/Tests/VaporTests/FileTests.swift b/Tests/VaporTests/FileTests.swift
index fee5c5a9c..6eea059c9 100644
--- a/Tests/VaporTests/FileTests.swift
+++ b/Tests/VaporTests/FileTests.swift
@@ -244,6 +244,57 @@ final class FileTests: XCTestCase {
 
         try app.test(.GET, "Utilities/") { res in
             XCTAssertEqual(res.status, .notFound)
         }
     }
+    
+    // https://github.com/vapor/vapor/security/advisories/GHSA-vj2m-9f5j-mpr5
+    func testInvalidRangeHeaderDoesNotCrash() throws {
+        let app = Application(.testing)
+        defer { app.shutdown() }
+
+        app.get("file-stream") { req in
+            return req.fileio.streamFile(at: #file)
+        }
+
+        var headers = HTTPHeaders()
+        headers.replaceOrAdd(name: .range, value: "bytes=0-9223372036854775807")
+        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
+            XCTAssertEqual(res.status, .badRequest)
+        }
+        
+        headers.replaceOrAdd(name: .range, value: "bytes=-1-10")
+        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
+            XCTAssertEqual(res.status, .badRequest)
+        }
+        
+        headers.replaceOrAdd(name: .range, value: "bytes=100-10")
+        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
+            XCTAssertEqual(res.status, .badRequest)
+        }
+        
+        headers.replaceOrAdd(name: .range, value: "bytes=10--100")
+        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
+            XCTAssertEqual(res.status, .badRequest)
+        }
+        
+        headers.replaceOrAdd(name: .range, value: "bytes=9223372036854775808-")
+        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
+            XCTAssertEqual(res.status, .badRequest)
+        }
+        
+        headers.replaceOrAdd(name: .range, value: "bytes=922337203-")
+        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
+            XCTAssertEqual(res.status, .badRequest)
+        }
+        
+        headers.replaceOrAdd(name: .range, value: "bytes=-922337203")
+        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
+            XCTAssertEqual(res.status, .badRequest)
+        }
+        
+        headers.replaceOrAdd(name: .range, value: "bytes=-9223372036854775808")
+        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
+            XCTAssertEqual(res.status, .badRequest)
+        }
+    }
 }

Vulnerable code:                 (offset, byteCount) = firstRange.asByteBufferBounds(withMaxSize: fileSize)
    fileprivate func asByteBufferBounds(withMaxSize size: Int) -> (offset: Int64, byteCount: Int) {
                return (offset: numericCast(start), byteCount: end - start + 1)
Fixed code: import Logging
        } else if request.headers.contains(name: .range) {
            // Range header was supplied but could not be parsed i.e. it was invalid
            request.logger.debug("Range header was provided in request but was invalid")
            let response = Response(status: .badRequest)
            return response
                do {
                    (offset, byteCount) = try firstRange.asByteBufferBounds(withMaxSize: fileSize, logger: request.logger)
                } catch {
                    let response = Response(status: .badRequest)
                    return response
                }
    fileprivate func asByteBufferBounds(withMaxSize size: Int, logger: Logger) throws -> (offset: Int64, byteCount: Int) {
                guard value <= size, value >= 0 else {
                    logger.debug("Requested range start was invalid: \(value)")
                    throw Abort(.badRequest)
                }
                guard value <= size, value >= 0 else {
                    logger.debug("Requested range end was invalid: \(value)")
                    throw Abort(.badRequest)
                }
                guard start >= 0, end >= 0, start < end else {
                    logger.debug("Requested range was invalid: \(start)-\(end)")
                    throw Abort(.badRequest)
                }
                let (byteCount, overflow) =  (end - start).addingReportingOverflow(1)
                guard !overflow else {
                    logger.debug("Requested range was invalid: \(start)-\(end)")
                    throw Abort(.badRequest)
                }
                return (offset: numericCast(start), byteCount: byteCount)
    
    // https://github.com/vapor/vapor/security/advisories/GHSA-vj2m-9f5j-mpr5
    func testInvalidRangeHeaderDoesNotCrash() throws {
        let app = Application(.testing)
        defer { app.shutdown() }

        app.get("file-stream") { req in
            return req.fileio.streamFile(at: #file)
        }

        var headers = HTTPHeaders()
        headers.replaceOrAdd(name: .range, value: "bytes=0-9223372036854775807")
        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
            XCTAssertEqual(res.status, .badRequest)
        }
        
        headers.replaceOrAdd(name: .range, value: "bytes=-1-10")
        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
            XCTAssertEqual(res.status, .badRequest)
        }
        
        headers.replaceOrAdd(name: .range, value: "bytes=100-10")
        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
            XCTAssertEqual(res.status, .badRequest)
        }
        
        headers.replaceOrAdd(name: .range, value: "bytes=10--100")
        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
            XCTAssertEqual(res.status, .badRequest)
        }
        
        headers.replaceOrAdd(name: .range, value: "bytes=9223372036854775808-")
        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
            XCTAssertEqual(res.status, .badRequest)
        }
        
        headers.replaceOrAdd(name: .range, value: "bytes=922337203-")
        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
            XCTAssertEqual(res.status, .badRequest)
        }
        
        headers.replaceOrAdd(name: .range, value: "bytes=-922337203")
        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
            XCTAssertEqual(res.status, .badRequest)
        }
        
        headers.replaceOrAdd(name: .range, value: "bytes=-9223372036854775808")
        try app.testable(method: .running).test(.GET, "/file-stream", headers: headers) { res in
            XCTAssertEqual(res.status, .badRequest)
        }
    }