Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions internal/ctype/contenttype.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package ctype

import (
"net/http"
"path"
"unicode/utf8"

"github.com/saintfish/chardet"
)

// DetectContentType determines the content type and charset for a file.
func DetectContentType(filePath string, data []byte, bytesRead int) (string, string) {
// Try to determine content type by filename first
contentType := GetContentTypeForFilename(path.Base(filePath))

// If no content type was detected by filename and we have content to examine,
// try to detect it from the content
if contentType == "" && bytesRead > 0 {
detected := http.DetectContentType(data[:bytesRead])
if detected != "application/octet-stream" {
contentType = detected
}
}

// Detect charset if we have valid content type and data to examine
charset := ""
if bytesRead > 0 && contentType != "" && contentType != "application/octet-stream" {
// Check if data is valid UTF-8
if utf8.Valid(data[:bytesRead]) {
charset = "utf-8"
} else {
// Try to detect charset with chardet
res, err := chardet.NewTextDetector().DetectBest(data[:bytesRead])
if err == nil && res.Confidence > 50 && res.Charset != "" {
charset = res.Charset
}
}
}

// Add charset for text-based content types
if charset != "" && contentType != "application/octet-stream" {
contentType += "; charset=" + charset
}

return contentType, charset
}
30 changes: 30 additions & 0 deletions internal/fileutil/files.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package fileutil

import (
"os"
"path/filepath"
"slices"
"strings"
)

// FileExists checks if a file exists at the given path.
func FileExists(filePath string) bool {
_, err := os.Stat(filePath)
return err == nil
}

// IsMarkdownFile checks if a file is a markdown file based on its extension.
func IsMarkdownFile(filePath string) bool {
ext := strings.ToLower(filepath.Ext(filePath))
return ext == ".md" || ext == ".markdown"
}

// IsIndexFile checks if a filename is in the allowedIndexFiles list.
func IsIndexFile(filename string, allowedIndexFiles []string) bool {
return slices.Contains(allowedIndexFiles, filename)
}

// IsHTMLIndexFile checks if a filename is a standard HTML index file.
func IsHTMLIndexFile(filename string, htmlIndexFiles []string) bool {
return slices.Contains(htmlIndexFiles, filename)
}
13 changes: 13 additions & 0 deletions internal/httputil/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Package httputil provides utility functions for HTTP operations.
package httputil

import (
"fmt"
"net/http"
)

// Error is a helper function to return HTTP errors
func Error(statusCode int, w http.ResponseWriter, message string) {
w.WriteHeader(statusCode)
fmt.Fprint(w, message)
}
8 changes: 6 additions & 2 deletions internal/mdrendering/goldmark_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,12 @@ func (r *HTTPServerRendering) renderImageAlign(w util.BufWriter, source []byte,

w.WriteString(`" alt="`)

//nolint:staticcheck // skipping temporarily until we decide on keeping goldmark
w.Write(util.EscapeHTML(n.Text(source)))
// Render alt text by walking through child nodes
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if t, ok := c.(*ast.Text); ok {
w.Write(util.EscapeHTML(t.Segment.Value(source)))
}
}
w.WriteString(`"`)
if n.Title != nil {
w.WriteString(` title="`)
Expand Down
2 changes: 2 additions & 0 deletions internal/middlewares/disable_config_access.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strings"
)

// DisableAccessToFile returns a middleware that disables access to files
// that match the given function.
func DisableAccessToFile(fn func(string) bool, statusCode int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
10 changes: 8 additions & 2 deletions internal/middlewares/etag.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package middlewares

import (
"bytes"
"crypto/sha1"
"fmt"
"hash"
"hash/fnv"
"io"
"net/http"
"sync"
Expand All @@ -29,12 +29,18 @@ func Etag(enabled bool, maxBodySize int64) func(http.Handler) http.Handler {

hashPool := sync.Pool{
New: func() interface{} {
return sha1.New()
return fnv.New64a()
},
}

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip ETag generation for HEAD requests since they don't return a body
if r.Method == http.MethodHead {
next.ServeHTTP(w, r)
return
}

// Get a buffer and hasher from the pools.
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
Expand Down
124 changes: 59 additions & 65 deletions internal/middlewares/jwt_validate.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package middlewares

import (
"errors"
"fmt"
"net/http"
"strings"
Expand All @@ -9,12 +10,18 @@
"github.com/golang-jwt/jwt/v5"
)

func signMethod(signingKey []byte) func(*jwt.Token) (interface{}, error) {
return func(token *jwt.Token) (interface{}, error) {
// sendUnauthorized sends a 401 response with a warning message.
func sendUnauthorized(w http.ResponseWriter, warnFunctionf func(string, ...any), format string, args ...any) {
warnFunctionf(format, args...)
http.Error(w, "unauthorized", http.StatusUnauthorized)
}

// signMethod returns a key function that validates the signing method is HS256.
func signMethod(signingKey []byte) func(*jwt.Token) (any, error) {
return func(token *jwt.Token) (any, error) {
// Validate the signing method
hm, ok := token.Method.(*jwt.SigningMethodHMAC)
if !ok || hm.Hash != jwt.SigningMethodHS256.Hash {
// This error will be caught by the err check below
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}

Expand All @@ -25,9 +32,31 @@
// ValidateJWTHS256 validates a JWT token using the HS256 algorithm,
// the token can be passed in the "Authorization" header or in the
// "token" query parameter.
func ValidateJWTHS256(warnFunctionf func(string, ...interface{}), loggedInFunction func(string), jwtSigningKey string, validateTimedJWT bool) func(http.Handler) http.Handler {
func ValidateJWTHS256(warnFunctionf func(string, ...any), loggedInFunction func(string), jwtSigningKey string, validateTimedJWT bool) func(http.Handler) http.Handler {
// Cache the signing key bytes
signingKeyBytes := []byte(jwtSigningKey)
keyfunc := signMethod(signingKeyBytes)

// Configure parser options based on validation requirements
options := []jwt.ParserOption{
jwt.WithValidMethods([]string{"HS256"}),
}

if validateTimedJWT {
// Add time validation options
options = append(options,
jwt.WithLeeway(time.Second*5), // Small leeway for clock skew
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),
)
}

// Create a parser with the specified options
parser := jwt.NewParser(options...)

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path // Cache path to avoid repeated access
var claims jwt.MapClaims

// Get the token from the "Authorization" header or from the "token" query parameter
Expand All @@ -42,82 +71,47 @@
return
}

// Check for errors during parsing (includes signature validation errors and method mismatch errors)
tkn, err := jwt.ParseWithClaims(token, &claims, signMethod([]byte(jwtSigningKey)))
// Parse token with claims using the configured parser
tkn, err := parser.ParseWithClaims(token, &claims, keyfunc)

Check failure on line 76 in internal/middlewares/jwt_validate.go

View workflow job for this annotation

GitHub Actions / Test Application

File is not properly formatted (gofumpt)
// Handle parsing errors with more specific messages based on error type
if err != nil {
warnFunctionf("error parsing token for URL %q: %s", r.URL.Path, err.Error())
http.Error(w, "unauthorized", http.StatusUnauthorized)
// Check specific JWT error types for better error messages
switch {
case errors.Is(err, jwt.ErrTokenExpired):
sendUnauthorized(w, warnFunctionf, "JWT token validation failed: token expired for URL: %s", path)
case errors.Is(err, jwt.ErrTokenUsedBeforeIssued), errors.Is(err, jwt.ErrTokenNotValidYet):
sendUnauthorized(w, warnFunctionf, "JWT token validation failed: token not valid yet for URL: %s", path)
case errors.Is(err, jwt.ErrTokenMalformed):
sendUnauthorized(w, warnFunctionf, "JWT token validation failed: malformed token for URL: %s", path)
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
sendUnauthorized(w, warnFunctionf, "JWT token validation failed: invalid signature for URL: %s", path)
case errors.Is(err, jwt.ErrTokenRequiredClaimMissing):
sendUnauthorized(w, warnFunctionf, "JWT token validation failed: required claim missing for URL: %s", path)
default:
sendUnauthorized(w, warnFunctionf, "Error parsing token for URL %q: %s", path, err.Error())
}
return
}

// Basic token validity check (mainly signature and structure if no options passed)
// Basic token validity check
if !tkn.Valid {
// This case should ideally not be hit if err is nil above, unless custom validation
// options were passed which they are not. Including defensively.
warnFunctionf("JWT token basic validation failed: invalid token for url: %s", r.URL.Path)
http.Error(w, "unauthorized", http.StatusUnauthorized)
sendUnauthorized(w, warnFunctionf, "JWT token basic validation failed: invalid token for URL: %s", path)
return
}

if validateTimedJWT {
now := time.Now()

// Check for missing 'exp' claim first
if _, ok := claims["exp"]; !ok {
warnFunctionf("JWT token validation failed: missing 'exp' claim for url: %s", r.URL.Path)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

// Claim exists, now try to get it as a NumericDate
exp, err := claims.GetExpirationTime()
if err != nil {
warnFunctionf("JWT token validation failed: invalid 'exp' claim value for url: %s: %s", r.URL.Path, err.Error())
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

// Now compare the time directly
if now.After(exp.Time) {
warnFunctionf("JWT token validation failed: token expired for url: %s", r.URL.Path)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

// We check for 'iat' existence first
if _, ok := claims["iat"]; !ok {
warnFunctionf("JWT token validation failed: missing 'iat' claim for url: %s", r.URL.Path)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

// Claim exists, now try to get it as a NumericDate
iss, err := claims.GetIssuedAt()
if err != nil {
warnFunctionf("JWT token validation failed: invalid 'iat' claim value for url: %s: %s", r.URL.Path, err.Error())
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

// Now compare the time directly
if now.Before(iss.Time) {
warnFunctionf("JWT token validation failed: token issued in the future for url: %s", r.URL.Path)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}

// Logging successful authentication
// Log successful authentication
if user := claims["sub"]; user != nil {
s := fmt.Sprintf("JWT auth passed for url %q: user: %q", r.URL.Path, user)
log := fmt.Sprintf("JWT auth passed for URL %q: user: %q", path, user)

if issuer := claims["iss"]; issuer != nil {
s += fmt.Sprintf(" (issuer: %q)", issuer)
log += fmt.Sprintf(" (issuer: %q)", issuer)
}

loggedInFunction(s)
loggedInFunction(log)
}

// Continue to the next handler
next.ServeHTTP(w, r)
})
}
Expand Down
22 changes: 22 additions & 0 deletions internal/pathutil/url.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Package pathutil provides utility functions for path and URL operations.
package pathutil

import (
"path"
"strings"
)

// GetParentURL returns the parent URL for the given location.
func GetParentURL(base, loc string) string {
if loc == base {
return ""
}

// Handle directory paths, ensuring they end with a slash
dir := path.Dir(strings.TrimSuffix(loc, "/"))
if !strings.HasSuffix(dir, "/") {
dir += "/"
}

return dir
}
6 changes: 3 additions & 3 deletions internal/server/error_handling.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func (m *MultiError) Error() string {
type ValidationError struct {
Field string
Tag string
Value interface{}
Value any
Param string
}

Expand All @@ -78,9 +78,9 @@ func (v *ValidationError) Error() string {
)
}

// FieldToValidationError converts a validator.FieldError from
// fieldToValidationError converts a validator.FieldError from
// the validator v10 package to a local ValidationError
func FieldToValidationError(field validator.FieldError) *ValidationError {
func fieldToValidationError(field validator.FieldError) *ValidationError {
return &ValidationError{
Field: field.Field(),
Tag: field.Tag(),
Expand Down
Loading
Loading