Compare commits

...

7 commits

Author SHA1 Message Date
Felix Niederwanger d486f8176d
Update section
Update the tls section to reflect that we're supporting tls now.
2024-03-20 20:14:34 +01:00
Felix Niederwanger eee10c16ad
Remove self-signed TODO
We're not going to support self-signed certificates from within weblug.
Remove the TODO section.
2024-03-20 20:12:41 +01:00
Felix Niederwanger 405cf8dfed Merge pull request 'Add TLS test' (#25) from testing into main
Reviewed-on: https://codeberg.org/grisu48/weblug/pulls/25
2024-02-26 14:57:43 +00:00
Felix Niederwanger ef0bc17e67
Add TLS test
Adds a unit test for the TLS webserver.
2024-02-26 13:50:30 +01:00
Felix Niederwanger d9d8b73b50 Merge pull request 'Remove leftovers' (#24) from testing into main
Reviewed-on: https://codeberg.org/grisu48/weblug/pulls/24
2024-02-26 09:41:25 +00:00
Felix Niederwanger 6cf62dc77c
Remove leftovers
Remove leftover config fields. Those are not used and should not have
been introduces in the previous commits.
2024-02-26 08:12:13 +01:00
Felix Niederwanger f6ebd78c43 Merge pull request 'Introduce tls support' (#22) from tls into main
Reviewed-on: https://codeberg.org/grisu48/weblug/pulls/22
2024-02-26 07:09:36 +00:00
6 changed files with 315 additions and 27 deletions

View file

@ -10,4 +10,5 @@ static: cmd/weblug/*.go
CGO_ENABLED=0 go build -ldflags="-w -s" -o weblug $^
test: weblug
go test ./...
sudo bash -c "cd test && ./blackbox.sh"

View file

@ -16,17 +16,12 @@ Webooks are defined via a yaml file. See [weblug.yml](weblug.yml) for an example
### Caveats
1. `weblug` does not support https encryption
1. `weblug` should not face the open internet
weblug is expected to run behind a http reverse proxy (e.g. `apache` or `nginx`) which handles transport encryption. The program it self does not support https, nor are there any plans to implement this in the near future.
weblug is expected to run behind a http reverse proxy (e.g. `apache` or `nginx`).
While the program itself does support tls, to avoid a whole class of security issues, `weblug` should never run on the open internet without a http reverse proxy.
CAVE: Don't expose secrets and credentials by running this without any transport encryption!
2. Do not run this without reverse proxy
`weblug` relies on the standart go http implementation. To avoid a whole class of security issues, `weblug` should never run on the open internet without a http reverse proxy.
3. `weblug` runs as root, when using custom UID/GIDs
2. `weblug` runs as root, when using custom UID/GIDs
In it's current implementation, `weblug` requires to remain running as root without dropping privileges when using custom UID/GIDs.

View file

@ -29,6 +29,7 @@ tasks:
preconditions:
- test -f weblug
cmds:
- go test ./...
- sudo bash -c "cd test && ./blackbox.sh"
vet:
cmds:

View file

@ -26,12 +26,10 @@ type ConfigSettings struct {
}
type TLSSettings struct {
Enabled bool `yaml:"enabled"`
MinVersion string `yaml:"minversion"`
MaxVersion string `yaml:"maxversion"`
Keypairs []TLSKeypairs `yaml:"keypairs"`
Keyfile string `yaml:"keyfile"`
Certificates []string `yaml:"certificates"`
Enabled bool `yaml:"enabled"`
MinVersion string `yaml:"minversion"`
MaxVersion string `yaml:"maxversion"`
Keypairs []TLSKeypairs `yaml:"keypairs"`
}
type TLSKeypairs struct {
@ -85,14 +83,14 @@ func (cf *Config) LoadYAML(filename string) error {
func ParseTLSVersion(version string) (uint16, error) {
if version == "" {
return tls.VersionTLS12, nil
} else if version == "1.0" {
return tls.VersionTLS10, nil
} else if version == "1.1" {
return tls.VersionTLS11, nil
} else if version == "1.2" {
return tls.VersionTLS12, nil
} else if version == "1.3" {
return tls.VersionTLS13, nil
} else if version == "1.2" {
return tls.VersionTLS12, nil
} else if version == "1.1" {
return tls.VersionTLS11, nil
} else if version == "1.0" {
return tls.VersionTLS10, nil
} else {
return 0, fmt.Errorf("invalid tls version string")
}

View file

@ -11,6 +11,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
@ -56,6 +57,20 @@ func sanityCheckHooks(hooks []Hook) error {
return nil
}
// Extract the hostname from an URL
func hostname(url string) string {
hostname := url
i := strings.Index(hostname, "://")
if i > 0 {
hostname = hostname[i+3:]
}
i = strings.Index(hostname, ":")
if i > 0 {
hostname = hostname[:i]
}
return hostname
}
func main() {
cf.SetDefaults()
if len(os.Args) < 2 {
@ -153,7 +168,7 @@ func CreateTLSListener(cf Config) (net.Listener, error) {
}
}
if cf.Settings.TLS.MaxVersion != "" {
tlsConfig.MaxVersion, err = ParseTLSVersion(cf.Settings.TLS.MinVersion)
tlsConfig.MaxVersion, err = ParseTLSVersion(cf.Settings.TLS.MaxVersion)
if err != nil {
return nil, fmt.Errorf("invalid tls max version")
}
@ -164,8 +179,7 @@ func CreateTLSListener(cf Config) (net.Listener, error) {
// Create self-signed certificate, when no keyfile and no certificates are present
if len(cf.Settings.TLS.Keypairs) == 0 {
// TODO
return nil, fmt.Errorf("creating self-signed certificates is not yet supported")
return nil, fmt.Errorf("no keypairs provided")
} else {
// Load key/certificates keypairs
tlsConfig.Certificates = make([]tls.Certificate, len(cf.Settings.TLS.Keypairs))
@ -196,6 +210,7 @@ func CreateWebserver(cf Config) *http.Server {
}
func RegisterHandlers(cf Config, mux *http.ServeMux) error {
// Register default paths
mux.HandleFunc("/", createDefaultHandler())
mux.HandleFunc("/health", createHealthHandler())
mux.HandleFunc("/health.json", createHealthHandler())
@ -222,8 +237,10 @@ func createHandler(hook Hook) Handler {
// Check if this hook is remote-host limited
if len(hook.Hosts) > 0 {
allowed := false
// Extract the queried hostname
queriedHost := hostname(r.Host)
for _, host := range hook.Hosts {
if r.RemoteAddr == host {
if host == queriedHost {
allowed = true
break
}

View file

@ -2,10 +2,21 @@ package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"os"
"strings"
"testing"
"time"
)
func TestMain(m *testing.M) {
@ -36,6 +47,12 @@ func TestWebserver(t *testing.T) {
return
}
go server.Serve(listener)
defer func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatalf("error while server shutdown: %s", err)
return
}
}()
assertStatusCode := func(url string, statusCode int) {
resp, err := http.Get(url)
@ -59,9 +76,268 @@ func TestWebserver(t *testing.T) {
// Test registered hooks
assertStatusCode(fmt.Sprintf("http://%s/test1", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("http://%s/test2", cf.Settings.BindAddress), http.StatusOK)
}
if err := server.Shutdown(context.Background()); err != nil {
t.Fatalf("error while server shutdown: %s", err)
// Tests the TLS functions of the webserver
func TestTLSWebserver(t *testing.T) {
var cf Config
const TESTPORT = 2089
// Test keypairs. testkey1 belongs to the "localhost" host and testkey2 belongs to the "localhost" and "example.com" hosts
keypairs := make([]TLSKeypairs, 0)
keypairs = append(keypairs, TLSKeypairs{Keyfile: "testkey1.pem", Certificate: "testcert1.pem"})
keypairs = append(keypairs, TLSKeypairs{Keyfile: "testkey2.pem", Certificate: "testcert2.pem"})
// Generate test certificates
for i, keypair := range keypairs {
if fileExists(keypair.Keyfile) {
t.Fatalf("test key '%s' already exists", keypair.Keyfile)
return
}
if fileExists(keypair.Certificate) {
t.Fatalf("test certificate '%s' already exists", keypair.Certificate)
return
}
hostnames := []string{"localhost"}
if i == 1 {
hostnames = append(hostnames, "example.com")
}
if err := generateKeypair(keypair.Keyfile, keypair.Certificate, hostnames); err != nil {
t.Fatalf("keypair generation failed: %s\n", err)
return
}
defer func(keypair TLSKeypairs) {
os.Remove(keypair.Keyfile)
os.Remove(keypair.Certificate)
}(keypair)
}
cf.Settings.BindAddress = fmt.Sprintf("localhost:%d", TESTPORT)
cf.Settings.TLS.Enabled = true
cf.Settings.TLS.MinVersion = "1.3"
cf.Settings.TLS.MaxVersion = "1.3"
cf.Settings.TLS.Keypairs = keypairs
cf.Hooks = make([]Hook, 0)
cf.Hooks = append(cf.Hooks, Hook{Route: "/test1", Name: "test1", Command: "", Hosts: []string{"localhost"}})
cf.Hooks = append(cf.Hooks, Hook{Route: "/test2", Name: "test2", Command: "", Hosts: []string{"localhost", "example.com"}})
// Setup TLS webserver
listener, err := CreateTLSListener(cf)
if err != nil {
t.Fatalf("error creating tls listener: %s", err)
return
}
server := CreateWebserver(cf)
mux := http.NewServeMux()
server.Handler = mux
if err := RegisterHandlers(cf, mux); err != nil {
t.Fatalf("error registering handlers: %s", err)
return
}
go server.Serve(listener)
defer func() {
if err := server.Shutdown(context.Background()); err != nil {
t.Fatalf("error while server shutdown: %s", err)
return
}
}()
// Default page without https should return a 400 error
resp, err := http.Get(fmt.Sprintf("http://%s/", cf.Settings.BindAddress))
if err != nil {
t.Fatalf("%s", err)
return
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("GET / returns status code %d for default page (400 expected)", resp.StatusCode)
}
// Check default page with tls certificates
certs := make([]tls.Certificate, 0)
rootCAs, _ := x509.SystemCertPool()
for i, keypair := range keypairs {
x509cert, err := readCertificate(keypair.Certificate)
if err != nil {
t.Fatalf("error loading certificate %d: %s", i, err)
return
}
raw := make([][]byte, 0)
raw = append(raw, x509cert.Raw)
certs = append(certs, tls.Certificate{Certificate: raw})
rootCAs.AddCert(x509cert)
}
dialer := &net.Dialer{}
transport := &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: certs,
RootCAs: rootCAs,
},
// Mock connections to example.com -> 127.0.0.1
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
if strings.Contains(addr, "example.com") {
addr = strings.ReplaceAll(addr, "example.com", "127.0.0.1")
}
return dialer.DialContext(ctx, network, addr)
},
}
client := http.Client{Transport: transport, Timeout: 15 * time.Second}
assertStatusCode := func(url string, statusCode int) {
resp, err = client.Get(url)
if err != nil {
t.Fatalf("%s", err)
return
}
if resp.StatusCode != statusCode {
t.Fatalf("GET / returns status code %d != %d", resp.StatusCode, statusCode)
}
}
fetchBody := func(url string) (string, error) {
resp, err = client.Get(url)
if err != nil {
return "", err
}
body, err := io.ReadAll(resp.Body)
return string(body), err
}
// Check default page and test hooks
assertStatusCode(fmt.Sprintf("https://%s/", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("https://%s/test1", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("https://%s/test2", cf.Settings.BindAddress), http.StatusOK)
assertStatusCode(fmt.Sprintf("https://%s/test404", cf.Settings.BindAddress), http.StatusNotFound)
// Check if connection via TLS 1.2 is not accepted (we're enforcing TLS >= 1.3)
transport.TLSClientConfig.MinVersion = tls.VersionTLS12
transport.TLSClientConfig.MaxVersion = tls.VersionTLS12
resp, err = client.Get(fmt.Sprintf("https://%s/", cf.Settings.BindAddress))
if err == nil {
t.Fatal("tls 1.2 connection possible where it should be unsupported", err)
return
} else {
// TODO: Matching by string might be flanky.
if !strings.Contains(err.Error(), "tls: protocol version not supported") {
t.Fatalf("%s", err)
return
}
}
transport.TLSClientConfig.MaxVersion = tls.VersionTLS13
// Check if example.com resolves (second certificate)
assertStatusCode(fmt.Sprintf("https://example.com:%d/", TESTPORT), http.StatusOK)
// Only /test2 should be reachable via example.com
assertStatusCode(fmt.Sprintf("https://example.com:%d/test1", TESTPORT), http.StatusNotFound)
assertStatusCode(fmt.Sprintf("https://example.com:%d/test2", TESTPORT), http.StatusOK)
// Assert, that the host 404 page is the same as the 404 page for a route that doesn't exist.
// This check is needed, because we pretend a path to not exist, if `hosts` is configured and
// we don't want to give attackers the possibility to distinguish between the two 404 errors
if body1, err := fetchBody(fmt.Sprintf("https://%s/test404", cf.Settings.BindAddress)); err != nil {
t.Fatalf("%s", err)
return
} else if body2, err := fetchBody(fmt.Sprintf("https://example.com:%d/test1", TESTPORT)); err != nil {
t.Fatalf("%s", err)
return
} else {
if body1 != body2 {
t.Fatal("404 bodies differ between default 404 page and host-not-matched route", err)
return
}
}
}
func generateKeypair(keyfile string, certfile string, hostnames []string) error {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
// Write key to file
var buffer []byte = x509.MarshalPKCS1PrivateKey(key)
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: buffer,
}
file, err := os.Create(keyfile)
if err != nil {
return err
}
defer file.Close()
if err := file.Chmod(os.FileMode(0400)); err != nil {
return err
}
if err = pem.Encode(file, block); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
// Generate certificate
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * 10 * time.Hour)
//Create certificate templet
template := x509.Certificate{
SerialNumber: big.NewInt(0),
Subject: pkix.Name{CommonName: hostnames[0]},
SignatureAlgorithm: x509.SHA256WithRSA,
DNSNames: hostnames,
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
}
//Create certificate using templet
cert, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
return err
}
block = &pem.Block{
Type: "CERTIFICATE",
Bytes: cert,
}
file, err = os.Create(certfile)
if err != nil {
return err
}
defer file.Close()
if err := file.Chmod(os.FileMode(0644)); err != nil {
return err
}
if err = pem.Encode(file, block); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
return nil
}
func readCertificate(certfile string) (*x509.Certificate, error) {
buffer, err := os.ReadFile(certfile)
if err != nil {
return nil, err
}
p, _ := pem.Decode(buffer)
if p == nil {
return nil, fmt.Errorf("invalid pem file")
}
cert, err := x509.ParseCertificate(p.Bytes)
return cert, err
}
func fileExists(filename string) bool {
st, err := os.Stat(filename)
if err != nil {
return false
}
return !st.IsDir()
}