Fix downloading attachments from WebServer. (#615)

* Fix /attachments endpoint on WebServer, and update demo to use it.
* Add @Throws statements to servlet methods.
* Ensure target entry is not a directory.
* Simplify, because JarInputStream verifies signatures by default.
* Move JarInputStream.extractFile() function into core.
* Don't close the output stream automatically as it commits our response.
This commit is contained in:
Chris Rankin 2017-05-03 13:21:26 +01:00 committed by GitHub
parent ce8e40df50
commit 780f93e625
7 changed files with 71 additions and 26 deletions

View File

@ -10,6 +10,7 @@ import net.corda.core.node.services.ServiceType
import net.corda.core.serialization.* import net.corda.core.serialization.*
import net.corda.core.transactions.TransactionBuilder import net.corda.core.transactions.TransactionBuilder
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.security.PublicKey import java.security.PublicKey
@ -496,20 +497,7 @@ interface Attachment : NamedByHash {
* *
* @throws FileNotFoundException if the given path doesn't exist in the attachment. * @throws FileNotFoundException if the given path doesn't exist in the attachment.
*/ */
fun extractFile(path: String, outputTo: OutputStream) { fun extractFile(path: String, outputTo: OutputStream) = openAsJAR().use { it.extractFile(path, outputTo) }
val p = path.toLowerCase().split('\\', '/')
openAsJAR().use { jar ->
while (true) {
val e = jar.nextJarEntry ?: break
if (e.name.toLowerCase().split('\\', '/') == p) {
jar.copyTo(outputTo)
return
}
jar.closeEntry()
}
}
throw FileNotFoundException(path)
}
} }
abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment { abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment {
@ -529,3 +517,17 @@ abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment {
override fun hashCode() = id.hashCode() override fun hashCode() = id.hashCode()
override fun toString() = "${javaClass.simpleName}(id=$id)" override fun toString() = "${javaClass.simpleName}(id=$id)"
} }
@Throws(IOException::class)
fun JarInputStream.extractFile(path: String, outputTo: OutputStream) {
val p = path.toLowerCase().split('\\', '/')
while (true) {
val e = nextJarEntry ?: break
if (!e.isDirectory && e.name.toLowerCase().split('\\', '/') == p) {
copyTo(outputTo)
return
}
closeEntry()
}
throw FileNotFoundException(path)
}

View File

@ -70,6 +70,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
advertisedServices = [] advertisedServices = []
p2pPort 10008 p2pPort 10008
rpcPort 10009 rpcPort 10009
webPort 10010
cordapps = [] cordapps = []
rpcUsers = ext.rpcUsers rpcUsers = ext.rpcUsers
} }

View File

@ -19,9 +19,14 @@ import net.corda.core.utilities.DUMMY_NOTARY_KEY
import net.corda.core.utilities.Emoji import net.corda.core.utilities.Emoji
import net.corda.flows.FinalityFlow import net.corda.flows.FinalityFlow
import java.io.InputStream import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.PublicKey import java.security.PublicKey
import java.util.jar.JarInputStream
import javax.servlet.http.HttpServletResponse.SC_OK
import javax.ws.rs.core.HttpHeaders.CONTENT_DISPOSITION
import javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM
import kotlin.system.exitProcess import kotlin.system.exitProcess
import kotlin.test.assertEquals
internal enum class Role { internal enum class Role {
SENDER, SENDER,
@ -73,7 +78,7 @@ fun sender(rpc: CordaRPCOps, inputStream: InputStream, hash: SecureHash.SHA256)
if (!rpc.attachmentExists(hash)) { if (!rpc.attachmentExists(hash)) {
inputStream.use { inputStream.use {
val id = rpc.uploadAttachment(it) val id = rpc.uploadAttachment(it)
assertEquals(hash, id) require(hash == id) { "Id was '$id' instead of '$hash'" }
} }
} }
@ -102,6 +107,29 @@ fun recipient(rpc: CordaRPCOps) {
if (wtx.outputs.isNotEmpty()) { if (wtx.outputs.isNotEmpty()) {
val state = wtx.outputs.map { it.data }.filterIsInstance<AttachmentContract.State>().single() val state = wtx.outputs.map { it.data }.filterIsInstance<AttachmentContract.State>().single()
require(rpc.attachmentExists(state.hash)) require(rpc.attachmentExists(state.hash))
// Download the attachment via the Web endpoint.
val connection = URL("http://localhost:10010/attachments/${state.hash}").openConnection() as HttpURLConnection
try {
require(connection.responseCode == SC_OK) { "HTTP status code was ${connection.responseCode}" }
require(connection.contentType == APPLICATION_OCTET_STREAM) { "Content-Type header was ${connection.contentType}" }
require(connection.contentLength > 1024) { "Attachment contains only ${connection.contentLength} bytes" }
require(connection.getHeaderField(CONTENT_DISPOSITION) == "attachment; filename=\"${state.hash}.zip\"") {
"Content-Disposition header was ${connection.getHeaderField(CONTENT_DISPOSITION)}"
}
// Write out the entries inside this jar.
println("Attachment JAR contains these entries:")
JarInputStream(connection.inputStream).use { it ->
while (true) {
val e = it.nextJarEntry ?: break
println("Entry> ${e.name}")
it.closeEntry()
}
}
} finally {
connection.disconnect()
}
println("File received - we're happy!\n\nFinal transaction is:\n\n${Emoji.renderIfSupported(wtx)}") println("File received - we're happy!\n\nFinal transaction is:\n\n${Emoji.renderIfSupported(wtx)}")
} else { } else {
println("Error: no output state found in ${wtx.id}") println("Error: no output state found in ${wtx.id}")

View File

@ -1,12 +1,17 @@
package net.corda.webserver.servlets package net.corda.webserver.servlets
import net.corda.core.contracts.extractFile
import net.corda.core.crypto.SecureHash import net.corda.core.crypto.SecureHash
import net.corda.core.node.services.StorageService import net.corda.core.messaging.CordaRPCOps
import net.corda.core.utilities.loggerFor import net.corda.core.utilities.loggerFor
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException
import java.util.jar.JarInputStream
import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse import javax.servlet.http.HttpServletResponse
import javax.ws.rs.core.HttpHeaders
import javax.ws.rs.core.MediaType
/** /**
* Allows the node administrator to either download full attachment zips, or individual files within those zips. * Allows the node administrator to either download full attachment zips, or individual files within those zips.
@ -17,11 +22,12 @@ import javax.servlet.http.HttpServletResponse
* Files are always forced to be downloads, they may not be embedded into web pages for security reasons. * Files are always forced to be downloads, they may not be embedded into web pages for security reasons.
* *
* TODO: See if there's a way to prevent access by JavaScript. * TODO: See if there's a way to prevent access by JavaScript.
* TODO: Provide an endpoint that exposes attachment file listings, to make attachments browseable. * TODO: Provide an endpoint that exposes attachment file listings, to make attachments browsable.
*/ */
class AttachmentDownloadServlet : HttpServlet() { class AttachmentDownloadServlet : HttpServlet() {
private val log = loggerFor<AttachmentDownloadServlet>() private val log = loggerFor<AttachmentDownloadServlet>()
@Throws(IOException::class)
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
val reqPath = req.pathInfo?.substring(1) val reqPath = req.pathInfo?.substring(1)
if (reqPath == null) { if (reqPath == null) {
@ -31,21 +37,23 @@ class AttachmentDownloadServlet : HttpServlet() {
try { try {
val hash = SecureHash.parse(reqPath.substringBefore('/')) val hash = SecureHash.parse(reqPath.substringBefore('/'))
val storage = servletContext.getAttribute("storage") as StorageService val rpc = servletContext.getAttribute("rpc") as CordaRPCOps
val attachment = storage.attachments.openAttachment(hash) ?: throw FileNotFoundException() val attachment = rpc.openAttachment(hash)
// Don't allow case sensitive matches inside the jar, it'd just be confusing. // Don't allow case sensitive matches inside the jar, it'd just be confusing.
val subPath = reqPath.substringAfter('/', missingDelimiterValue = "").toLowerCase() val subPath = reqPath.substringAfter('/', missingDelimiterValue = "").toLowerCase()
resp.contentType = "application/octet-stream" resp.contentType = MediaType.APPLICATION_OCTET_STREAM
if (subPath == "") { if (subPath.isEmpty()) {
resp.addHeader("Content-Disposition", "attachment; filename=\"$hash.zip\"") resp.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$hash.zip\"")
attachment.open().use { it.copyTo(resp.outputStream) } attachment.use { it.copyTo(resp.outputStream) }
} else { } else {
val filename = subPath.split('/').last() val filename = subPath.split('/').last()
resp.addHeader("Content-Disposition", "attachment; filename=\"$filename\"") resp.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"")
attachment.extractFile(subPath, resp.outputStream) JarInputStream(attachment).use { it.extractFile(subPath, resp.outputStream) }
} }
// Closing the output stream commits our response. We cannot change the status code after this.
resp.outputStream.close() resp.outputStream.close()
} catch(e: FileNotFoundException) { } catch(e: FileNotFoundException) {
log.warn("404 Not Found whilst trying to handle attachment download request for ${servletContext.contextPath}/$reqPath") log.warn("404 Not Found whilst trying to handle attachment download request for ${servletContext.contextPath}/$reqPath")

View File

@ -6,6 +6,7 @@ import net.corda.core.messaging.CordaRPCOps
import net.corda.core.node.CordaPluginRegistry import net.corda.core.node.CordaPluginRegistry
import org.glassfish.jersey.server.model.Resource import org.glassfish.jersey.server.model.Resource
import org.glassfish.jersey.server.model.ResourceMethod import org.glassfish.jersey.server.model.ResourceMethod
import java.io.IOException
import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse import javax.servlet.http.HttpServletResponse
@ -16,6 +17,7 @@ import javax.servlet.http.HttpServletResponse
*/ */
class CorDappInfoServlet(val plugins: List<CordaPluginRegistry>, val rpc: CordaRPCOps): HttpServlet() { class CorDappInfoServlet(val plugins: List<CordaPluginRegistry>, val rpc: CordaRPCOps): HttpServlet() {
@Throws(IOException::class)
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
resp.writer.appendHTML().html { resp.writer.appendHTML().html {
head { head {

View File

@ -3,6 +3,7 @@ package net.corda.webserver.servlets
import net.corda.core.messaging.CordaRPCOps import net.corda.core.messaging.CordaRPCOps
import net.corda.core.utilities.loggerFor import net.corda.core.utilities.loggerFor
import org.apache.commons.fileupload.servlet.ServletFileUpload import org.apache.commons.fileupload.servlet.ServletFileUpload
import java.io.IOException
import java.util.* import java.util.*
import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
@ -14,6 +15,7 @@ import javax.servlet.http.HttpServletResponse
class DataUploadServlet : HttpServlet() { class DataUploadServlet : HttpServlet() {
private val log = loggerFor<DataUploadServlet>() private val log = loggerFor<DataUploadServlet>()
@Throws(IOException::class)
override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) {
@Suppress("DEPRECATION") // Bogus warning due to superclass static method being deprecated. @Suppress("DEPRECATION") // Bogus warning due to superclass static method being deprecated.
val isMultipart = ServletFileUpload.isMultipartContent(req) val isMultipart = ServletFileUpload.isMultipartContent(req)

View File

@ -1,5 +1,6 @@
package net.corda.webserver.servlets package net.corda.webserver.servlets
import java.io.IOException
import javax.ws.rs.container.ContainerRequestContext import javax.ws.rs.container.ContainerRequestContext
import javax.ws.rs.container.ContainerResponseContext import javax.ws.rs.container.ContainerResponseContext
import javax.ws.rs.container.ContainerResponseFilter import javax.ws.rs.container.ContainerResponseFilter
@ -10,6 +11,7 @@ import javax.ws.rs.ext.Provider
*/ */
@Provider @Provider
class ResponseFilter : ContainerResponseFilter { class ResponseFilter : ContainerResponseFilter {
@Throws(IOException::class)
override fun filter(requestContext: ContainerRequestContext, responseContext: ContainerResponseContext) { override fun filter(requestContext: ContainerRequestContext, responseContext: ContainerResponseContext) {
val headers = responseContext.headers val headers = responseContext.headers