From c760d7c9bd14d7e56ae1553a0b3c4a1258448686 Mon Sep 17 00:00:00 2001 From: "Juan J. Martinez" Date: Sat, 3 Jul 2021 14:14:31 +0100 Subject: CGI support --- .gitlab-ci.yml | 7 +- README.md | 42 ++ server/src/net/usebox/gemini/server/Response.scala | 85 +++++ server/src/net/usebox/gemini/server/Server.scala | 63 ++- .../src/net/usebox/gemini/server/ServiceConf.scala | 72 ++-- server/test/resources/dir/bad-cgi | 4 + server/test/resources/dir/bad-response | 3 + server/test/resources/dir/cgi | 4 + server/test/resources/dir/sub/cgi | 6 + server/test/resources/dir/sub/empty | 0 server/test/src/ServerSpec.scala | 425 +++++++++++++++------ server/test/src/ServiceConfSpec.scala | 135 +++++++ spacebeans.conf.example | 2 +- 13 files changed, 696 insertions(+), 152 deletions(-) create mode 100755 server/test/resources/dir/bad-cgi create mode 100755 server/test/resources/dir/bad-response create mode 100755 server/test/resources/dir/cgi create mode 100755 server/test/resources/dir/sub/cgi create mode 100644 server/test/resources/dir/sub/empty create mode 100644 server/test/src/ServiceConfSpec.scala diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 93ee0b5..740120c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,16 +1,19 @@ image: openjdk:8 +variables: + MILL_CLI: "-D coursier.cache=$CI_PROJECT_DIR/.cache -j 0" + cache: paths: - .cache/ test: script: - - ./mill -D ivy.default.ivy.user.dir=.cache server.test + - ./mill $MILL_CLI server.test package: script: - - ./mill -D ivy.default.ivy.user.dir=.cache server.assembly + - ./mill $MILL_CLI server.assembly artifacts: paths: - out/server/assembly/dest/*.jar diff --git a/README.md b/README.md index 4fcb6a6..319a78c 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,48 @@ secret used when importing the certificate. CA signed certificates don't play well with TOFU, but if the client is properly validating the certificate, this is perfectly safe. +## CGI support + +Classic CGI is supported via the `allow-cgi` flag in the `directories` directive. + +The flag only affects to the specified directory. + +The environment variables provided to the CGIs are: + +| Variable | Example | +| --- | --- | +| REMOTE_ADDR | `127.0.0.1` | +| SERVER_PORT | `1965` | +| GEMINI_URL | `gemini://localhost/mycgi/example/path?test=1` | +| REMOTE_HOST | `127.0.0.1` | +| SERVER_PROTOCOL | `GEMINI` | +| SERVER_SOFTWARE | `spacebeans/v1.1.3` | +| SCRIPT_NAME | `mycgi` | +| SERVER_NAME | `localhost` | +| PATH_INFO | `/example/path` | +| GATEWAY_INTERFACE | `CGI/1.1` | +| QUERY_STRING | `test=1` | + +These are described in [RFC-3875](https://datatracker.ietf.org/doc/html/rfc3875). + +The server expects: + +- the CGI must terminate with exit code `0` (success) +- the response will be in CGI's standard output +- it **must** be a valid Gemini response + +An example of a CGI script written in Python that outputs the environment +variables could be: + +```python +#!/usr/bin/env python3 + +import os + +print("20 text/gemini\r\n") +print('\n'.join([k + "=" + v for k, v in os.environ.items()])) +``` + ## Development Requirements: diff --git a/server/src/net/usebox/gemini/server/Response.scala b/server/src/net/usebox/gemini/server/Response.scala index 6c243b2..a6993c4 100644 --- a/server/src/net/usebox/gemini/server/Response.scala +++ b/server/src/net/usebox/gemini/server/Response.scala @@ -2,6 +2,11 @@ package net.usebox.gemini.server import java.nio.file.Path +import scala.sys.process._ +import scala.util.Try + +import org.log4s._ + import akka.stream.ActorAttributes import akka.stream.scaladsl.{Source, FileIO} import akka.util.ByteString @@ -78,6 +83,86 @@ case class DirListing( ) } +case class Cgi( + req: String, + filename: String, + queryString: String, + pathInfo: String, + scriptName: String, + host: String, + port: String, + remoteAddr: String +) extends Response { + + private[this] val logger = getLogger + + val bodyPath: Option[Path] = None + + val responseRe = "([0-9]{2}) (.*)".r + + val env = Map( + "GATEWAY_INTERFACE" -> "CGI/1.1", + "SERVER_SOFTWARE" -> s"${BuildInfo.name}/${BuildInfo.version}", + "SERVER_PROTOCOL" -> "GEMINI", + "GEMINI_URL" -> req, + "SCRIPT_NAME" -> scriptName, + "PATH_INFO" -> pathInfo, + "QUERY_STRING" -> queryString, + "SERVER_NAME" -> host, + "SERVER_PORT" -> port, + "REMOTE_ADDR" -> remoteAddr, + "REMOTE_HOST" -> remoteAddr + ).toSeq + + val (status: Int, meta: String, body: String) = { + val output = new java.io.ByteArrayOutputStream + Try { + val jpb = new java.lang.ProcessBuilder(filename) + jpb.environment.clear() + env.foreach { case (k, v) => jpb.environment.put(k, v) } + + val exit = (Process(jpb) #> output).! + output.close() + + exit + }.toEither match { + case Right(0) => + val body = output.toString("UTF-8") + body.split("\r\n").headOption match { + case Some(req @ responseRe(status, meta)) + if req.length <= Server.maxReqLen => + (status.toInt, meta, body) + case _ => + logger.warn(s"$scriptName: invalid CGI response") + respError(40, "Invalid response from CGI") + } + + case Right(exit) => + logger.warn(s"$scriptName: failed to execute CGI (exit: $exit)") + respError(50, s"Error executing CGI") + + case Left(error) => + logger.warn( + s"$scriptName: failed to execute CGI (${error.getMessage()})" + ) + respError(50, s"Error executing CGI") + } + } + + def respError( + status: Int, + meta: String + ): (Int, String, String) = { + // response should fit in 1024 bytes: XX META\r\n + val limited = meta.substring(0, Math.min(meta.length, Server.maxReqLen - 5)) + (status, meta, s"$status $limited\r\n") + } + + def bodySize: Long = body.size + + override def toSource = Source.single(ByteString(body)) +} + case class TempRedirect( req: String, meta: String = "Redirect - temporary" diff --git a/server/src/net/usebox/gemini/server/Server.scala b/server/src/net/usebox/gemini/server/Server.scala index a250158..3c78721 100644 --- a/server/src/net/usebox/gemini/server/Server.scala +++ b/server/src/net/usebox/gemini/server/Server.scala @@ -23,9 +23,6 @@ case class Server(conf: ServiceConf) { private[this] val logger = getLogger - val defPort = 1965 - val maxReqLen = 1024 - val mimeTypes = conf.mimeTypes val defaultMimeType = conf.defaultMimeType val vHosts = conf.virtualHosts @@ -67,7 +64,7 @@ case class Server(conf: ServiceConf) { case mime => mime } - def handleReq(req: String): Response = + def handleReq(req: String, remoteAddr: String): Response = (for { uri <- Try(URI.create(req)).toEither resp <- Try( @@ -83,7 +80,7 @@ case class Server(conf: ServiceConf) { case _ if uri.getPort() != -1 && uri.getPort() != conf.port => logger.debug(s"invalid port, is a proxy request") ProxyRequestRefused(req) - case _ if uri.getPort() == -1 && conf.port != defPort => + case _ if uri.getPort() == -1 && conf.port != Server.defPort => logger.debug( s"default port but non default was configured, is a proxy request" ) @@ -107,10 +104,41 @@ case class Server(conf: ServiceConf) { .getDefault() .getPath(root, path) .normalize() + val cgi = vhost.getCgi(resource) - logger.debug(s"requesting: '$resource'") + logger.debug(s"requesting: '$resource', cgi is '$cgi'") resource.toFile() match { + case file + if cgi + .map(_.toFile()) + .map(f => f.isFile() && f.canExecute()) + .getOrElse(false) => + logger.debug("is cgi, will execute") + + val cgiFile = cgi.get + val queryString = + if (uri.getQuery() == null) "" else uri.getQuery() + val pathInfo = + if (cgiFile.compareTo(resource) == 0) "" + else + "/" + resource + .subpath( + cgiFile.getNameCount(), + resource.getNameCount() + ) + .toString() + + Cgi( + req, + filename = cgiFile.toString(), + queryString = queryString, + pathInfo = pathInfo, + scriptName = cgiFile.getFileName().toString(), + host = vhost.host, + port = conf.port.toString(), + remoteAddr = remoteAddr + ) case path if !path.exists() => logger.debug("no resource") NotFound(req) @@ -227,15 +255,15 @@ case class Server(conf: ServiceConf) { closing = TLSClosing.ignoreCancel ) .runForeach { connection => - val remoteHost = connection.remoteAddress.getHostString() - logger.debug(s"new connection $remoteHost") + val remoteAddr = connection.remoteAddress.getHostString() + logger.debug(s"new connection $remoteAddr") val handler = Flow[ByteString] .watchTermination() { (_, f) => f.onComplete { _.toEither.swap.map(error => logger.warn( - s"$remoteHost - stream terminated: ${error.getMessage()}" + s"$remoteAddr - stream terminated: ${error.getMessage()}" ) ) } @@ -244,7 +272,7 @@ case class Server(conf: ServiceConf) { Framing .delimiter( ByteString("\r\n"), - maximumFrameLength = maxReqLen + 1, + maximumFrameLength = Server.maxReqLen + 1, allowTruncation = true ) ) @@ -257,16 +285,16 @@ case class Server(conf: ServiceConf) { logger.debug(s"invalid UTF-8 encoding: ${error.getMessage()}") BadRequest(req.utf8String) case Right(reqStr) => - if (req.size > maxReqLen) + if (req.size > Server.maxReqLen) BadRequest(reqStr.take(1024) + "{...}") else - handleReq(reqStr) + handleReq(reqStr, remoteAddr) } } .take(1) .wireTap(resp => logger.info( - s"""$remoteHost "${resp.req}" ${resp.status} ${resp.bodySize}""" + s"""$remoteAddr "${resp.req}" ${resp.status} ${resp.bodySize}""" ) ) .flatMapConcat(_.toSource) @@ -275,3 +303,12 @@ case class Server(conf: ServiceConf) { } } } + +object Server { + + /** Maximum request length in bytes. */ + val maxReqLen = 1024 + + /** Default port. */ + val defPort = 1965 +} diff --git a/server/src/net/usebox/gemini/server/ServiceConf.scala b/server/src/net/usebox/gemini/server/ServiceConf.scala index 74ed548..dc28650 100644 --- a/server/src/net/usebox/gemini/server/ServiceConf.scala +++ b/server/src/net/usebox/gemini/server/ServiceConf.scala @@ -11,7 +11,11 @@ import org.log4s._ case class KeyStore(path: String, alias: String, password: String) -case class Directory(path: String, directoryListing: Option[Boolean]) +case class Directory( + path: String, + directoryListing: Option[Boolean], + allowCgi: Option[Boolean] +) case class VirtualHost( host: String, @@ -34,9 +38,24 @@ object VirtualHost { def getDirectoryListing(path: Path): Boolean = vhost.directories .find(_.path == path.toString()) - .fold(vhost.directoryListing)(loc => - loc.directoryListing.getOrElse(vhost.directoryListing) + .flatMap(_.directoryListing) + .getOrElse(vhost.directoryListing) + + def getCgi(path: Path): Option[Path] = + vhost.directories + .find(d => + path.startsWith( + d.path + ) && path.toString != d.path && d.allowCgi == Some(true) ) + .collect { + case d => + val dp = + FileSystems.getDefault().getPath(d.path).normalize() + FileSystems + .getDefault() + .getPath(d.path, path.getName(dp.getNameCount()).toString()) + } def getRoot(path: String): (String, String) = path match { @@ -75,32 +94,33 @@ object ServiceConf { import VirtualHost.userTag - def load(confFile: String) = - ConfigSource.file(confFile).load[ServiceConf].map { conf => - conf.copy(virtualHosts = conf.virtualHosts.map { vhost => - if ( - vhost.userDirectories && !vhost.userDirectoryPath - .fold(false)(dir => dir.contains(userTag)) + def initConf(conf: ServiceConf): ServiceConf = + conf.copy(virtualHosts = conf.virtualHosts.map { vhost => + if ( + vhost.userDirectories && !vhost.userDirectoryPath + .fold(false)(dir => dir.contains(userTag)) + ) + logger.warn( + s"In virtual host '${vhost.host}': user-directories is enabled but $userTag not found in user-directory-path" ) - logger.warn( - s"In virtual host '${vhost.host}': user-directories is enabled but $userTag not found in user-directory-path" - ) - vhost.copy(directories = vhost.directories.map { dir => - val path = - FileSystems - .getDefault() - .getPath(vhost.root, dir.path) - .normalize() + vhost.copy(directories = vhost.directories.map { dir => + val path = + FileSystems + .getDefault() + .getPath(vhost.root, dir.path) + .normalize() - if (!path.toFile().isDirectory()) - logger.warn( - s"In virtual host '${vhost.host}': directory entry '${dir.path}' is not a directory" - ) + if (!path.toFile().isDirectory()) + logger.warn( + s"In virtual host '${vhost.host}': directory entry '${dir.path}' is not a directory" + ) - dir - .copy(path = path.toString()) - }) + dir + .copy(path = path.toString()) }) - } + }) + + def load(confFile: String) = + ConfigSource.file(confFile).load[ServiceConf].map(initConf) } diff --git a/server/test/resources/dir/bad-cgi b/server/test/resources/dir/bad-cgi new file mode 100755 index 0000000..0ec31e2 --- /dev/null +++ b/server/test/resources/dir/bad-cgi @@ -0,0 +1,4 @@ +#!/bin/bash + +echo -e "20 text/gemini\r\n" +exit 1 diff --git a/server/test/resources/dir/bad-response b/server/test/resources/dir/bad-response new file mode 100755 index 0000000..c9bedb5 --- /dev/null +++ b/server/test/resources/dir/bad-response @@ -0,0 +1,3 @@ +#!/bin/bash + +echo "20 text/gemini" diff --git a/server/test/resources/dir/cgi b/server/test/resources/dir/cgi new file mode 100755 index 0000000..05a3e53 --- /dev/null +++ b/server/test/resources/dir/cgi @@ -0,0 +1,4 @@ +#!/bin/bash + +echo -e "20 text/gemini\r\n" +env diff --git a/server/test/resources/dir/sub/cgi b/server/test/resources/dir/sub/cgi new file mode 100755 index 0000000..05ba716 --- /dev/null +++ b/server/test/resources/dir/sub/cgi @@ -0,0 +1,6 @@ +#!/bin/bash + +echo -e "20 text/gemini\r\n" +echo "SHOULD NOT RUN" +exit 1 + diff --git a/server/test/resources/dir/sub/empty b/server/test/resources/dir/sub/empty new file mode 100644 index 0000000..e69de29 diff --git a/server/test/src/ServerSpec.scala b/server/test/src/ServerSpec.scala index 05e1580..7a2b501 100644 --- a/server/test/src/ServerSpec.scala +++ b/server/test/src/ServerSpec.scala @@ -12,12 +12,6 @@ class ServerSpec extends AnyFlatSpec with Matchers { def getPath(value: String) = FileSystems.getDefault().getPath(value) - def getPath(root: String, dir: String) = - FileSystems - .getDefault() - .getPath(root, dir) - .normalize() - behavior of "validPath" it should "return true for the emtpy path" in { @@ -135,107 +129,110 @@ class ServerSpec extends AnyFlatSpec with Matchers { behavior of "handleReq" it should "return bad request on URLs with no scheme" in { - Server(TestData.conf).handleReq("//localhost/") should matchPattern { - case _: BadRequest => - } + Server(TestData.conf) + .handleReq("//localhost/", "127.0.0.1") should be(a[BadRequest]) } it should "return proxy request refused on port mismatch" in { Server(TestData.conf) - .handleReq("gemini://localhost:8080/") should matchPattern { - case _: ProxyRequestRefused => - } + .handleReq("gemini://localhost:8080/", "127.0.0.1") should be( + a[ProxyRequestRefused] + ) } it should "return proxy request refused when port not provided and configured port is not default" in { Server(TestData.conf.copy(port = 8080)) - .handleReq("gemini://localhost/") should matchPattern { - case _: ProxyRequestRefused => - } + .handleReq("gemini://localhost/", "127.0.0.1") should be( + a[ProxyRequestRefused] + ) } it should "return success when port is provided and matches configured port (not default)" in { Server(TestData.conf.copy(port = 8080)) - .handleReq("gemini://localhost:8080/") should matchPattern { - case _: Success => - } + .handleReq("gemini://localhost:8080/", "127.0.0.1") should be(a[Success]) } it should "return proxy request refused when the vhost is not found" in { Server(TestData.conf) - .handleReq("gemini://otherhost/") should matchPattern { - case _: ProxyRequestRefused => - } + .handleReq("gemini://otherhost/", "127.0.0.1") should be( + a[ProxyRequestRefused] + ) } it should "return bad request when user info is present" in { Server(TestData.conf) - .handleReq("gemini://user@localhost/") should matchPattern { - case _: BadRequest => - } + .handleReq("gemini://user@localhost/", "127.0.0.1") should be( + a[BadRequest] + ) } it should "return bad request when the path is out of root dir" in { Server(TestData.conf) - .handleReq("gemini://localhost/../../") should matchPattern { - case _: BadRequest => - } + .handleReq("gemini://localhost/../../", "127.0.0.1") should be( + a[BadRequest] + ) } it should "return bad request for invalid URLs" in { Server(TestData.conf) - .handleReq("gemini://localhost/ invalid") should matchPattern { - case _: BadRequest => - } + .handleReq( + "gemini://localhost/ invalid", + "127.0.0.1" + ) should be(a[BadRequest]) } it should "redirect to normalize the URL" in { Server(TestData.conf) - .handleReq("gemini://localhost/./") should matchPattern { - case _: PermanentRedirect => - } + .handleReq("gemini://localhost/./", "127.0.0.1") should be( + a[PermanentRedirect] + ) } it should "return not found if the path doesn't exist" in { Server(TestData.conf) - .handleReq("gemini://localhost/doesnotexist") should matchPattern { - case _: NotFound => - } + .handleReq( + "gemini://localhost/doesnotexist", + "127.0.0.1" + ) should be(a[NotFound]) } it should "return not found if a dot file" in { Server(TestData.conf) - .handleReq("gemini://localhost/.dotfile") should matchPattern { - case _: NotFound => - } + .handleReq( + "gemini://localhost/.dotfile", + "127.0.0.1" + ) should be(a[NotFound]) } it should "return success on reading file" in { Server(TestData.conf) - .handleReq("gemini://localhost/index.gmi") should matchPattern { + .handleReq( + "gemini://localhost/index.gmi", + "127.0.0.1" + ) should matchPattern { case Success(_, "text/gemini", Some(_), 25L) => } } it should "redirect and normalize request on a directory" in { Server(TestData.conf) - .handleReq("gemini://localhost/dir") should matchPattern { - case _: PermanentRedirect => - } + .handleReq("gemini://localhost/dir", "127.0.0.1") should be( + a[PermanentRedirect] + ) } it should "return an existing index file when requesting a directory" in { Server(TestData.conf) - .handleReq("gemini://localhost/") should matchPattern { + .handleReq("gemini://localhost/", "127.0.0.1") should matchPattern { case Success(_, "text/gemini", Some(_), 25L) => } } it should "return proxy request refused for non gemini schemes" in { Server(TestData.conf) - .handleReq("https://localhost/") should matchPattern { - case _: ProxyRequestRefused => - } + .handleReq("https://localhost/", "127.0.0.1") should be( + a[ProxyRequestRefused] + ) } it should "include gemini params for gemini MIME type" in { @@ -243,7 +240,10 @@ class ServerSpec extends AnyFlatSpec with Matchers { TestData.conf.copy(virtualHosts = List(TestData.conf.virtualHosts(0).copy(geminiParams = Some("test"))) ) - ).handleReq("gemini://localhost/index.gmi") should matchPattern { + ).handleReq( + "gemini://localhost/index.gmi", + "127.0.0.1" + ) should matchPattern { case Success(_, "text/gemini; test", Some(_), 25L) => } } @@ -252,55 +252,80 @@ class ServerSpec extends AnyFlatSpec with Matchers { it should "return a directory listing if is enabled and no index" in { Server(TestData.conf) - .handleReq("gemini://localhost/dir/") should matchPattern { - case _: DirListing => - } + .handleReq("gemini://localhost/dir/", "127.0.0.1") should be( + a[DirListing] + ) } it should "return a directory listing, directory listing flags: vhost flag false, directories flag true" in { Server( - TestData.conf.copy(virtualHosts = - List( - TestData.conf - .virtualHosts(0) - .copy( - directoryListing = false, - directories = List( - Directory( - getPath(getClass.getResource("/").getPath(), "dir/") - .toString(), - directoryListing = Some(true) + ServiceConf.initConf( + TestData.conf.copy(virtualHosts = + List( + TestData.conf + .virtualHosts(0) + .copy( + directoryListing = false, + directories = List( + Directory( + "dir/", + directoryListing = Some(true), + allowCgi = None + ) ) ) - ) + ) ) ) - ).handleReq("gemini://localhost/dir/") should matchPattern { - case _: DirListing => - } + ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[DirListing]) } it should "return not found with no index, directory listing flags: vhost flag true, directories flag false" in { Server( - TestData.conf.copy(virtualHosts = - List( - TestData.conf - .virtualHosts(0) - .copy( - directoryListing = true, - directories = List( - Directory( - getPath(getClass.getResource("/").getPath(), "dir/") - .toString(), - directoryListing = Some(false) + ServiceConf.initConf( + TestData.conf.copy(virtualHosts = + List( + TestData.conf + .virtualHosts(0) + .copy( + directoryListing = true, + directories = List( + Directory( + "dir/", + directoryListing = Some(false), + allowCgi = None + ) ) ) - ) + ) ) ) - ).handleReq("gemini://localhost/dir/") should matchPattern { - case _: NotFound => - } + ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[NotFound]) + } + + it should "not apply directory listing override to subdirectories" in { + Server( + ServiceConf.initConf( + TestData.conf.copy(virtualHosts = + List( + TestData.conf + .virtualHosts(0) + .copy( + directoryListing = false, + directories = List( + Directory( + "dir/", + directoryListing = Some(true), + allowCgi = None + ) + ) + ) + ) + ) + ) + ).handleReq("gemini://localhost/dir/sub/", "127.0.0.1") should be( + a[NotFound] + ) } it should "return not found if directory listing is not enabled and no index" in { @@ -308,16 +333,15 @@ class ServerSpec extends AnyFlatSpec with Matchers { TestData.conf.copy(virtualHosts = List(TestData.conf.virtualHosts(0).copy(directoryListing = false)) ) - ).handleReq("gemini://localhost/dir/") should matchPattern { - case _: NotFound => - } + ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[NotFound]) } behavior of "handleReq, user directories" it should "return success on reading file" in { Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username/index.gmi" + "gemini://localhost/~username/index.gmi", + "127.0.0.1" ) should matchPattern { case Success(_, "text/gemini", Some(_), 38L) => } @@ -325,15 +349,15 @@ class ServerSpec extends AnyFlatSpec with Matchers { it should "return redirect accessing the user directory without ending slash" in { Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username" - ) should matchPattern { - case _: PermanentRedirect => - } + "gemini://localhost/~username", + "127.0.0.1" + ) should be(a[PermanentRedirect]) } it should "return success accessing the user directory index" in { Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username/" + "gemini://localhost/~username/", + "127.0.0.1" ) should matchPattern { case Success(_, "text/gemini", Some(_), 38L) => } @@ -341,32 +365,28 @@ class ServerSpec extends AnyFlatSpec with Matchers { it should "return bad request trying to exit the root directory" in { Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username/../../" - ) should matchPattern { - case _: BadRequest => - } + "gemini://localhost/~username/../../", + "127.0.0.1" + ) should be(a[BadRequest]) } it should "return redirect to the virtual host root when leaving the user dir" in { Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username/../" - ) should matchPattern { - case _: PermanentRedirect => - } + "gemini://localhost/~username/../", + "127.0.0.1" + ) should be(a[PermanentRedirect]) } it should "not translate root if used an invalid user pattern" in { Server(TestData.confUserDir).handleReq( - "gemini://localhost/~username../" - ) should matchPattern { - case _: NotFound => - } + "gemini://localhost/~username../", + "127.0.0.1" + ) should be(a[NotFound]) Server(TestData.confUserDir).handleReq( - "gemini://localhost/~0invalid/" - ) should matchPattern { - case _: NotFound => - } + "gemini://localhost/~0invalid/", + "127.0.0.1" + ) should be(a[NotFound]) } it should "not translate root if no user directory path was provided" in { @@ -379,21 +399,187 @@ class ServerSpec extends AnyFlatSpec with Matchers { ) ) ).handleReq( - "gemini://localhost/~username/" + "gemini://localhost/~username/", + "127.0.0.1" + ) should be(a[NotFound]) + } + + it should "not execute a CGI if the target resource is not executable" in { + Server(TestData.cgiConf).handleReq( + "gemini://localhost/dir/file.txt", + "127.0.0.1" + ) should matchPattern { + case Success(_, "text/plain", Some(_), 5L) => + } + } + + it should "not execute a CGI if the target resource is a directory" in { + Server(TestData.cgiConf).handleReq( + "gemini://localhost/dir/sub/", + "127.0.0.1" + ) should be(a[DirListing]) + } + + it should "not apply allow CGI to subdirectories" in { + Server(TestData.cgiConf).handleReq( + "gemini://localhost/dir/sub/cgi", + "127.0.0.1" + ) should matchPattern { + case Success(_, "text/plain", Some(_), 72) => + } + } + + it should "execute a CGI" in { + val cgi = Server(TestData.cgiConf) + .handleReq( + "gemini://localhost/dir/cgi", + "127.0.0.1" + ) + .asInstanceOf[Cgi] + + cgi.status should be(20) + cgi.meta should be("text/gemini") + cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") + } + + it should "execute a CGI: empty parameters, host and port" in { + Server(TestData.cgiConf).handleReq( + "gemini://localhost/dir/cgi", + "127.0.0.1" + ) should matchPattern { + case Cgi( + _, + _, + "", + "", + "cgi", + TestData.host, + TestData.portStr, + _ + ) => + } + } + + it should "execute a CGI: query string" in { + Server(TestData.cgiConf).handleReq( + "gemini://localhost/dir/cgi?query&string", + "127.0.0.1" + ) should matchPattern { + case Cgi( + _, + _, + "query&string", + "", + "cgi", + TestData.host, + TestData.portStr, + _ + ) => + } + } + + it should "execute a CGI: path info" in { + Server(TestData.cgiConf).handleReq( + "gemini://localhost/dir/cgi/path/info", + "127.0.0.1" + ) should matchPattern { + case Cgi( + _, + _, + "", + "/path/info", + "cgi", + TestData.host, + TestData.portStr, + _ + ) => + } + } + + it should "execute a CGI: query string and path info" in { + Server(TestData.cgiConf).handleReq( + "gemini://localhost/dir/cgi/path/info?query=string", + "127.0.0.1" ) should matchPattern { - case _: NotFound => + case Cgi( + _, + _, + "query=string", + "/path/info", + "cgi", + TestData.host, + TestData.portStr, + _ + ) => } } + it should "not execute an executable if allow CGI is off" in { + Server(TestData.conf) + .handleReq( + "gemini://localhost/dir/cgi", + "127.0.0.1" + ) should matchPattern { + case Success(_, "text/plain", Some(_), _) => + } + } + + it should "response with an error if the CGI exits with non 0" in { + val bad = Server(TestData.cgiConf) + .handleReq( + "gemini://localhost/dir/bad-cgi", + "127.0.0.1" + ) + .asInstanceOf[Cgi] + + val meta = "Error executing CGI" + bad.status should be(50) + bad.meta should be(meta) + bad.body should include(meta) + } + + it should "return a response with an error if the CGI exits with non 0" in { + val bad = Server(TestData.cgiConf) + .handleReq( + "gemini://localhost/dir/bad-cgi", + "127.0.0.1" + ) + .asInstanceOf[Cgi] + + val meta = "Error executing CGI" + bad.status should be(50) + bad.meta should be(meta) + bad.body should include(meta) + } + + it should "return a response with an error if the CGI response is invalid" in { + val bad = Server(TestData.cgiConf) + .handleReq( + "gemini://localhost/dir/bad-response", + "127.0.0.1" + ) + .asInstanceOf[Cgi] + + val meta = "Invalid response from CGI" + bad.status should be(40) + bad.meta should be(meta) + bad.body should include(meta) + } + object TestData { + + val host = "localhost" + val port = 1965 + val portStr = port.toString() + val conf = ServiceConf( address = "127.0.0.1", - port = 1965, + port = port, defaultMimeType = "text/plain", idleTimeout = 10.seconds, virtualHosts = List( VirtualHost( - host = "localhost", + host = host, root = getClass.getResource("/").getPath() ) ), @@ -402,6 +588,25 @@ class ServerSpec extends AnyFlatSpec with Matchers { enabledCipherSuites = Nil ) + val cgiConf = ServiceConf.initConf( + conf.copy(virtualHosts = + List( + conf + .virtualHosts(0) + .copy( + directoryListing = true, + directories = List( + Directory( + "dir/", + directoryListing = Some(false), + allowCgi = Some(true) + ) + ) + ) + ) + ) + ) + val confUserDir = conf.copy(virtualHosts = List( conf diff --git a/server/test/src/ServiceConfSpec.scala b/server/test/src/ServiceConfSpec.scala new file mode 100644 index 0000000..d886b4f --- /dev/null +++ b/server/test/src/ServiceConfSpec.scala @@ -0,0 +1,135 @@ +package net.usebox.gemini.server + +import java.nio.file.FileSystems + +import scala.concurrent.duration._ + +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +class ServiceConfSpec extends AnyFlatSpec with Matchers { + + def getPath(value: String) = + FileSystems.getDefault().getPath(getClass.getResource("/").getPath(), value) + + behavior of "getDirectoryListing" + + it should "resolve directory listing using vhost conf if no directory override" in { + val vh = TestData.conf.virtualHosts.head + vh.getDirectoryListing(getPath("/dir")) shouldBe vh.directoryListing + } + + it should "resolve directory listing using directory override" in { + val vh = TestData.conf.virtualHosts.head.copy( + directoryListing = false, + directories = List( + Directory( + getPath("dir").toString(), + directoryListing = Some(true), + None + ) + ) + ) + vh.getDirectoryListing(getPath("dir")) shouldBe true + } + + it should "ignore non matching directories resolving directory listing" in { + val vh = TestData.conf.virtualHosts.head.copy( + directoryListing = false, + directories = List( + Directory( + getPath("no-match").toString(), + directoryListing = Some(true), + allowCgi = None + ) + ) + ) + vh.getDirectoryListing(getPath("dir")) shouldBe false + } + + behavior of "getCgi" + + it should "return None as allow CGI is off by default" in { + val vh = TestData.conf.virtualHosts.head + vh.getCgi(getPath("dir/cgi")) shouldBe None + } + + it should "set allow CGI via directory override" in { + List(true, false).foreach { value => + val vh = TestData.conf.virtualHosts.head.copy( + directories = List( + Directory( + getPath("dir").toString(), + directoryListing = None, + allowCgi = Some(value) + ) + ) + ) + vh.getCgi(getPath("dir/cgi")) should matchPattern { + case Some(_) if value => + case None if !value => + } + } + } + + it should "return the CGI path minus path info" in { + val vh = TestData.conf.virtualHosts.head.copy( + directories = List( + Directory( + getPath("dir").toString(), + directoryListing = None, + allowCgi = Some(true) + ) + ) + ) + vh.getCgi(getPath("dir/cgi/path/info")) shouldBe Some( + getPath("dir/cgi") + ) + } + + it should "not return the CGI path if is exactly the CGI dir" in { + val vh = TestData.conf.virtualHosts.head.copy( + directories = List( + Directory( + getPath("dir").toString(), + directoryListing = None, + allowCgi = Some(true) + ) + ) + ) + vh.getCgi(getPath("dir")) shouldBe None + vh.getCgi(getPath("dir/")) shouldBe None + } + + it should "not return the CGI path if allow CGI is false" in { + val vh = TestData.conf.virtualHosts.head.copy( + directories = List( + Directory( + getPath("dir").toString(), + directoryListing = None, + allowCgi = Some(false) + ) + ) + ) + vh.getCgi(getPath("dir/cgi")) shouldBe None + vh.getCgi(getPath("dir/cgit/with/path")) shouldBe None + } + + object TestData { + val conf = ServiceConf( + address = "127.0.0.1", + port = 1965, + defaultMimeType = "text/plain", + idleTimeout = 10.seconds, + virtualHosts = List( + VirtualHost( + host = "localhost", + root = getClass.getResource("/").getPath() + ) + ), + genCertValidFor = 1.day, + enabledProtocols = Nil, + enabledCipherSuites = Nil + ) + } +} diff --git a/spacebeans.conf.example b/spacebeans.conf.example index 96e9d08..9e92d45 100644 --- a/spacebeans.conf.example +++ b/spacebeans.conf.example @@ -33,7 +33,7 @@ virtual-hosts = [ // important: directory's path is relative to the root // // directories = [ - // { path = "relative/path/", directory-listing = true } + // { path = "relative/path/", directory-listing = true, allow-cgi = false } // ] // user directory support -- cgit v1.2.3