diff --git a/internal/html/html.go b/internal/html/html.go index dc03f7f..7b5c1dd 100644 --- a/internal/html/html.go +++ b/internal/html/html.go @@ -18,12 +18,16 @@ type listingRow struct { func (row *listingRow) genHtml(sb *sh.StringBuilder) { sb.AddS(``) - sb.AddS(``) + } else { // tell browser to download file instead of opening in new tab - sb.AddS(" download") + sb.AddS(`" download>`) } - sb.AddB('>').AddS(row.name).AddS(``) + sb.AddS(row.name).AddS(``) sb.AddS(``).AddS(row.size).AddS(``) sb.AddS(``).AddS(row.mtime).AddS(``) sb.AddS(``) diff --git a/internal/http/Connection.go b/internal/http/Connection.go index 77a2dda..37da983 100644 --- a/internal/http/Connection.go +++ b/internal/http/Connection.go @@ -90,7 +90,7 @@ func (conn *Connection) Start() { } else { // valid request conn.log.Info(req.Method, " ", req.Url, " ", req.Protocol) - conn.log.Trace("request headers: ", req.Headers) + conn.log.Trace("request headers: ", HeadersToString(req.Headers, SecretHeaders)) res = conn.callback(req) if req.HasUnreadBody() { res = conn.CreateErrorResponse(req, true, ErrHasUnreadBody) @@ -99,7 +99,7 @@ func (conn *Connection) Start() { conn.log.Info("sending response: ", res.Status, " content-length: ", res.Headers["content-length"]) - conn.log.Trace("response headers: ", res.Headers) + conn.log.Trace("response headers: ", HeadersToString(res.Headers, SecretHeaders)) err = conn.sendResponse(res) if err != nil { conn.logError(err) diff --git a/internal/http/Response.go b/internal/http/Response.go index 2b6e362..ec40ede 100644 --- a/internal/http/Response.go +++ b/internal/http/Response.go @@ -122,6 +122,17 @@ func (res *Response) WithBodyReader(body io.Reader, return res } +func (res *Response) WithHeader(k, v string) *Response { + res.Headers[k] = v + return res +} +func (res *Response) WithHeaderI(k string, v int64) *Response { + return res.WithHeader(k, strconv.FormatInt(v, 10)) +} +func (res *Response) WithHeaderU(k string, v uint64) *Response { + return res.WithHeader(k, strconv.FormatUint(v, 10)) +} + func (conn *Connection) CreateErrorResponse( req *Request, wantsClose bool, httpErr *HttpError, ) *Response { diff --git a/internal/http/headers.go b/internal/http/headers.go index 2242710..7810575 100644 --- a/internal/http/headers.go +++ b/internal/http/headers.go @@ -8,6 +8,31 @@ import ( const HeaderTimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT" +type SetStr map[string]struct{} + +var SecretHeaders = SetStr{ + "authorization": {}, +} + +func HeadersToString(headers HeadersMap, secretHeaders SetStr) string { + sb := sh.StringBuilder{Buf: make([]byte, 0, len(headers)*64)} + sb.AddS("{ ") + for k, v := range headers { + sb.AddB('"').AddS(k).AddS(`": "`) + if _, ok := secretHeaders[k]; ok { + sb.AddS("***") + } else { + sb.AddS(v) + } + sb.AddS(`", `) + } + if sb.Len() > 2 { + sb.Remove(2) + } + sb.AddS(" }") + return sb.StringUnsafe() +} + // search in splitted parts of header value func HeaderValueContains(sourceSplit []string, target string) bool { for _, v := range sourceSplit { diff --git a/internal/server/GET-HEAD.go b/internal/server/GET-HEAD.go index f5497d3..3b27a4f 100644 --- a/internal/server/GET-HEAD.go +++ b/internal/server/GET-HEAD.go @@ -11,17 +11,26 @@ import ( func (conn *TcpConnection) serveFileOrDir(req *http.Request) *http.Response { path, share, urlStatus := conn.srv.Conf.Share.MapUrl(req.Url) - // TODO implement authorization - _ = share + // handle authorization + if share != nil { + access := checkSharePasswords(share, req) + if access < AccessReadOnly { + return conn.createErrorResponse(req, false, http.StatusUnauthorized, + "authorization required"). + WithHeader("www-authenticate", + `Basic realm="`+share.Url+`", charset="UTF-8"`) + } + } + switch urlStatus { case UrlFile: return conn.serveFile(req, path) case UrlDir: return conn.serveDirListing(req, path) case UrlNotFound: - return conn.createErrorResponse(req, false, http.StatusNotFound, path) + return conn.createErrorResponse(req, false, http.StatusNotFound, req.Url) case UrlForbidden: - return conn.createErrorResponse(req, false, http.StatusForbidden, path) + return conn.createErrorResponse(req, false, http.StatusForbidden, req.Url) case UrlIncorrect: return conn.createErrorResponse(req, false, http.StatusBadRequest, "invalid url '"+req.Url+"'") diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..e93e642 --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,58 @@ +package server + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "serveg/internal/http" + "strings" +) + +type ShareAccess uint8 + +const ( + AccessDenied ShareAccess = iota + AccessReadOnly + AccessReadWrite +) + +// writing: share allows writing and client sent write password +// reading: share is public or client sent password (read or write) +func checkSharePasswords(share *SharedLocation, req *http.Request, +) ShareAccess { + reqPass := getBasicAuthPassword(req) + canWrite := share.WritePassword != "" && + (reqPass != "" && passwordMatches(reqPass, share.WritePassword)) + if canWrite { + return AccessReadWrite + } + canRead := share.ReadPassword == "" || + (reqPass != "" && passwordMatches(reqPass, share.ReadPassword)) + if canRead { + return AccessReadOnly + } + return AccessDenied +} + +func getBasicAuthPassword(req *http.Request) string { + header := req.Headers["authorization"] + scheme, b64, ok := strings.Cut(header, " ") + if !ok || !strings.EqualFold(scheme, "Basic") { + return "" + } + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) + if err != nil { + return "" + } + // try get password from username:password + _, password, _ := strings.Cut(string(decoded), ":") + return password +} + +func passwordMatches(got, want string) bool { + // hash first: ConstantTimeCompare returns early when lengths differ, + // which would leak the password length through timing + g := sha256.Sum256([]byte(got)) + w := sha256.Sum256([]byte(want)) + return subtle.ConstantTimeCompare(g[:], w[:]) == 1 +} diff --git a/internal/string_helper/StringBuilder.go b/internal/string_helper/StringBuilder.go index 6970c26..7e27ea5 100644 --- a/internal/string_helper/StringBuilder.go +++ b/internal/string_helper/StringBuilder.go @@ -121,3 +121,9 @@ func (b *StringBuilder) String() string { func (b *StringBuilder) StringUnsafe() string { return UnsafeBytesToStr(b.Buf) } + +func (b *StringBuilder) Remove(n int) *StringBuilder { + l := b.Len() + b.Buf = b.Buf[:l-min(n, l)] + return b +} diff --git a/serveg.example.toml b/serveg.example.toml index cd15597..0dae75e 100644 --- a/serveg.example.toml +++ b/serveg.example.toml @@ -1,7 +1,7 @@ # serveg config, copy to serveg.toml # The file contains passwords: make it readable only by the user serveg runs as (chmod 600). address = "127.0.0.1:8080" -max_upload = 4_294_967_296 +max_upload = 4_294_967_296 # 4 GB html_title = "serveg" # Each [[share]] makes a directory available under an URL prefix.