tiny DIY url shortener service

reinhard@finalmedia.de Fri 31 Mar 2023 05:46:47 PM CEST

Zero Knowledge Backend Service

Prinzipiell ist das nur ein kleiner Key-Value Storage Service. Sehr minimalistisch aufgebaut und mit granularer priviledge separation über separate Prozesse via IPC.

Zuerst musst du dir meine nachfolgend drei aufgeführten Tools besorgen, compilieren und die aus dem compiler herausfallenden statisch gelinkten musl-gcc binaries unter /usr/bin/ installieren:

Bauen und Installieren kannst du z.B. auf diese Tools z.B: auf diese Weise

Da wir kleine, statische gelinkte Binaries möchten, bitte als root die musl-libc und build essentials installieren, falls noch nicht vorhanden

# do as root
apt-get install musl-tools make build-essential

Als normaler, eingeschränkter System-User bauen wir dann die Tools aus den Quellen



# do as normal system user
mkdir -p linky/bin linky/src
cd linky/src
for name in onlyif prepend_cubehash writelinefile
do
wget https://finalmedia.de/code/$name.tar.gz
tar xvzf $name.tar.gz
cd $name
make
cp $name ../../bin/
cd ..
done
cd ..


Kopiere dann als als root die compilierten statisch gelinkten und in bin erstellten binaries nach /usr/bin/

# do as root
cp ./linky/bin/* /usr/bin/

Desweiteren setze ich voraus, dass du bereits die daemontools installiert hast. Wenn nicht, dann

# do as root
apt-get install daemontools daemontools-run

Zudem setze ich voraus, dass du ohnehin einen readonly https daemon auf dem Server aktiv hast, der statische Dateien ausliefern kann. In diesem Fall ein apache2 mit TLS und letsencrypt acme Certs,z.B. in einem Jail. Dynamischer Content muss nicht unterstützt werden. Also kein cgi und kein php etc. Wir müssen lediglich die Logdateien des Servers auswerten können.

Dies ist nun das run Script in /srv/linky/run und das Herzstück des Backend:


#!/bin/sh
# +++ /srv/linky/run +++
# running as root and dropping priviledges
#
# reinhard@finalmedia.de
# Fri 31 Mar 2023 04:22:16 PM CEST
# use tail to read newest line of /dev/shm/get.log and follow every new line.
# process further in pipe.
# only do this, if the line contains an valid access token between position 2 and 33.
# all valid access tokens are given filenames in directory "allowed/"
# and are exactly 32 chars long.
# filter out further line content to just allow base64 chars.
# truncated line if more than 4096 chars
# calculate cubehash for this extracted content.
# save content to a file named by the cubehash
# of this content. since we set umask 266, all
# written files have no write permission afterwards.
# this protects them from overwriting, until you
# manually allow write again via chmod u+w

# max chars per line: 4096

# important umask, so its not allowed to overwrite files again.
# BUT THIS ONLY WORKS, IF writelinefile DOES NOT RUN AS ROOT!
# so always use setuidgid or unshare -n -S.. -G..
# and priviledge separation as follows.

u1=$(id -u nobody)
u2=$(id -u www-data)
g1=$(id -g nobody)
g2=$(id -g www-data)

exec 2>&1

export targetdir="/var/www/linky/files/"
export tokendir="/etc/linky/tokens/allowed/"

umask 266 || exit 1

exec \
unshare -n -S$u1 -G$g1 tail -n0 -F /dev/shm/get.log | \
unshare -n -S$u1 -G$g1 stdbuf -i0 -o0 onlyif 2 32 "$tokendir" | \
unshare -n -S$u1 -G$g1 stdbuf -i0 -o0 cut -d ":" -f2- | \
unshare -n -S$u1 -G$g1 stdbuf -i0 -o0 tr -dc "0-9a-zA-Z/+=\n" | \
unshare -n -S$u1 -G$g1 stdbuf -i0 -o0 cut -c 1-4096 | \
unshare -n -S$u1 -G$g1 stdbuf -i0 -o0 prepend_cubehash | \
unshare -n -S$u1 -G$g1 stdbuf -i0 -o0 sed "s|^|$targetdir|g" | \
unshare -n -S$u2 -G$g2 stdbuf -i0 -o0 writelinefile


(Es würde sich hier auch empfehlen mit subuids zu arbeiten, also apt-get install uidmap rootlesskit) und die Privilegien der jeweiligen Prozesse weiter zu reduzieren. onlyif braucht nur leserechte auf das angegebene verzeichnis. mit unshare -n entziehen wir jedem Prozess die Netzwerkfähigkeit.

Deklariere nun das run script als ausführbar und starte es mit den daemontools:


chmod +x /srv/linky/run
ln -s /srv/linky/ /etc/service/

Lege nun das Token-Verzeichnis an und erstelle dort ein 32 Zeichen Demo-Token (Hier nur ein Beispiel):

mkdir -p /var/www/linky/
chown www-data:www-data /var/www/linky

mkdir -p /etc/linky/tokens/allowed
touch /etc/linky/tokens/allowed/3MzHjNCtHvU4KJVWmPiJdCCd4KWjkbqA

Du kannst die Funktion nun testen mittels:

echo "?3MzHjNCtHvU4KJVWmPiJdCCd4KWjkbqA:$(date | base64 -w0)" | tee -a /dev/shm/get.log

Du kannst zur Laufzeit jederzeit weitere Tokens hinzufügen oder entfernen, indem du einfach diese leeren Datei im Ordner allowed anlegst oder entfernst. Wir nutzen das Dateisystem als minimalistische Datenbank.

Wozu nun dieses get.log?

Nun dorthin lassen wir z.B. einen apache2 webserver eine spezielle log Datei hinschreiben - und zwar in einem speziellen Logformat, das ausschließlich den query string der Webseiten-Anfrage protokolliert (Das musst du beachten, wenn du anstatt apache2 einen einen anderen httpd verwendest!). Der Eintrag in der apache2 vhosts config sieht so aus, das dann noch um ein Ratelimit pro IP-Adresse ergänzt werden sollte:


	LogFormat "%q" requestonly

	SetEnvIf Request_URI ^/linky/store/add/ getlog
	CustomLog /dev/shm/get.log requestonly env=getlog

	<Location "/linky/">
	Options -Indexes
	</Location>

Das bedeutet, dass bei jedem Aufruf der https://deinedomain.de/linky/store/add/ ein Logzeilen Eintrag in der /dev/shm/get.log auftauchen wird, dort jedoch nur der query. Sprich: Ein Aufruf von https://deinedomain.de/linky/store/add/?erbsen=test sorgt für eine Zeile mit dem Inhalt "?erbsen=test" in der Logdatei, da nur dies für uns interessant ist. In der übrigen regulären Logdatei des Servers wird der Aufruf natürlich ebenso protokolliert, dann auch mit üblicher Zeitangabe, IP-Adresse etc.

Bei einem Aufruf mit einem erlaubtem Token, lässt das Backend also den request durch:

https://deinedomain.de/linky/store/add/?3MzHjNCtHvU4KJVWmPiJdCCd4KWjkbqA:HIER_IST_CONTENT

Unser kleiner daemontools backend service hier wiederum lies eben diese Logdatei /dev/shm/get.log aus der ramdisk und überwacht jede neu auftauchende Zeile (schlicht mittels tail). Er prozessiert dann nur zeilen weiter, die mit erlaubten Access Tokens beginnen, wirft alle zeichen bis zum ersten Doppelpunkt weg und speichert somit nur den übermittelten content in der datei. Als dateiname verwendet das Backend den cubehash des übermittelten contents.

Das Backend hat somit später keinerlei Kenntnis über den eigentlichen Inhalt, da dieser verschlüsselt abgelegt wird. Es schreibt einfach nur beliebigen content in eine Datei, sobald ein legitimes access token übergeben wurde. Eine Replay-Attacke ist damit selbstverständlich auch möglich, daher *muss* der service mit https verwendet werden. Replay Angriff ist hier aber uninteressant, denn zum einen kann der content ja nicht mehrfach gespeichert werden, weil wir die schreibrechte nach dem einmaligen anlegen entziehen... (umask) und zum anderen kann er auch nicht manipuliert werden, da sich dann ja auch ein anderer hash als namen ergeben würde - da sich der hash ja aus dem übermittelten Inhalt ableitet.

Frontend Service

Das Frontend frontend.htm (Rechtsklick > Ziel speichern unter) ist nur statischer HTML und inline Javascript content, der vom Apache ausgeliefert wird. Es nutzt tweet-nacl.js public domain Implementierung, um chacha20 Verschlüsselung des Contents durchzuführen. Zudem ist hier auch eine public domain cubehash Implementierung in Javascript integriert.

Weiterführende Informationen zu cubehash findest du unter https://cubehash.cr.yp.to/.

cubehash stammt von djb und kam bei der SHA-3 Standardisierung des NIST im SHA-3-Wettbewerb mit in die letzte Runde. Es ist ein bzgl. Performance in Software interessanter als der zum Gewinner erklärte Kandidat "Keccak".

Das Javascript Frontend erstellt clientseitig im Browser aus Klartext-Content dann Crypto-Content. Da es auch den crypto-content kennt, kann es daraus also ebenso einen identischen cubehash erzeugen. Es dann gibt dann einen shortlink aus, der den gehashten Namen enthält, den key und die nounce. Dieser Cryptolink kann dann auf separatem Wege über einen sicheren Kanal an den eigentlichen Empfänger weitergereicht werden. Zudem weiß das Javascript damit wo der Server die Datei abgelegt hat, da es ja den Dateinamen kennt, der sich aus dem hash des cryptocontents ableitet.

Beispielweise Wird nun ein vom Javascript clientseitig erstellter Cryptolink aufgerufen

https://deinedomain.de/linky/#hash#key#nounce

(wobei hash, key und nounce natürlich dann korrekte Werte enthalten), dann läd das Javascript - weil es sich um Anchor Elemente der URL handelt, die nicht an den Server übermittelt werden, wiederum die daraus abgeleitete

https://deinedomain.de/linky/files/HIER_HASH

vom Server, die ja nur den base64 encodierten crypto content beinhaltet. Anhand des im anchor, also via # clientseitig übergebenen keys entschlüsselt es den Inhalt und stellt ihn dar. Beginnt der Inhalt mit http:// oder https://, so startet das Javascript eine automatische Weiterleitung zu diesem Link.

Da die Parameter via Anchor übergeben werden, landen diese auch nicht in den Logs des Servers und können dort nicht erfasst werden.

Auch ist es möglich eine lokale Kopie des Frontends zu führen und somit auf den Server wirklich nur zum Zwecke des Storage zu vertrauen. Das Frontend muss nicht vom Server ausgeliefert werden.

Tipp: Alternatives Backend als Variante

Wird der Log des eingesetzten httpd auf dem System nicht für andere Zwecke verwendet, bietet sich auch diese Alternative an: Logfilefreie lokale Test-Variante via tcpserver (ucspi-tcp) und publicfile als oneliner (ohne apache), die uid 65534 von nobody ist natürlich überall entsprechend anzupassen und dann auch eine priviledge separation über diverse uids und subuids des systems durchzuführen. Daher unten auch im Beispiel uid 1234 für den schreibenden prozess.

umask 266 || exit 1
cd /var/www || exit 1
exec tcpserver -u 65534 -c 10000 -R -H 127.0.0.1 80 httpd 2>&1 | \
unshare -n -S65534 -G65534 stdbuf -i0 -o0 grep -o "?.*" | \
unshare -n -S65534 -G65534 stdbuf -i0 -o0 cut -d " " -f1 | \
unshare -n -S65534 -G65534 stdbuf -i0 -o0 onlyif 2 32 /etc/linky/tokens/allowed/ | \
unshare -n -S65534 -G65534 stdbuf -i0 -o0 cut -d ":" -f2- | \
unshare -n -S65534 -G65534 stdbuf -i0 -o0 tr -dc "0-9a-zA-Z+=\n" | \
unshare -n -S65534 -G65534 stdbuf -i0 -o0 cut -c 1-4096 | \
unshare -n -S65534 -G65534 stdbuf -i0 -o0 prepend_cubehash | \
unshare -n -S65534 -G65534 stdbuf -i0 -o0 sed "s|^|/var/www/linky/files/|g" | \
unshare -n -S1234 -G1234 stdbuf -i0 -o0 writelinefile

Tipp: Durch die Software-Architektur und bewusste Auftrennung in separate Systemprozesse unter Nutzung von IPC (Interprozesskommunikation) via pipe, ist es auch möglich, den schreibenden Teil wiederum komplett auszulagern, indem dies z.B. durch eine SSH pipe remote geführt und erst dort dann writelinefile genutzt wird.