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.