Initial commit
This commit is contained in:
124
server/server.go
Normal file
124
server/server.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gemserve/common"
|
||||
"gemserve/config"
|
||||
"gemserve/errors"
|
||||
"gemserve/logging"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
type ServerConfig interface {
|
||||
DirIndexingEnabled() bool
|
||||
RootPath() string
|
||||
}
|
||||
|
||||
func GenerateResponse(conn *tls.Conn, connId string, input string) ([]byte, error) {
|
||||
trimmedInput := strings.TrimSpace(input)
|
||||
// url will have a cleaned and normalized path after this
|
||||
url, err := common.ParseURL(trimmedInput, "", true)
|
||||
if err != nil {
|
||||
return nil, errors.NewConnectionError(fmt.Errorf("failed to parse URL: %w", err))
|
||||
}
|
||||
logging.LogDebug("%s %s normalized URL path: %s", connId, conn.RemoteAddr(), url.Path)
|
||||
serverRootPath := config.CONFIG.RootPath
|
||||
localPath, err := calculateLocalPath(url.Path, serverRootPath)
|
||||
if err != nil {
|
||||
return nil, errors.NewConnectionError(err)
|
||||
}
|
||||
logging.LogDebug("%s %s request file path: %s", connId, conn.RemoteAddr(), localPath)
|
||||
|
||||
// Get file/directory information
|
||||
info, err := os.Stat(localPath)
|
||||
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
|
||||
return []byte("51 not found\r\n"), nil
|
||||
} else if err != nil {
|
||||
return nil, errors.NewConnectionError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err))
|
||||
}
|
||||
|
||||
// Handle directory.
|
||||
if info.IsDir() {
|
||||
return generateResponseDir(conn, connId, url, localPath)
|
||||
}
|
||||
return generateResponseFile(conn, connId, url, localPath)
|
||||
}
|
||||
|
||||
func generateResponseFile(conn *tls.Conn, connId string, url *common.URL, localPath string) ([]byte, error) {
|
||||
data, err := os.ReadFile(localPath)
|
||||
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
|
||||
return []byte("51 not found\r\n"), nil
|
||||
} else if err != nil {
|
||||
return nil, errors.NewConnectionError(fmt.Errorf("%s %s failed to read file: %w", connId, conn.RemoteAddr(), err))
|
||||
}
|
||||
|
||||
var mimeType string
|
||||
if path.Ext(localPath) == ".gmi" {
|
||||
mimeType = "text/gemini"
|
||||
} else {
|
||||
mimeType = mimetype.Detect(data).String()
|
||||
}
|
||||
headerBytes := []byte(fmt.Sprintf("20 %s\r\n", mimeType))
|
||||
response := append(headerBytes, data...)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func generateResponseDir(conn *tls.Conn, connId string, url *common.URL, localPath string) (output []byte, err error) {
|
||||
entries, err := os.ReadDir(localPath)
|
||||
if err != nil {
|
||||
return nil, errors.NewConnectionError(fmt.Errorf("%s %s failed to read directory: %w", connId, conn.RemoteAddr(), err))
|
||||
}
|
||||
|
||||
if config.CONFIG.DirIndexingEnabled {
|
||||
var contents []string
|
||||
contents = append(contents, "Directory index:\n\n")
|
||||
contents = append(contents, "=> ../\n")
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
contents = append(contents, fmt.Sprintf("=> %s/\n", entry.Name()))
|
||||
} else {
|
||||
contents = append(contents, fmt.Sprintf("=> %s\n", entry.Name()))
|
||||
}
|
||||
}
|
||||
data := []byte(strings.Join(contents, ""))
|
||||
headerBytes := []byte("20 text/gemini;\r\n")
|
||||
response := append(headerBytes, data...)
|
||||
return response, nil
|
||||
} else {
|
||||
filePath := path.Join(localPath, "index.gmi")
|
||||
return generateResponseFile(conn, connId, url, filePath)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func calculateLocalPath(input string, basePath string) (string, error) {
|
||||
// Check for invalid characters early
|
||||
if strings.ContainsAny(input, "\\") {
|
||||
return "", errors.NewError(fmt.Errorf("invalid characters in path: %s", input))
|
||||
}
|
||||
|
||||
// If IsLocal(path) returns true, then Join(base, path)
|
||||
// will always produce a path contained within base and
|
||||
// Clean(path) will always produce an unrooted path with
|
||||
// no ".." path elements.
|
||||
filePath := input
|
||||
filePath = strings.TrimPrefix(filePath, "/")
|
||||
if filePath == "" {
|
||||
filePath = "."
|
||||
}
|
||||
filePath = strings.TrimSuffix(filePath, "/")
|
||||
|
||||
localPath, err := filepath.Localize(filePath)
|
||||
if err != nil || !filepath.IsLocal(localPath) {
|
||||
return "", errors.NewError(fmt.Errorf("could not construct local path from %s: %s", input, err))
|
||||
}
|
||||
|
||||
filePath = path.Join(basePath, localPath)
|
||||
return filePath, nil
|
||||
}
|
||||
225
server/server_test.go
Normal file
225
server/server_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculateLocalPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
basePath string
|
||||
want string
|
||||
expectError bool
|
||||
}{
|
||||
// Basic path handling
|
||||
{
|
||||
name: "Simple valid path",
|
||||
input: "folder/file.txt",
|
||||
basePath: "/base",
|
||||
want: "/base/folder/file.txt",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty path",
|
||||
input: "",
|
||||
basePath: "/base",
|
||||
want: "/base",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Current directory",
|
||||
input: ".",
|
||||
basePath: "/base",
|
||||
want: "/base",
|
||||
expectError: false,
|
||||
},
|
||||
|
||||
// Leading/trailing slash handling
|
||||
{
|
||||
name: "Path with leading slash",
|
||||
input: "/folder/file.txt",
|
||||
basePath: "/base",
|
||||
want: "/base/folder/file.txt",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Path with trailing slash",
|
||||
input: "folder/",
|
||||
basePath: "/base",
|
||||
want: "/base/folder",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Path with both leading and trailing slashes",
|
||||
input: "/folder/",
|
||||
basePath: "/base",
|
||||
want: "/base/folder",
|
||||
expectError: false,
|
||||
},
|
||||
|
||||
// Path traversal attempts
|
||||
{
|
||||
name: "Simple path traversal attempt",
|
||||
input: "../file.txt",
|
||||
basePath: "/base",
|
||||
want: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Complex path traversal attempt",
|
||||
input: "folder/../../../etc/passwd",
|
||||
basePath: "/base",
|
||||
want: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Encoded path traversal attempt",
|
||||
input: "folder/..%2F..%2F..%2Fetc%2Fpasswd",
|
||||
basePath: "/base",
|
||||
want: "/base/folder/..%2F..%2F..%2Fetc%2Fpasswd",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Double dot hidden in path",
|
||||
input: "folder/.../.../etc/passwd",
|
||||
basePath: "/base",
|
||||
want: "/base/folder/.../.../etc/passwd",
|
||||
expectError: false,
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "Multiple sequential slashes",
|
||||
input: "folder///subfolder////file.txt",
|
||||
basePath: "/base",
|
||||
want: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Unicode characters in path",
|
||||
input: "фольдер/файл.txt",
|
||||
basePath: "/base",
|
||||
want: "/base/фольдер/файл.txt",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Path with spaces and special characters",
|
||||
input: "my folder/my file!@#$%.txt",
|
||||
basePath: "/base",
|
||||
want: "/base/my folder/my file!@#$%.txt",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Very long path",
|
||||
input: "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.txt",
|
||||
basePath: "/base",
|
||||
want: "/base/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.txt",
|
||||
expectError: false,
|
||||
},
|
||||
|
||||
// Base path variations
|
||||
{
|
||||
name: "Empty base path",
|
||||
input: "file.txt",
|
||||
basePath: "",
|
||||
want: "file.txt",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Relative base path",
|
||||
input: "file.txt",
|
||||
basePath: "base/folder",
|
||||
want: "base/folder/file.txt",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Base path with trailing slash",
|
||||
input: "file.txt",
|
||||
basePath: "/base/",
|
||||
want: "/base/file.txt",
|
||||
expectError: false,
|
||||
},
|
||||
|
||||
// Symbolic link-like paths (if supported)
|
||||
{
|
||||
name: "Path with symbolic link-like components",
|
||||
input: "folder/symlink/../file.txt",
|
||||
basePath: "/base",
|
||||
want: "",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := calculateLocalPath(tt.input, tt.basePath)
|
||||
|
||||
// Check error expectation
|
||||
if (err != nil) != tt.expectError {
|
||||
t.Errorf("calculateLocalPath() error = %v, expectError = %v", err, tt.expectError)
|
||||
return
|
||||
}
|
||||
|
||||
// If we expect an error, don't check the returned path
|
||||
if tt.expectError {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the returned path matches expected
|
||||
if got != tt.want {
|
||||
t.Errorf("calculateLocalPath() = %v, want %v", got, tt.want)
|
||||
}
|
||||
|
||||
// Additional security checks for non-error cases
|
||||
if !tt.expectError {
|
||||
// Verify the returned path is within base path
|
||||
if !isWithinBasePath(got, tt.basePath) {
|
||||
t.Errorf("Result path %v escapes base path %v", got, tt.basePath)
|
||||
}
|
||||
|
||||
// Verify no '..' components in final path
|
||||
if containsParentRef(got) {
|
||||
t.Errorf("Result path %v contains parent references", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a path is contained within the base path
|
||||
func isWithinBasePath(path, basePath string) bool {
|
||||
if basePath == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
absBase, err := filepath.Abs(basePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(absBase, absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return !strings.HasPrefix(rel, "..")
|
||||
}
|
||||
|
||||
// Helper function to check if a path contains parent directory references
|
||||
func containsParentRef(path string) bool {
|
||||
parts := strings.Split(filepath.Clean(path), string(filepath.Separator))
|
||||
for _, part := range parts {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user