From 5c1958b150e6d25bb425cdb677b1c5d7d244ce12 Mon Sep 17 00:00:00 2001 From: Solene Rapenne Date: Sat, 5 Dec 2020 20:07:08 +0100 Subject: [PATCH] first commit --- LICENSE | 21 +++++ Makefile | 16 ++++ README.md | 60 ++++++++++++ khan.8 | 58 ++++++++++++ main.c | 188 +++++++++++++++++++++++++++++++++++++ tests/test.sh | 37 ++++++++ tests/var/gopher/gophermap | 1 + tests/var/gopher/main.gph | 1 + 8 files changed, 382 insertions(+) create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 khan.8 create mode 100644 main.c create mode 100644 tests/test.sh create mode 100644 tests/var/gopher/gophermap create mode 100644 tests/var/gopher/main.gph diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..74eb46e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2020 Solène Rapenne + +BSD 2-clause License + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..75f3d85 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +PREFIX?=/usr/local/ + +all: khan + +clean: + rm -f khan *.core + +khan: main.c + ${CC} -o khan main.c + +install: khan + install -o root -g wheel khan ${PREFIX}/bin/ + install -o root -g wheel khan.8 ${PREFIX}/man/man8/ + +test: khan + cd tests && sh test.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b24b07 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# A simplistic and secure Gopher server + +**Khan** is a gopher server supporting chroot meant to be run on +inetd. + +**Khan** design is relying on inetd, the idea is to delegate the +network to a daemon which proved doing it correctly, so khan takes +its request from stdin and outputs the result to stdout. This +also makes it very easy to write tests for it. + +**Khan** is perfectly secure if run on **OpenBSD**, using `unveil()` +the filesystem access is restricted to one directory (default to +`/var/gopher/`) and with `pledge()` only systems calls related to +reading files and reading input/output are allowed. + +In addition, it's possible to run khan into a chroot and drop +privileges to a dedicated user on every system on which **Khan** +compiles. + + +# Installing + +For some systems, the library `libsd` may be required. + +``` +git clone https://tildegit.org/solene/khan.git +cd khan +make +su -c 'make install' +``` + +# Running tests + +**Khan** comes with a test suite you can use with `make test`. + + +# Command line parameters + +**Khan** has a few parameters you can use in inetd configuration. + +- `-d PATH`: use `PATH` as the data directory to serve files from. Default is `/var/gopher` +- `-u username`: enable chroot to the data directory and drop privileges to `username`. + + +# How to configure Khan using inetd + +Create directory `/var/gopher/`, files will be served from there, +or use `-d` parameter to choose another path. + +Add this line to inetd.conf: + +``` +11965 stream tcp nowait gopher_user /usr/local/bin/khan khan +``` + +On OpenBSD, enable and start inetd: +``` +# rcctl enable inetd +# rcctl start inetd +``` diff --git a/khan.8 b/khan.8 new file mode 100644 index 0000000..19662f3 --- /dev/null +++ b/khan.8 @@ -0,0 +1,58 @@ +.Dd $Mdocdate: December 03 2020 $ +.Dt KHAN 8 +.Os +.Sh NAME +.Nm khan +.Nd inetd gopher server +.Sh SYNOPSIS +.Nm khan +.Op Fl d Ar path +.Op Fl u Ar username +.Sh DESCRIPTION +.Nm +is a secure gopher server that is meant to be run on +.Xr inetd 8 . +.Pp +If an incoming gopher query doesn't explicitly request a file, +.Nm +will serves a default gophermap file if present. +.Sh OPTIONS +.Bl -tag -width Ds +.It Op Fl d Ar path +Use +.Ar path +instead of the default "/var/gopher/" path to look for files. +On +.Ox +.Nm +will use +.Xr unveil 2 +on this path in read-only to prevent file access outside this directory. +.It Op Fl u Ar username +Enable +.Xr chroot 2 +on the data directory and then drop privileges to +.Ar username . +This requires +.Nm +to be run as root user. +.El +.Sh DEPLOYMENT +.Nm +is meant to be run by +.Xr inetd 8 . +.Pp +/etc/inetd.conf example using a dedicated gopher_user: +.Bd -literal -offset indent +70 stream tcp nowait gopher_user /usr/local/bin/khan khan +.Ed +.Sh EXIT STATUS +.Ex -std khan +.Sh SEE ALSO +.Xr chroot 2 , +.Xr unveil 2 , +.Xr inetd 8 +.Sh AUTHORS +.An See the LICENSE file for the authors . +.Sh LICENSE +See the LICENSE file for the terms of redistribution. diff --git a/main.c b/main.c new file mode 100644 index 0000000..dd66fc5 --- /dev/null +++ b/main.c @@ -0,0 +1,188 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define BUFF_LEN_1 1000 +#define BUFF_LEN_2 1025 +#define BUFF_LEN_3 1024 +#define DEFAULT_CHROOT "/var/gopher/" + + +void display_file(const char *); +void drop_privileges(const char *, const char *); + +void +drop_privileges(const char *user, const char *path) +{ + struct passwd *pw; + char chroot_dir[BUFF_LEN_2]; + + strlcpy(chroot_dir, path, sizeof(chroot_dir)); + + /* + * use chroot() if an user is specified requires root user to be + * running the program to run chroot() and then drop privileges + */ + if (strlen(user) > 0) { + + /* is root? */ + if (getuid() != 0) { + syslog(LOG_DAEMON, "chroot requires program to be run as root"); + errx(1, "chroot requires root user"); + } + /* search user uid from name */ + if ((pw = getpwnam(user)) == NULL) { + syslog(LOG_DAEMON, "the user %s can't be found on the system", user); + err(1, "finding user"); + } + /* chroot worked? */ + if (chroot(chroot_dir) != 0) { + syslog(LOG_DAEMON, "the chroot_dir %s can't be used for chroot", chroot_dir); + err(1, "chroot"); + } + if (chdir("/") == -1) { + syslog(LOG_DAEMON, "failed to chdir(\"/\")"); + err(1, "chdir"); + } + /* drop privileges */ + if (setgroups(1, &pw->pw_gid) || + setresgid(pw->pw_gid, pw->pw_gid, pw->pw_gid) || + setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid)) { + syslog(LOG_DAEMON, "dropping privileges to user %s (uid=%i) failed", + user, pw->pw_uid); + err(1, "Can't drop privileges"); + } + strlcpy(chroot_dir, "/", sizeof(chroot_dir)); + } +#ifdef __OpenBSD__ + /* + * prevent access to files other than the one in path + */ + if (unveil(chroot_dir, "r") == -1) { + syslog(LOG_DAEMON, "unveil on %s failed", chroot_dir); + err(1, "unveil"); + } + /* + * prevent system calls other parsing queryfor fread file and + * write to stdio + */ + if (pledge("stdio rpath", NULL) == -1) { + syslog(LOG_DAEMON, "pledge call failed"); + err(1, "pledge"); + } +#endif + + + +} + +void +display_file(const char *path) +{ + size_t buflen = BUFF_LEN_1; + char *buffer[BUFF_LEN_1]; + ssize_t nread; + struct stat sb; + FILE *fd; + + /* this is to check if path is a directory */ + if (stat(path, &sb) == -1) + goto err; + + /* open the file requested */ + if ((fd = fopen(path, "r")) == NULL) + goto err; + + /* check if directory */ + if (S_ISDIR(sb.st_mode) == 1) + goto err; + + /* read the file and write it to stdout */ + while ((nread = fread(buffer, sizeof(char), buflen, fd)) != 0) + fwrite(buffer, sizeof(char), nread, stdout); + fclose(fd); + syslog(LOG_DAEMON, "path served %s", path); + + return; + +err: + /* return an error code and no content */ + printf("resource not found for %s\n", path); + syslog(LOG_DAEMON, "path invalid %s", path); +} + +int +main(int argc, char **argv) +{ + char buffer [BUFF_LEN_2]; + char request [BUFF_LEN_2]; + char path [BUFF_LEN_2] = DEFAULT_CHROOT; + char user [_SC_LOGIN_NAME_MAX] = ""; + int option; + int chroot = 0; + char *pos; + + while ((option = getopt(argc, argv, ":d:u:")) != -1) { + switch (option) { + case 'd': + strlcpy(path, optarg, sizeof(path)); + break; + case 'u': + chroot = 1; + strlcpy(user, optarg, sizeof(user)); + break; + } + } + + /* + * do chroot if an user is supplied run pledge/unveil if OpenBSD + */ + drop_privileges(user, path); + if (chroot == 1) + strlcpy(path, "/", sizeof(path)); + + /* + * read 1024 chars from stdin + * to get the request + */ + fgets(request, BUFF_LEN_3, stdin); + + /* remove \r\n at the end of string + * replace \n first and then \r + * because some client may only use + * \n instead of \r\n + */ + pos = strchr(request, '\n'); + if (pos != NULL) *pos = '\n'; + + pos = strchr(request, '\r'); + if (pos != NULL) *pos = '\0'; + + syslog(LOG_DAEMON, "request %s", request); + + /* + * look for the first / after the hostname + * in order to split hostname and uri + */ + fprintf(stderr, "<%s %ld>\n", request, strlen(request)); + if(strlen(request) == 0 || strcmp(request, "/") == 0) { + fprintf(stderr, "<%s %ld>\n", request, strlen(request)); + strlcpy(request, "/gophermap", sizeof(request)); + } + + /* add the base dir to the file requested */ + strlcat(path, request, sizeof(path)); + + /* open file and send it to stdout */ + display_file(path); + + return (0); +} diff --git a/tests/test.sh b/tests/test.sh new file mode 100644 index 0000000..6cdc406 --- /dev/null +++ b/tests/test.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +set -x + +# md5 is BSD md5 binary +# Linux uses md5sum +MD5=md5 +type md5 2>/dev/null +if [ $? -ne 0 ]; then + MD5=md5sum +fi + +# serving a file +OUT=$(printf "/main.gph\r\n" | ../khan -d var/gopher/ | tee /dev/stderr | $MD5) +if ! [ $OUT = "979481230b482d033c5a6e6c566c0322" ] ; then echo "error" ; exit 1 ; fi + +# default gophermap file with nothing +OUT=$(printf "\r\n" | ../khan -d var/gopher/ | tee /dev/stderr | $MD5) +if ! [ $OUT = "979481230b482d033c5a6e6c566c0322" ] ; then echo "error" ; exit 1 ; fi + +# default gophermap with / +OUT=$(printf "/\r\n" | ../khan -d var/gopher/ | tee /dev/stderr | $MD5) +if ! [ $OUT = "979481230b482d033c5a6e6c566c0322" ] ; then echo "error" ; exit 1 ; fi + +# file not existing +OUT=$(printf "/foobar\r\n" | ../khan -v -d var/gopher/ | tee /dev/stderr | $MD5) +if ! [ $OUT = "fd5f8d5e7e0dab56056547bc1d9f2f3e" ] ; then echo "error" ; exit 1 ; fi + +# must fail only on OpenBSD ! +# try to escape from unveil +if [ -f /bsd ] +then + OUT=$(printf "/../../test.sh\r\n" | ../khan -d var/gopher/ | tee /dev/stderr | $MD5) + if [ $OUT = "$(cat $0 | $MD5)" ] ; then echo "error" ; exit 1 ; fi +fi + +echo "SUCCESS" diff --git a/tests/var/gopher/gophermap b/tests/var/gopher/gophermap new file mode 100644 index 0000000..d967f19 --- /dev/null +++ b/tests/var/gopher/gophermap @@ -0,0 +1 @@ +1menu hostname 2345 a diff --git a/tests/var/gopher/main.gph b/tests/var/gopher/main.gph new file mode 100644 index 0000000..d967f19 --- /dev/null +++ b/tests/var/gopher/main.gph @@ -0,0 +1 @@ +1menu hostname 2345 a