Implement new abstraction for file paths

Once integrated with the rest of the installer, this should fix many directory traversal and path encoding issues
This commit is contained in:
comp500 2022-01-27 19:56:55 +00:00
parent 6db8422c87
commit f4dd4fa866
6 changed files with 281 additions and 0 deletions

View File

@ -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<proguard.gradle.ProGuardTask>("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

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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<String>()
// 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"
}
}