Move SSD network safety checks to typed Kotlin
ober
c9b467e8808422a5c5d30e866bc37dfe6df890c8
--- a/templates/ssd-review.ss +++ b/templates/ssd-review.ss @@ -1424,7 +1424,8 @@ learnedExamplesFileName inputLimitExceededMessage outputLimitExceededMessage clientLogHttpStatusMessage bearerAuthorizationHeader - zipEntryCountLimitExceededMessage httpStatusSuccessful httpStatusMessage + zipEntryCountLimitExceededMessage + httpStatusSuccessful httpStatusFailed httpStatusMessage unableToCreateSyncFolderMessage unableToCreateFileMessage) (type Int32) (record ImportBudget @@ -1491,6 +1492,8 @@ (string-append "/" (int32->string limit))))) (def (httpStatusSuccessful (code : Int32)) : Bool (and (>= code (int32 200)) (<= code (int32 299)))) + (def (httpStatusFailed (code : Int32)) : Bool + (not (httpStatusSuccessful code))) (def (httpStatusMessage (code : Int32)) : String (string-append "HTTP " (int32->string code))) (def (unableToCreateSyncFolderMessage (name : String)) : String @@ -1640,14 +1643,17 @@ TruthIndexEntry-sourceName TruthIndexEntry-canonicalSourceName TruthIndexEntry-page TruthIndexEntry-dpi TruthIndexEntry-groupCount TruthIndexEntry-truthTime betterTruthIndex - fileExists fileIsDirectory fileIsRegular fileIsSymbolicLink + fileExists fileIsDirectory fileIsRegular fileIsNotRegular + fileIsSymbolicLink fileSizeBytes - pathIsSymbolicLink pathNotSymbolicLink safeLocalRegularFile + pathIsSymbolicLink pathNotSymbolicLink pathParentMatches + safeLocalRegularFile destinationNewFileDelta storageStatsExceedsQuota storageCapacityWithinQuota byteCountWithinLimit storageEntryBytesAllowed boundedReadLimitValid - positiveCount nonPositiveCount positiveByteCount learnedExamplesFull + positiveCount nonPositiveCount + positiveByteCount nonPositiveByteCount learnedExamplesFull streamReadEnded streamReadEmpty streamByteWasRead inputCountExceeded outputSingleWriteExceeds outputBufferWriteExceeds @@ -1658,7 +1664,8 @@ byteArraySizeBytes byteArrayBytesWithinLimit textUtf8Bytes textUtf8SizeBytes textUtf8BytesWithinLimit oldDestinationBytes urlEffectivePort - constantTimeBytesEqual constantTimeNullableBytesEqual + constantTimeBytesEqual constantTimeBytesMismatch + constantTimeNullableBytesEqual constantTimeNullableBytesMismatch remoteBearerTokenPresent remoteBearerTokenValid remoteEndpointIsHttps safeRemoteUrlParts remotePinPresent remotePinOrNull remotePinSha256Length @@ -1666,6 +1673,7 @@ remotePinAllowedForProtocol remoteConfigHasPin remoteRelativePathSafe remoteOriginMatches shouldCompareMoreCompleteTruth shouldWriteRemoteTruth + remoteTruthWriteSkipped localFileMissing localTextMatchesRemote shouldReplaceLocalByTimes zipEntryIsDirectory zipEntryIsFile zipEntryDeclaresExcessiveSize) @@ -1686,6 +1694,8 @@ (kotlin-member-call toPath)) (extern (pathIsSymbolicLinkRaw (path : Path)) : Bool (kotlin-call Files isSymbolicLink)) + (extern (pathParent (path : Path)) : Path + (kotlin-member-get parent)) (extern (bytesSize (bytes : Bytes)) : Int32 (kotlin-member-get size)) (extern (base64Decode (text : String) (flags : Int32)) : Bytes @@ -1733,12 +1743,16 @@ (and (fileExists file) (fileIsDirectoryRaw file))) (def (fileIsRegular (file : File)) : Bool (and (fileExists file) (fileIsFile file))) + (def (fileIsNotRegular (file : File)) : Bool + (not (fileIsRegular file))) (def (fileSizeBytes (file : File)) : Int (fileLength file)) (def (pathIsSymbolicLink (path : Path)) : Bool (pathIsSymbolicLinkRaw path)) (def (pathNotSymbolicLink (path : Path)) : Bool (not (pathIsSymbolicLink path))) + (def (pathParentMatches (path : Path) (parent : Path)) : Bool + (equal? (pathParent path) parent)) (def (fileIsSymbolicLink (file : File)) : Bool (pathIsSymbolicLink (fileToPath file))) (def (safeLocalRegularFile (file : File)) : Bool @@ -1772,6 +1786,8 @@ (not (positiveCount count))) (def (positiveByteCount (count : Int)) : Bool (> count (int 0))) + (def (nonPositiveByteCount (count : Int)) : Bool + (not (positiveByteCount count))) (def (streamReadEnded (read : Int32)) : Bool (< read (int32 0))) (def (streamReadEmpty (read : Int32)) : Bool @@ -1833,11 +1849,16 @@ (if (>= port (int32 0)) port (urlDefaultPort url)))) (def (constantTimeBytesEqual (actual : Bytes) (expected : Bytes)) : Bool (messageDigestIsEqual actual expected)) + (def (constantTimeBytesMismatch (actual : Bytes) (expected : Bytes)) : Bool + (not (constantTimeBytesEqual actual expected))) (def (constantTimeNullableBytesEqual (actual : Bytes) (expected : (Nullable Bytes))) : Bool (if (nullable-null? expected) #f (constantTimeBytesEqual actual (nullable-get expected)))) + (def (constantTimeNullableBytesMismatch (actual : Bytes) + (expected : (Nullable Bytes))) : Bool + (not (constantTimeNullableBytesEqual actual expected))) (def (remoteEndpointIsHttps (protocol : String) (host : String)) : Bool (and (equal? protocol "https") (not (string-blank? host)))) (def (safeRemoteUrlParts (userInfo : (Nullable String)) @@ -1899,6 +1920,8 @@ (if compareMoreComplete (or (>= remoteGroupCount localGroupCount) localPolicyAllows) localPolicyAllows)) + (def (remoteTruthWriteSkipped (shouldWrite : Bool)) : Bool + (not shouldWrite)) (def (shouldReplaceLocalByTimes (localGenerated : (Nullable Int)) (remoteGenerated : (Nullable Int)) (remoteModified : Int) @@ -7437,7 +7460,7 @@ " try {" " connection.outputStream.use { it.write(bytes) }" " val code = connection.responseCode" - " if (!httpStatusSuccessful(code)) throw IllegalStateException(clientLogHttpStatusMessage(code))" + " if (httpStatusFailed(code)) throw IllegalStateException(clientLogHttpStatusMessage(code))" " connection.inputStream.close()" " } finally {" " connection.disconnect()" @@ -8338,7 +8361,7 @@ " }" "" " private fun truthIndexEntry(file: File): TruthIndexEntry? {" - " if (!fileIsRegular(file)) return null" + " if (fileIsNotRegular(file)) return null" " return try {" " val truth = jsonObjectFromText(readLocalText(file))" " truthIndexEntryFromJson(file, truth, truthTime(truth) ?: file.lastModified())" @@ -8443,7 +8466,7 @@ " val basePath = base.toPath().toRealPath(LinkOption.NOFOLLOW_LINKS)" " require(pathNotSymbolicLink(basePath)) { \"Symlinked storage root rejected\" }" " val target = basePath.resolve(leaf).normalize()" - " require(target.parent == basePath) { \"Local path escaped storage root\" }" + " require(pathParentMatches(target, basePath)) { \"Local path escaped storage root\" }" " return target.toFile()" " }" "" @@ -8483,7 +8506,7 @@ " val parentPath = parent.toPath().toRealPath(LinkOption.NOFOLLOW_LINKS)" " require(pathNotSymbolicLink(parentPath)) { \"Symlinked destination parent rejected\" }" " val target = parentPath.resolve(destination.name).normalize()" - " require(target.parent == parentPath) { \"Destination escaped parent\" }" + " require(pathParentMatches(target, parentPath)) { \"Destination escaped parent\" }" " require(pathNotSymbolicLink(target)) { \"Symlinked destination rejected\" }" " val temporary = Files.createTempFile(parentPath, \".ssd-review-\", \".part\")" " try {" @@ -8566,7 +8589,7 @@ " val certificate = connection.serverCertificates.firstOrNull()" " ?: throw SSLPeerUnverifiedException(\"Server provided no certificate\")" " val actualPin = MessageDigest.getInstance(\"SHA-256\").digest(certificate.publicKey.encoded)" - " if (!constantTimeNullableBytesEqual(actualPin, config.spkiSha256)) {" + " if (constantTimeNullableBytesMismatch(actualPin, config.spkiSha256)) {" " connection.disconnect()" " throw SSLPeerUnverifiedException(\"SSD service identity pin mismatch\")" " }" @@ -8590,7 +8613,7 @@ " platform.checkServerTrusted(chain, authType)" " val leaf = chain.firstOrNull() ?: throw CertificateException(\"Server provided no certificate\")" " val actual = MessageDigest.getInstance(\"SHA-256\").digest(leaf.publicKey.encoded)" - " if (!constantTimeBytesEqual(actual, expectedPin)) {" + " if (constantTimeBytesMismatch(actual, expectedPin)) {" " throw CertificateException(\"SSD service identity pin mismatch\")" " }" " }" @@ -8606,7 +8629,7 @@ " val connection = openPinnedConnection(relative, \"GET\", acceptGzip = true)" " try {" " val code = connection.responseCode" - " if (!httpStatusSuccessful(code)) {" + " if (httpStatusFailed(code)) {" " connection.errorStream?.close()" " return 0" " }" @@ -8672,7 +8695,7 @@ " val compareMoreComplete = shouldCompareMoreCompleteTruth(replaceIfMoreComplete, dest)" " val local = if (compareMoreComplete) jsonObjectFromText(readLocalText(dest)) else null" " val shouldWrite = shouldWriteRemoteTruth(compareMoreComplete, truthGroupCount(truth), nullableTruthGroupCount(local), shouldReplaceLocal(dest, text, modified))" - " if (!shouldWrite) return false" + " if (remoteTruthWriteSkipped(shouldWrite)) return false" " atomicWriteText(dest, text)" " invalidateTruthIndex()" " return true" @@ -8829,7 +8852,7 @@ " val connection = openPinnedConnection(\"dump.zip?scope=app\", \"GET\")" " try {" " val code = connection.responseCode" - " if (!httpStatusSuccessful(code)) {" + " if (httpStatusFailed(code)) {" " connection.errorStream?.close()" " throw IllegalStateException(httpStatusMessage(code))" " }" @@ -8858,7 +8881,7 @@ " archive.inputStream().use { input -> input.copyTo(output, 32 * 1024) }" " }" " val code = connection.responseCode" - " if (!httpStatusSuccessful(code)) {" + " if (httpStatusFailed(code)) {" " connection.errorStream?.close()" " throw IllegalStateException(httpStatusMessage(code))" " }" @@ -8966,7 +8989,7 @@ " val name = child.name ?: return@forEach" " if (safeLeafInvalid(name)) return@forEach" " val remaining = MAX_ZIP_TOTAL_BYTES - budget.expandedBytes" - " if (!positiveByteCount(remaining)) throw IllegalStateException(\"Import tree exceeds byte limit\")" + " if (nonPositiveByteCount(remaining)) throw IllegalStateException(\"Import tree exceeds byte limit\")" " val bytes = context.contentResolver.openInputStream(child.uri)?.use {" " readBoundedBytes(it, boundedTreeReadLimit(MAX_ZIP_ENTRY_BYTES, remaining))" " }" @@ -9075,7 +9098,7 @@ " try {" " connection.outputStream.use { it.write(bytes) }" " val code = connection.responseCode" - " if (!httpStatusSuccessful(code)) {" + " if (httpStatusFailed(code)) {" " connection.errorStream?.close()" " } else {" " connection.inputStream?.close()"