Prof. Dr.-Ing. Oliver Radfelder
Informatik / Wirtschaftsinformatik
Hochschule Bremerhaven
cgi mit bash

Das Common Gateway Interface (cgi) ist mit seinem Enstehungsjahr 1993 eine der ältesten Techniken, um im Web dynamische Inhalte auszuliefern. Die Technologie cgi ist nicht an eine Programmiersprache gebunden, sondern beschreibt lediglich die Schnittstelle zwischen Webserver (apache, nginx, etc) und auszuführenden Programmen.

Die Grundidee ist, dass der Webserver selbst den Request entgegennimmt und unter bestimmten Voraussetzungen (die URL beinhaltet z.B. cgi-bin/) ein externes Programm aufruft. Die konkrete Anfrage wird als Menge von belegten Umgebungsvariablen und gegebenenfalls über Stdin an den Prozess übergeben und das Ergebnis wird über dessen Stdout entgegengenommen und an den aufrufenden Browser weitergeleitet.

cgi ist durchaus ein Beispiel für gut durchdachte Softwaretechnik: es wird die Schnittstelle beschrieben, ohne sich auf Details wie die zu implementierende Sprache festzulegen. Daher kann eine cgi-Anwendung auch in jeder Programmiersprache geschrieben werden, in der auf Umgebungsvariablen, Stdin und Stdout zugegriffen werden kann: C, Java, Bash, Javascript, etc.

Bei uns bietet sich die Bash an, weil Ihr sie und die zugehörigen Posix-Programme seit der ersten Woche des Studiums kennenlernt.

Es gibt heute eine Menge anderer Technologien, die für anspruchsvolle Web-Anwendungen geeigneter sind, die aber nicht so einfach einzusetzen sind. Bis Ihr beispielsweise in Java in der Lage seid, mit Servlet-Technologien verstehend zu programmieren, müsst Ihr in Java sehr viel tiefer eingestiegen sein als wir das im ersten Semester tun können.

Daher nutzen wir hier die Bash als cgi-Sprache und schauen uns Servlets im dritten Semester an.

cgi und das Http-Protokoll

Die Grundlage des Webs liefert seit Beginn der 90er Jahre das Http-Protokoll.

Der Client - also der Browser - initiiert eine Verbindung mit dem Web-Server, entweder nachdem eine URL in das entsprechende Feld eingegeben wurde oder indem ein Link angeklickt wurde. In dem Moment bauen Client und Server eine TCP-Verbindung auf.

Der Client schickt nun über diese Verbindung seine Anfrage als Abfolge von Textzeilen, die mit einer Leerzeile abgeschlossen wird. Danach sendet der Server entweder das angefragte Dokument zurück oder eine Fehlerseite. Genau in diesen Prozess wird das cgi- Skript so eingehängt, dass dessen Ausgabe an den Client weitergesandt wird.

Wenn wir im Folgenden cgi untersuchen wollen, ist es hilfreich, statt jeweils zum Browser zu greifen, dass wir uns mit curl vertrauter machen.

Mit curl werden Http-Requests abgesetzt, wobei die Ausgabe des Servers im Terminal auf Stdout ausgegeben wird.

# einfacher Request
curl https://informatik.hs-bremerhaven.de/docker-demo-web/

# einfacher Request im verbose mode
curl -v https://informatik.hs-bremerhaven.de/docker-demo-web/

# einfacher Request mit http 1.1 im verbose mode
curl --http1.1 -v https://informatik.hs-bremerhaven.de/docker-demo-web/

# Request mit Get-Parametern
curl "https://informatik.hs-bremerhaven.de/docker-demo-web/cgi-bin/query.sh?search=word&lang=de"

# Response mit Headern
curl -i "https://informatik.hs-bremerhaven.de/docker-demo-web/cgi-bin/query.sh?search=word&lang=de"

# nur Header
curl -I "https://informatik.hs-bremerhaven.de/docker-demo-web/cgi-bin/query.sh?search=word&lang=de"

# Ausgabe von Informationen (siehe: man curl)
curl -s -o /dev/null -w "%{scheme} %{time_connect} %{time_total} %{response_code} %{method} %{content_type} %{size_download}\n" \
"https://informatik.hs-bremerhaven.de/docker-demo-web/cgi-bin/query.sh?search=word&lang=de"
HTTPS 0.003102 0.064156 200 GET text/plain 41
    
Das erste CGI-Script

Gehe in Deinen Docker-Container und dort in das Verzeichnis /usr/lib/cgi-bin/. Dort findest Du eine Vorlage für cgi-Skripte: demo.sh.

#!/usr/bin/env bash
echo "Content-Type: text/plain"
echo
echo moin

Das Skript kannst Du auf dem Server ausführen lassen, indem Du es mit curl über den Webserver ansprichst:

curl https://informatik.hs-bremerhaven.de/docker-demo-web/cgi-bin/demo.sh

Jedes cgi-Skript muss zunächst in einer Header-Zeile angeben, welcher Content-Type zurückgeschrieben werden soll und dann eine Leerzeile senden, um anzuzeigen, dass danach der Inhalt der Anwort kommt. Im einfachsten Fall, wie hier, wird mit Content-Type: text/plain angegeben, dass einfacher Text geschickt wird.

Da das Skript ein Bash-Skript ist, wie im Shebang angegeben, kann auch hier wieder alles getan werden, was in einem Bash-Skript oder auf der Kommandozeile möglich ist: Programme aufrufen, Schleifen, Bedingungen, Pipes, etc.

Alles, was das Skript in irgendeiner Weise auf Stdout rausschreibt, wird an den Browser geschickt.

#!/usr/bin/env bash
echo "Content-Type: text/plain"
echo
i="0"
while test "$i" -lt 10; do
  echo "Zeile $i"
  ((++i))
done
seq 10 | sed 's/^/hallo /g'
    

Schau Dir das Ergebnis sowohl mit curl als auch im Browser an. Mit curl solltest Du den Schalter -v nutzen, um den Verlauf zwischen Client und Server zu untersuchen. Im Browser wird der Text ungewohnt schmucklos dargestellt, denn im Content-Type steht text/plain. Du solltest Dir angewöhnen, die Devloper Tools offen zu halten, um zu verfolgen, was geschieht.

Um Html vom Browser anzeigen zu lassen, müssen wir zum einen den Content-Type zu text/html ändern und zum anderen auch wirklich Html-Code ausgeben.

#!/usr/bin/env bash
echo "Content-Type: text/html"
echo
echo "<!DOCTYPE html>
<html>
  <head>
    <meta charset='utf-8'>
    <title>cgi</title>
  </head>
  <body>"

i="0"
while test "$i" -lt 3; do
  echo "<h2>Überschrift Nr. $i<h2>"
  ((++i))
done

echo "  </body>
</html>"

Das Herausschreiben über echo von HTML-Code kann etwas mühselig werden. In dem dynamischen Teil wird das nicht zu vermeiden sein. Allerdings können wir insbesondere den Kopf und den Fuß auslagern und beispielsweise mit cat ausgeben. Dann haben wir in den Teil-Dateien wenigstens Syntax-Highlighting. Kleinere Anpassungen (z.B. der Titel) können dann gut mit sed durchgeführt werden.

#!/usr/bin/env bash
echo "Content-Type: text/html"
echo
cat html-head.html
i="0"
while test "$i" -lt 3; do
  echo "<h2>Überschrift Nr. $i<h2>"
  ((++i))
done
cat html-foot.html
QUERY_STRING und Parameter

Wenn im Web ein Formular ausgefüllt wird, werden die Werte entweder als GET oder POST übertragen. In tatsächlichen Anwendungen wird man POST nehmen, da hier die Länge der Anfrage unbeschränkt ist. Leichter aber zu verstehen ist zunächst das Arbeiten mit GET. Übergebene Parameter werden url-encoded, was bedeutet, dass sie als Key-Value Liste mit dem Request zusammen gesandt werden.

Gib das folgende HTML-Formular in Deinem Docker-Container in das Verzeichnis /var/www/html/docker-...-web/ als form.html ein und lade es in den Browser mit https://informatik.hs-bremerhaven.de/docker-...-web/form.html. Ersetze dabei ... durch Deinen hopper-Account.

<!DOCTYPE html>
<html>
  <head>
    <meta charset='utf-8'>
    <title>form</title>
  </head>
  <body>
    <form action='cgi-bin/demo.sh' method='get'>
      Name:<br>
      <input type=text name="name"><br>
      Alter:<br>
      <input type=text name="alter"><br>
      <input type=submit>
    </form>
  </body>
</html>

Ändere nun Dein cgi-Skript so, dass die relevanten Umgebungsvariablen als einfacher Text ausgegeben werden und probiere unterschiedliche Wert aus:

#!/usr/bin/env bash
echo "Content-Type: text/plain"
echo
echo "SERVER_NAME: $SERVER_NAME"
echo "REQUEST_METHOD: $REQUEST_METHOD"
echo "REMOTE_ADDR: $REMOTE_ADDR"
echo "QUERY_STRING: $QUERY_STRING"

Trage unterschiedliche Werte in die beiden Felder ein und beobachte, was geschieht, wenn Du sie sendest. Versuche auf jeden Fall Leerzeichen, Umlaute und sonstige Sonderzeichen wie Plus, Fragezeichen, Ampersand (&), Dollar etc. Versuche auch, ein drittes Eingabefeld in das Formular einzutragen und beobachte die Variable QUERY_STRING. Was Du beobachtest, ist das sogenannte URL-Encoding, also das Codieren von möglichen Zeichen in einer Form, so dass sie in einer URL enthalten sein können.

In jedem Fall müssen wir die Werte, die am Ampersand getrennt sind, auseinandernehmen, um damit arbeiten zu können.

Es gibt dafür viele Varianten. Eine sehr einfache, allerdings begrenzt funktionsfähige, ist die folgende. Dabei müssen die Namen der Parameter so eindeutig sein, dass sie sich nicht überlappen (denke an name und nachname!) und wir beschränken uns auf den ASCII-Wertebereich in den Werten - also keine Umlaute, Leerzeichen etc.:

#!/usr/bin/env bash
echo "Content-Type: text/plain"
echo
echo "SERVER_NAME: $SERVER_NAME"
echo "REQUEST_METHOD: $REQUEST_METHOD"
echo "REMOTE_ADDR: $REMOTE_ADDR"
echo "QUERY_STRING: $QUERY_STRING"
echo
name=$(echo "$QUERY_STRING" | sed -e 's/^.*name=//g' -e 's/&.*//g')
echo "name:$name"
alter=$(echo "$QUERY_STRING" | sed -e 's/^.*alter=//g' -e 's/&.*//g')
echo "alter:$alter"

Für eine reale Anwendung ist das aus vielerlei Gründen nicht ausreichend: Zum einen fehlen eben Umlaute und Sonderzeichen. Zum anderen fehlt noch eine Überprüfung, ob die Schlüssel überhaupt vorhanden sind. Desweiteren sind Kommandosubstitution und sed-Aufrufe nicht gerade effizient. Nicht zuletzt würde für ausgefüllte Formulare besser POST als GET genommen. Es gibt also noch ausreichend Platz für Verbesserungen. Für eine einfache, STEP-konforme Anwendung sind aber die bekannten Mechanismen mit sed oder cut und Kommandosubstitution mehr als ausreichend.

Zudem sollten ja gar nicht unbedingt Usereingaben in Formularen mit Textfeldern der erste richtige Anwendungsfall sein. Stellt Euch stattdessen vor, Ihr habt eine Liste mit z.B. Personen in Form von eindeutigen Matrikelnummern und wollt auf jeden einzelnen Eintrag klicken können, um die sortierte Liste der belegten Module zu dieser Person anzeigen zu lassen. Dann würden HTML-Anker um die einzelnen Personen in der Liste direkt auf ein cgi-Skript verweisen und die Matrikel-Nummer wäre in das href-Attribut direkt einkodiert. Eine Skizze für diese kleine Anwendung bieten die beiden folgenden Skripte.

#!/usr/bin/env bash
# list-students.sh
echo "Content-Type: text/html"
echo
cat html-head.html
cat /usr/lib/cgi-bin/data/matrikel.csv | while read -r matr; do
  echo "<a href='show-student.sh?matr=$matr'>$matr</a><br>"
done
cat html-foot.html
#!/usr/bin/env bash
# show-student.sh
echo "Content-Type: text/html"
echo
cat html-head.html
if ! echo "$QUERY_STRING" | grep -q '^matr='; then
  echo "ups ..."
  cat html-foot.html
  exit
fi
matr=$(echo "$QUERY_STRING" | sed 's/^matr=//g')
echo "</h1>you are looking for student: $matr</h1>"
# get information for $matr from other file
# ... grep "^$matr " /usr/lib/cgi-bin/matr-results.csv ...
cat html-foot.html

Content-Types

Eine kleine Auswahl an möglichen Content-Types.

DateiformatContent-Type
Texttext/plain
Htmltext/html
pngimage/png
jpgimage/jpeg
pdfapplication/pdf
jsonapplication/json
xmlapplication/xml
mp4video/mp4
webmvideo/webm

In diesem Zusammenhang sei noch erwähnt, dass überall, wo eine Ressource als URL in Html steht, auch ein Verweis auf eine dynamisch generierte Ressource stehen kann.

<!DOCTYPE html>
<html>
  <head>
    <meta charset='utf-8'>
    <title>form</title>
  </head>
  <body>
    <img src='cgi-bin/create-image.sh'>
  </body>
</html>

Dabei sollte natürlich darauf geachtet werden, dass das Bild wirklich sehr schnell erzeugt wird, sonst baut sich die Seite langsam und ruckelig auf. Meist ist es besser, wenn erzeugte Bilder in irgendeiner Weise vorgeneriert oder gecached werden.

Typische Fallstricke

Macht Euch klar, dass cgi-Skripte im Namen des Users www-data ausgeführt werden und dementsprechend die Posix-Rechte mit chmod gesetzt sein müssen.

Insbesondere beim Erzeugen von temporären Dateien ist darauf zu achten, dass diese Dateien im Allgemeinen nicht einfach von dem normalen docker-User entfernt werden können. Es hilft zumeist, ein gesondertes clean.sh-Skript anzulegen, das nur aktiv wird, wenn es mit curl http://localhost/docker-...-web/cgi-bin/clean.sh direkt aus dem docker-Container aufgerufen wird. Dann läuft es nur über den lokalen Apache und das kann über die Variable REMOTE_ADDR geprüft werden.

Beim Erzeugen von temporären Dateien muss darauf geachtet werden, dass sich nicht zeitgleich laufende Skripte gegenseitig Dateien überschreiben. Die Variable $$ beschreibt in der Bash die aktuelle Prozess-ID und ein typisches Muster ist, ein temporäres Verzeichnis anzulegen, die notwendigen Skripte dort hineinzukopieren, und im weiteren dort zu arbeiten. Zum Schluss nicht vergessen, aufzuräumen, weil demnächst garantiert die Prozess-ID wiederverwendet wird. Während der Entwicklung kann das Aufräumen unterlassen werden, um Zwischenstände zu erhalten und Fehler zu finden.

#!/usr/bin/env bash
echo "Content-Type: image/png"
echo

# prepare
work=/tmp/imagecreation-$$/
mkdir -p "$work"
cp -r /usr/lib/cgi-bin/imagecreation/* $work

# do the work
cd "$work" || exit 1
./create.sh > image.png
cat image.png

# clean up
cd /tmp || exit 1
rm -rf "$work"
Fortgeschrittenes

Normalerweise wird man in realen Anwendungen Formulare über POST an den Server schicken. Anders als bei GET werden die Eingaben per Stdin an das cgi-Programm gesandt. Zudem können wir über die Umgebungsvariable CONTENT_LENGTH auslesen, wieviel Bytes wir erwarten können. Der Inhalt selbst wird üblicherweise ebenfalls url-encoded übergeben. Aus Sicherheits- und Performancegründen wird hier mit Variablensubstitution, Bash-Arrays und noch einigen anderen Mechanismen gearbeitet, die wir so nicht im ersten Semester behandeln. Also wirklich: fortgeschritten! Wer es einsetzen will, muss es erklären können.

Dennoch werde ich meine Hand nicht dafür ins Feuer legen, dass das Verfahren nicht angreifbar wäre, also: Vorsicht.

#!/usr/bin/env bash
echo "Content-Type: text/plain; charset=utf-8"
echo
if test "$REQUEST_METHOD" = POST; then
  read -r -N "$CONTENT_LENGTH" POST
  echo "content-length: $CONTENT_LENGTH"
  echo "content-type: $CONTENT_TYPE"
  echo "POST  : $POST"
  regex='[a-zA-Z0-9=&_+/%;.!~*-]*'
  if ! [[ "$POST" =~ $regex ]]; then
    echo "someone sent malicious content"
    exit
  fi

  # read into array
  IFS="&" read -r -a POSTARRAY  <<< "$POST"

  # iterate through array
  for e in "${POSTARRAY[@]}"; do
    echo "  $e"
    # left up to =
    key=${e%%=*}
    # right after =
    value=${e#*=}
    echo "  $key:$value"

    # replace % by \x for unicode
    decodedtmp=${value//%/\\x}
    decoded=${decodedtmp//+/ }
    echo "  $key:$decoded"
    echo -e "$key:$decoded"
  done
fi

Sessions werden typischerweise über Cookies verfolgt. Das sind Header-Informationen, die vom Server einmalig rausgesandt werden und die sich der Client - also der Browser - merkt und bei jeder Folgeanfrage mitsendet, damit wir sie uns aus dem Header wieder herausholen können. Darüber werden beispielsweise Logins durchgeführt: Zunächst wird eine Session erzeugt, dann loggt sich der User ein und anschließend wird die Session anhand des Cookies als gültig oder ungültig behandelt.

#!/usr/bin/env bash
# set-mysession-cookie.sh
cookie="$(pwgen 40 1)"
mkdir -p /tmp/mysession-$cookie/
echo "0" > /tmp/mysession-$cookie/mycounter
echo "Content-Type: text/html"
echo "Set-Cookie: __SECURE-mysession=$cookie; Secure; HttpOnly; SameSite=Strict; Path=/docker-demo-web/"
echo
echo "The cookie is $cookie"
#!/usr/bin/env bash
# intern.sh
echo "Content-Type: text/plain"
cookieline=$(echo "$HTTP_COOKIE" | tr ";" "\n" | grep "^__SECURE-mysession=")
cookie=$(echo "$cookieline" | cut -d "=" -f 2 )
if test -d "/tmp/mysession-$cookie/"
then
  echo
  echo "mysession:$cookie"
  read -r now < /tmp/mysession-$cookie/mycounter
  echo $((now+1)) > /tmp/mysession-$cookie/mycounter
  cat "/tmp/mysession-$cookie/mycounter"
else
  echo "Status: 303 See Other"
  echo "Location: /docker-demo-web/"
  echo
fi
#!/usr/bin/env bash
# delete-mysession-cookie.sh
cookieline=$(echo "$HTTP_COOKIE" | tr ";" "\n" | grep "^__SECURE-mysession=")
cookie=$(echo "$cookieline" | cut -d "=" -f 2 )
rm -rf /tmp/mysession-$cookie/

echo "Content-Type: text/plain"
echo "Set-Cookie: __SECURE-mysession=; Secure; HttpOnly; Path=/docker-demo-web/; Expires=Thu, 01 Jan 1970 00:00:00 GMT;"
echo
echo "deleted $cookie"
Abschließendes

Sicher: cgi ist nicht besonders modern. Aber nach wie vor ist die Technologie so gradlinig, dass sich innerhalb eines Semesters hinreichend viel über die Bash und die zugehörigen Werkzeuge lernen lässt, dass damit eine kleine, aber vollständige, klassische Webanwendung mit Formularen, dynamischer Bild- und Pdf-Erzeugung, Sessions mit Userverwaltung und vielem mehr entwickelt werden kann und dass dennoch jeder einzelne Teil verstehbar ist. Diese Grundlagen werden dann später in anderen Sprachen wie Java, Php, Python, C++ oder Javascript hilfreich sein, denn dort werden die gleichen Mechanismen genutzt.

Weiterführendes