From f4dd4fa866fc88c10a43999c8c7ef189998728d0 Mon Sep 17 00:00:00 2001 From: comp500 Date: Thu, 27 Jan 2022 19:56:55 +0000 Subject: [PATCH] Implement new abstraction for file paths Once integrated with the rest of the installer, this should fix many directory traversal and path encoding issues --- build.gradle.kts | 7 + .../installer/request/RequestExceptions.kt | 70 ++++++++++ .../packwiz/installer/target/ClientHolder.kt | 12 ++ .../installer/target/path/FilePathBase.kt | 29 +++++ .../installer/target/path/HttpUrlBase.kt | 41 ++++++ .../installer/target/path/PackwizPath.kt | 122 ++++++++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 src/main/kotlin/link/infra/packwiz/installer/request/RequestExceptions.kt create mode 100644 src/main/kotlin/link/infra/packwiz/installer/target/ClientHolder.kt create mode 100644 src/main/kotlin/link/infra/packwiz/installer/target/path/FilePathBase.kt create mode 100644 src/main/kotlin/link/infra/packwiz/installer/target/path/HttpUrlBase.kt create mode 100644 src/main/kotlin/link/infra/packwiz/installer/target/path/PackwizPath.kt diff --git a/build.gradle.kts b/build.gradle.kts index 1c33397..64b6723 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation("com.google.code.gson:gson:2.8.9") implementation("com.squareup.okio:okio:3.0.0") implementation(kotlin("stdlib-jdk8")) + implementation("com.squareup.okhttp3:okhttp:4.9.3") } application { @@ -91,7 +92,13 @@ tasks.register("shrinkJar") { keep("class link.infra.packwiz.installer.** { *; }") dontoptimize() dontobfuscate() + + // Used by Okio and OkHttp dontwarn("org.codehaus.mojo.animal_sniffer.*") + dontwarn("okhttp3.internal.platform.**") + dontwarn("org.conscrypt.**") + dontwarn("org.bouncycastle.**") + dontwarn("org.openjsse.**") } // Used for vscode launch.json diff --git a/src/main/kotlin/link/infra/packwiz/installer/request/RequestExceptions.kt b/src/main/kotlin/link/infra/packwiz/installer/request/RequestExceptions.kt new file mode 100644 index 0000000..233b565 --- /dev/null +++ b/src/main/kotlin/link/infra/packwiz/installer/request/RequestExceptions.kt @@ -0,0 +1,70 @@ +package link.infra.packwiz.installer.request + +import okhttp3.Response +import okio.IOException + +sealed class RequestException: Exception { + constructor(message: String, cause: Throwable) : super(message, cause) + constructor(message: String) : super(message) + + /** + * Internal errors that should not be shown to the user when the code is correct + */ + sealed class Internal: RequestException { + constructor(message: String, cause: Throwable) : super(message, cause) + constructor(message: String) : super(message) + + class UnsinkableBase: Internal("Base associated with this path is not a SinkableBase") + + sealed class HTTP: Internal { + constructor(message: String, cause: Throwable) : super(message, cause) + constructor(message: String) : super(message) + + class NoResponseBody: HTTP("HTTP response in onResponse must have a response body") + class RequestFailed(cause: IOException): HTTP("HTTP request failed; may have been cancelled", cause) + class IllegalState(cause: IllegalStateException): HTTP("Internal fatal HTTP request error", cause) + } + } + + /** + * Errors indicating that the request is malformed + */ + sealed class Validation: RequestException { + constructor(message: String, cause: Throwable) : super(message, cause) + constructor(message: String) : super(message) + + // TODO: move out of RequestException? + class PathContainsNUL(path: String): Validation("Invalid path; contains NUL bytes: ${path.replace("\u0000", "")}") + class PathContainsVolumeLetter(path: String): Validation("Invalid path; contains volume letter: $path") + } + + /** + * Errors relating to the response from the server + */ + sealed class Response: RequestException { + constructor(message: String, cause: Throwable) : super(message, cause) + constructor(message: String) : super(message) + + // TODO: fancier way of displaying this? + sealed class HTTP: Response { + val response: okhttp3.Response + + constructor(response: okhttp3.Response, message: String, cause: Throwable) : super(message, cause) { + this.response = response + } + constructor(response: okhttp3.Response, message: String) : super(message) { + this.response = response + } + + class ErrorCode(res: okhttp3.Response): HTTP(res, "Non-successful error code from HTTP request: ${res.code}") + } + + sealed class File: RequestException { + constructor(message: String, cause: Throwable) : super(message, cause) + constructor(message: String) : super(message) + + class FileNotFound(file: String): File("File path not found: $file") + class Other(cause: Throwable): File("Failed to read file", cause) + } + } +} diff --git a/src/main/kotlin/link/infra/packwiz/installer/target/ClientHolder.kt b/src/main/kotlin/link/infra/packwiz/installer/target/ClientHolder.kt new file mode 100644 index 0000000..caf0638 --- /dev/null +++ b/src/main/kotlin/link/infra/packwiz/installer/target/ClientHolder.kt @@ -0,0 +1,12 @@ +package link.infra.packwiz.installer.target + +import okhttp3.OkHttpClient +import okio.FileSystem + +class ClientHolder { + // TODO: timeouts? + // TODO: a button to increase timeouts temporarily when retrying? + val okHttpClient by lazy { OkHttpClient.Builder().build() } + + val fileSystem = FileSystem.SYSTEM +} \ No newline at end of file diff --git a/src/main/kotlin/link/infra/packwiz/installer/target/path/FilePathBase.kt b/src/main/kotlin/link/infra/packwiz/installer/target/path/FilePathBase.kt new file mode 100644 index 0000000..b3a9ecd --- /dev/null +++ b/src/main/kotlin/link/infra/packwiz/installer/target/path/FilePathBase.kt @@ -0,0 +1,29 @@ +package link.infra.packwiz.installer.target.path + +import link.infra.packwiz.installer.request.RequestException +import link.infra.packwiz.installer.target.ClientHolder +import okio.* + +data class FilePathBase(private val path: Path): PackwizPath.Base, PackwizPath.SinkableBase { + override fun source(path: String, clientHolder: ClientHolder): BufferedSource { + val resolved = this.path.resolve(path, true) + try { + return clientHolder.fileSystem.source(resolved).buffer() + } catch (e: FileNotFoundException) { + throw RequestException.Response.File.FileNotFound(resolved.toString()) + } catch (e: IOException) { + throw RequestException.Response.File.Other(e) + } + } + + override fun sink(path: String, clientHolder: ClientHolder): BufferedSink { + val resolved = this.path.resolve(path, true) + try { + return clientHolder.fileSystem.sink(resolved).buffer() + } catch (e: FileNotFoundException) { + throw RequestException.Response.File.FileNotFound(resolved.toString()) + } catch (e: IOException) { + throw RequestException.Response.File.Other(e) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/link/infra/packwiz/installer/target/path/HttpUrlBase.kt b/src/main/kotlin/link/infra/packwiz/installer/target/path/HttpUrlBase.kt new file mode 100644 index 0000000..74b5398 --- /dev/null +++ b/src/main/kotlin/link/infra/packwiz/installer/target/path/HttpUrlBase.kt @@ -0,0 +1,41 @@ +package link.infra.packwiz.installer.target.path + +import link.infra.packwiz.installer.request.RequestException +import link.infra.packwiz.installer.target.ClientHolder +import okhttp3.HttpUrl +import okhttp3.Request +import okio.BufferedSource +import okio.IOException + +data class HttpUrlBase(private val url: HttpUrl): PackwizPath.Base { + private fun resolve(path: String) = url.newBuilder().addPathSegments(path).build() + + override fun source(path: String, clientHolder: ClientHolder): BufferedSource { + val req = Request.Builder() + .url(resolve(path)) + .header("Accept", "application/octet-stream") + .header("User-Agent", "packwiz-installer") + .get() + .build() + try { + val res = clientHolder.okHttpClient.newCall(req).execute() + // Can't use .use since it would close the response body before returning it to the caller + try { + if (!res.isSuccessful) { + throw RequestException.Response.HTTP.ErrorCode(res) + } + + val body = res.body ?: throw RequestException.Internal.HTTP.NoResponseBody() + return body.source() + } catch (e: Exception) { + // If an exception is thrown, close the response and rethrow + res.close() + throw e + } + } catch (e: IOException) { + throw RequestException.Internal.HTTP.RequestFailed(e) + } catch (e: IllegalStateException) { + throw RequestException.Internal.HTTP.IllegalState(e) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/link/infra/packwiz/installer/target/path/PackwizPath.kt b/src/main/kotlin/link/infra/packwiz/installer/target/path/PackwizPath.kt new file mode 100644 index 0000000..37e5023 --- /dev/null +++ b/src/main/kotlin/link/infra/packwiz/installer/target/path/PackwizPath.kt @@ -0,0 +1,122 @@ +package link.infra.packwiz.installer.target.path + +import link.infra.packwiz.installer.request.RequestException +import link.infra.packwiz.installer.target.ClientHolder +import okio.BufferedSink +import okio.BufferedSource + +class PackwizPath(path: String, base: Base) { + val path: String + val base: Base + + init { + this.base = base + + // Check for NUL bytes + if (path.contains('\u0000')) { throw RequestException.Validation.PathContainsNUL(path) } + // Normalise separator, to prevent differences between Unix/Windows + val pathNorm = path.replace('\\', '/') + // Split, create new lists for output + val split = pathNorm.split('/') + val canonicalised = mutableListOf() + + // Backward pass: collapse ".." components, remove "." components and empty components (except an empty component at the end; indicating a folder) + var parentComponentCount = 0 + var first = true + for (component in split.asReversed()) { + if (first) { + first = false + if (component == "") { + canonicalised += component + } + } + // URL-encoded . is normalised + val componentNorm = component.replace("%2e", ".") + if (componentNorm == "." || componentNorm == "") { + // Do nothing + } else if (componentNorm == "..") { + parentComponentCount++ + } else if (parentComponentCount > 0) { + parentComponentCount-- + } else { + canonicalised += componentNorm + // Don't allow volume letters (allows traversal to the root on Windows) + if (componentNorm[0] in 'a'..'z' || componentNorm[0] in 'A'..'Z') { + if (componentNorm[1] == ':') { + throw RequestException.Validation.PathContainsVolumeLetter(path) + } + } + } + } + + // Join path + this.path = canonicalised.asReversed().joinToString("/") + } + + val folder: Boolean get() = path.endsWith("/") + + fun resolve(path: String): PackwizPath { + return if (path.startsWith('/') || path.startsWith('\\')) { + // Absolute (but still relative to base of pack) + PackwizPath(path, base) + } else if (folder) { + // File in folder; append + PackwizPath(this.path + path, base) + } else { + // File in parent folder; append with parent component + PackwizPath(this.path + "/../" + path, base) + } + } + + /** + * Obtain a BufferedSource for this path + * @throws RequestException When resolving the file failed + */ + fun source(clientHolder: ClientHolder): BufferedSource = base.source(path, clientHolder) + + /** + * Obtain a BufferedSink for this path + * @throws RequestException.Internal.UnsinkableBase When the base of this path does not have a sink + * @throws RequestException When resolving the file failed + */ + fun sink(clientHolder: ClientHolder): BufferedSink = + if (base is SinkableBase) { base.sink(path, clientHolder) } else { throw RequestException.Internal.UnsinkableBase() } + + interface Base { + /** + * Resolve the given (canonical) path against the base, and get a BufferedSource for this file. + * @throws RequestException + */ + fun source(path: String, clientHolder: ClientHolder): BufferedSource + } + + interface SinkableBase: Base { + /** + * Resolve the given (canonical) path against the base, and get a BufferedSink for this file. + * @throws RequestException + */ + fun sink(path: String, clientHolder: ClientHolder): BufferedSink + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PackwizPath + + if (path != other.path) return false + if (base != other.base) return false + + return true + } + + override fun hashCode(): Int { + var result = path.hashCode() + result = 31 * result + base.hashCode() + return result + } + + override fun toString(): String { + return "base=$base; $path" + } +} \ No newline at end of file