diff --git a/README.md b/README.md index 662403d..d7e78a3 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ 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 path/to/authfile``` -- (optionally) require authorization for upload/delete by providing a newline-separated file of scrypted auth keys +- ````-remoteauthfile path/to/remoteauthfile``` -- (optionally) require authorization for remote uploads 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..c8de59a --- /dev/null +++ b/auth.go @@ -0,0 +1,128 @@ +package main + +import ( + "bufio" + "encoding/base64" + "log" + "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 auth struct { + successHandler http.Handler + failureHandler http.Handler + authKeys []string + o AuthOptions +} + +func readAuthKeys(authFile string) []string { + var authKeys []string + + f, err := os.Open(authFile) + if err != nil { + log.Fatal("Failed to open authfile: ", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + authKeys = append(authKeys, scanner.Text()) + } + + err = scanner.Err() + if err != nil { + log.Fatal("Scanner error while reading authfile: ", err) + } + + return authKeys +} + +func checkAuth(authKeys []string, decodedAuth []byte) (result bool, err error) { + checkKey, err := scrypt.Key([]byte(decodedAuth), []byte(scryptSalt), scryptN, scryptr, scryptp, scryptKeyLen) + if err != nil { + return + } + + encodedKey := base64.StdEncoding.EncodeToString(checkKey) + for _, v := range authKeys { + if encodedKey == v { + result = true + return + } + } + + result = false + return +} + +func (a auth) 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.authKeys, 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 auth{ + successHandler: h, + failureHandler: http.HandlerFunc(badAuthorizationHandler), + authKeys: readAuthKeys(o.AuthFile), + 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/auth_test.go b/auth_test.go new file mode 100644 index 0000000..9cec2ea --- /dev/null +++ b/auth_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "testing" +) + +func TestCheckAuth(t *testing.T) { + authKeys := []string{ + "vhvZ/PT1jeTbTAJ8JdoxddqFtebSxdVb0vwPlYO+4HM=", + "vFpNprT9wbHgwAubpvRxYCCpA2FQMAK6hFqPvAGrdZo=", + } + + if r, err := checkAuth(authKeys, []byte("")); err != nil && r { + t.Fatal("Authorization passed for empty key") + } + + if r, err := checkAuth(authKeys, []byte("thisisnotvalid")); err != nil && r { + t.Fatal("Authorization passed for invalid key") + } + + if r, err := checkAuth(authKeys, []byte("haPVipRnGJ0QovA9nyqK")); err != nil && !r { + t.Fatal("Authorization failed for valid key") + } +} diff --git a/server.go b/server.go index 458ac12..4cb88e4 100644 --- a/server.go +++ b/server.go @@ -35,6 +35,8 @@ var Config struct { allowHotlink bool fastcgi bool remoteUploads bool + authFile string + remoteAuthFile string } var Templates = make(map[string]*pongo2.Template) @@ -42,6 +44,7 @@ var TemplateSet *pongo2.TemplateSet var staticBox *rice.Box var timeStarted time.Time var timeStartedStr string +var remoteAuthKeys []string func setup() *web.Mux { mux := web.New() @@ -64,6 +67,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,15 +113,25 @@ 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)) if Config.remoteUploads { mux.Get("/upload", uploadRemote) mux.Get("/upload/", uploadRemote) + + if Config.remoteAuthFile != "" { + remoteAuthKeys = readAuthKeys(Config.remoteAuthFile) + } } mux.Post("/upload", uploadPostHandler) @@ -159,6 +179,10 @@ 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.remoteAuthFile, "remoteauthfile", "", + "path to a file containing newline-separated scrypted auth keys for remote uploads") 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..ebacfc3 100644 --- a/server_test.go +++ b/server_test.go @@ -52,6 +52,47 @@ func TestIndex(t *testing.T) { } } +func TestAuthKeys(t *testing.T) { + Config.authFile = "/dev/null" + + redirects := []string{ + "/", + "/paste/", + } + + mux := setup() + + for _, v := range redirects { + w := httptest.NewRecorder() + + req, err := http.NewRequest("GET", v, nil) + if err != nil { + t.Fatal(err) + } + + mux.ServeHTTP(w, req) + + if w.Code != 303 { + t.Fatalf("Status code is not 303, but %d", w.Code) + } + } + + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/paste/", nil) + if err != nil { + t.Fatal(err) + } + + mux.ServeHTTP(w, req) + + if w.Code != 401 { + t.Fatalf("Status code is not 401, but %d", w.Code) + } + + Config.authFile = "" +} + func TestNotFound(t *testing.T) { mux := setup() w := httptest.NewRecorder() diff --git a/upload.go b/upload.go index b24a8ce..63582d9 100644 --- a/upload.go +++ b/upload.go @@ -138,6 +138,19 @@ func uploadPutHandler(c web.C, w http.ResponseWriter, r *http.Request) { } func uploadRemote(c web.C, w http.ResponseWriter, r *http.Request) { + if Config.remoteAuthFile != "" { + result, err := checkAuth(remoteAuthKeys, []byte(r.FormValue("key"))) + if err != nil || !result { + unauthorizedHandler(c, w, r) + } + } else { + // strict referrer checking is mandatory without remote auth keys + if !strictReferrerCheck(r, Config.siteURL, []string{"Linx-Delete-Key", "Linx-Expiry", "Linx-Randomize"}) { + badRequestHandler(c, w, r) + return + } + } + if r.FormValue("url") == "" { http.Redirect(w, r, "/", 303) return