diff --git a/README.md b/README.md index 662403d..7e46836 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Command-line options - ```-xframeoptions "..." ``` -- X-Frame-Options header (default is "SAMEORIGIN") - ```-remoteuploads``` -- (optionally) enable remote uploads (/upload?url=https://...) - ```-realip``` -- (optionally) let linx-server know you (nginx, etc) are providing the X-Real-IP and/or X-Forwarded-For headers. +- ````-authfile``` -- (optionally) require authorization for upload/delete by providing a newline-separated file of scrypted auth keys - ```-fastcgi``` -- (optionally) serve through fastcgi - ```-nologs``` -- (optionally) disable request logs in stdout diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..bc7de3e --- /dev/null +++ b/auth.go @@ -0,0 +1,111 @@ +package main + +import ( + "bufio" + "encoding/base64" + "net/http" + "os" + "strings" + + "golang.org/x/crypto/scrypt" +) + +const ( + authPrefix = "Linx " + scryptSalt = "linx-server" + scryptN = 16384 + scryptr = 8 + scryptp = 1 + scryptKeyLen = 32 +) + +type AuthOptions struct { + AuthFile string + UnauthMethods []string +} + +type uploadBasicAuth struct { + successHandler http.Handler + failureHandler http.Handler + o AuthOptions +} + +func checkAuth(authFile string, decodedAuth []byte) (result bool, err error) { + f, err := os.Open(authFile) + if err != nil { + return + } + + checkKey, err := scrypt.Key([]byte(decodedAuth), []byte(scryptSalt), scryptN, scryptr, scryptp, scryptKeyLen) + if err != nil { + return + } + + encodedKey := base64.StdEncoding.EncodeToString(checkKey) + + scanner := bufio.NewScanner(bufio.NewReader(f)) + for scanner.Scan() { + if encodedKey == scanner.Text() { + result = true + return + } + } + + result = false + err = scanner.Err() + return +} + +func (a uploadBasicAuth) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if sliceContains(a.o.UnauthMethods, r.Method) { + // allow unauthenticated methods + a.successHandler.ServeHTTP(w, r) + return + } + + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, authPrefix) { + a.failureHandler.ServeHTTP(w, r) + return + } + + decodedAuth, err := base64.StdEncoding.DecodeString(authHeader[len(authPrefix):]) + if err != nil { + a.failureHandler.ServeHTTP(w, r) + return + } + + result, err := checkAuth(a.o.AuthFile, decodedAuth) + if err != nil || !result { + a.failureHandler.ServeHTTP(w, r) + return + } + + a.successHandler.ServeHTTP(w, r) +} + +func UploadAuth(o AuthOptions) func(http.Handler) http.Handler { + fn := func(h http.Handler) http.Handler { + return uploadBasicAuth{ + successHandler: h, + failureHandler: http.HandlerFunc(badAuthorizationHandler), + o: o, + } + } + return fn +} + +func badAuthorizationHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) +} + +func sliceContains(slice []string, s string) bool { + for _, v := range slice { + if s == v { + return true + } + } + + return false +} diff --git a/server.go b/server.go index 458ac12..b6e74da 100644 --- a/server.go +++ b/server.go @@ -35,6 +35,7 @@ var Config struct { allowHotlink bool fastcgi bool remoteUploads bool + authFile string } var Templates = make(map[string]*pongo2.Template) @@ -64,6 +65,13 @@ func setup() *web.Mux { frame: Config.xFrameOptions, })) + if Config.authFile != "" { + mux.Use(UploadAuth(AuthOptions{ + AuthFile: Config.authFile, + UnauthMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"}, + })) + } + // make directories if needed err := os.MkdirAll(Config.filesDir, 0755) if err != nil { @@ -103,9 +111,15 @@ func setup() *web.Mux { selifIndexRe := regexp.MustCompile(`^/selif/$`) torrentRe := regexp.MustCompile(`^/(?P[a-z0-9-\.]+)/torrent$`) - mux.Get("/", indexHandler) - mux.Get("/paste/", pasteHandler) + if Config.authFile == "" { + mux.Get("/", indexHandler) + mux.Get("/paste/", pasteHandler) + } else { + mux.Get("/", http.RedirectHandler("/API", 303)) + mux.Get("/paste/", http.RedirectHandler("/API/", 303)) + } mux.Get("/paste", http.RedirectHandler("/paste/", 301)) + mux.Get("/API/", apiDocHandler) mux.Get("/API", http.RedirectHandler("/API/", 301)) @@ -159,6 +173,8 @@ func main() { "serve through fastcgi") flag.BoolVar(&Config.remoteUploads, "remoteuploads", false, "enable remote uploads") + flag.StringVar(&Config.authFile, "authfile", "", + "path to a file containing newline-separated scrypted auth keys") flag.StringVar(&Config.contentSecurityPolicy, "contentsecuritypolicy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; referrer none;", "value of default Content-Security-Policy header") diff --git a/server_test.go b/server_test.go index 1e382c7..e4c3a3a 100644 --- a/server_test.go +++ b/server_test.go @@ -52,6 +52,26 @@ func TestIndex(t *testing.T) { } } +func TestIndexAuthKeys(t *testing.T) { + Config.authFile = "/dev/null" + + mux := setup() + w := httptest.NewRecorder() + + req, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Fatal(err) + } + + mux.ServeHTTP(w, req) + + if w.Code != 303 { + t.Fatalf("Status code is not 301, but %d", w.Code) + } + + Config.authFile = "" +} + func TestNotFound(t *testing.T) { mux := setup() w := httptest.NewRecorder()