security fixes and structural improvements

This commit is contained in:
2026-01-11 21:31:34 +00:00
parent dc44a89a67
commit 010b36789c
4 changed files with 219 additions and 5 deletions

3
.gitignore vendored
View File

@@ -30,3 +30,6 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
# SQLite
data/

View File

@@ -19,12 +19,18 @@ RUN go build -o server main.go
# Final Stage
FROM alpine:latest
WORKDIR /root/
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser
WORKDIR /home/appuser
COPY --from=builder /app/server .
# Create directory for sqlite db
RUN mkdir -p data
RUN mkdir -p data && chown -R appuser:appuser /home/appuser
USER appuser
EXPOSE 8000

167
README.md Normal file
View File

@@ -0,0 +1,167 @@
# CalSpot
A lightweight, self-hosted calendar server providing WebDAV access for calendar management and public HTTP endpoints for sharing calendars. Built with Go and SQLite.
## Features
- **WebDAV Interface**: Upload and sync `.ics` calendar files using any CalDAV-compatible client
- **Public Calendar Sharing**: Share calendars via simple HTTP URLs with security-through-obscurity
- **Authentication**: HTTP Basic Auth protection for WebDAV endpoints
- **User Management**: Interactive REPL for managing users
- **Secure Storage**: bcrypt password hashing and SQLite backend
- **Lightweight**: Single binary with no external dependencies
- **Docker Support**: Production-ready containerized deployment
## Architecture
- **Language**: Go 1.25.5
- **Database**: SQLite3 (file-based)
- **Authentication**: HTTP Basic Auth with bcrypt password hashing
- **Calendar Format**: iCalendar (`.ics`) files
- **Storage**: Single calendar per user with automatic versioning
## Quick Start
### Local Development
```bash
# Clone the repository
git clone <repository-url>
cd calspot
# Build the server
go build -o calspot main.go
# Run the server
./calspot
```
The server will start on port 8000 with an interactive REPL for user management.
### Docker Deployment
```bash
# Build the Docker image
docker build -t calspot .
# Run the container
docker run -it -p 8000:8000 -v $(pwd)/data:/home/appuser/data calspot
```
## User Management (REPL)
The server includes an interactive command-line interface for managing users:
### Commands
#### Add User
```
add [username] [password]
```
- If no username provided: generates a UUID username
- If no password provided: generates a secure 16-character password
- Displays the public calendar ID (UUID) for sharing
Example:
```
> add alice MySecurePass123
User alice created. Public ID: 01933b2c-8f5e-7890-a234-567890abcdef
```
#### Delete User
```
del <username>
```
Removes user and their associated calendar data.
#### List Users
```
list
```
Shows all registered users with their public IDs.
#### Reset Password
```
resetpassword <username> [newpassword]
```
Updates user password. Generates secure password if not provided.
## API Endpoints
### WebDAV (Authenticated)
**Endpoint**: `/webdav/`
**Authentication**: HTTP Basic Auth
**Methods**: `GET`, `PUT`, `DELETE`, `PROPFIND`, etc.
Upload a calendar:
```bash
curl -u username:password \
-T calendar.ics \
http://localhost:8000/webdav/calendar.ics
```
Download via WebDAV:
```bash
curl -u username:password \
http://localhost:8000/webdav/calendar.ics
```
### Public Calendar Access (No Authentication)
**Endpoint**: `/<user-id>/calendar.ics`
**Authentication**: None (security via obscure UUID)
**Method**: `GET`
Access public calendar:
```bash
curl http://localhost:8000/01933b2c-8f5e-7890-a234-567890abcdef/calendar.ics
```
Subscribe in calendar apps:
```
http://localhost:8000/01933b2c-8f5e-7890-a234-567890abcdef/calendar.ics
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `DB_PATH` | `./data/cal.db` | Path to SQLite database file |
Example:
```bash
DB_PATH=/var/lib/calspot/calendar.db ./calspot
```
## Technical Details
### Database Schema
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY, -- UUIDv7 for public calendar access
username TEXT UNIQUE, -- Login username
password_hash TEXT -- bcrypt hash
);
CREATE TABLE calendars (
user_id TEXT PRIMARY KEY, -- Foreign key to users.id
filename TEXT, -- Original filename (e.g., calendar.ics)
content BLOB, -- iCalendar file content
mod_time DATETIME -- Last modification time
);
```
### Dependencies
- `github.com/google/uuid` - UUID generation (v1.6.0)
- `github.com/mattn/go-sqlite3` - SQLite driver (v1.14.32)
- `golang.org/x/crypto` - bcrypt hashing (v0.46.0)
- `golang.org/x/net/webdav` - WebDAV implementation (v0.48.0)
### Build Requirements
- Go 1.25.5 or later
- GCC (for CGO/SQLite3 compilation)

44
main.go
View File

@@ -211,7 +211,14 @@ func (f *sqlFileBuffer) Read(p []byte) (n int, err error) { return 0
func (f *sqlFileBuffer) Seek(offset int64, whence int) (int64, error) { return 0, nil }
func (f *sqlFileBuffer) Readdir(count int) ([]os.FileInfo, error) { return nil, os.ErrInvalid }
func (f *sqlFileBuffer) Stat() (os.FileInfo, error) { return &memFileInfo{name: f.filename}, nil }
func (f *sqlFileBuffer) Write(p []byte) (int, error) { return f.buffer.Write(p) }
func (f *sqlFileBuffer) Write(p []byte) (int, error) {
// Limit calendar file size to 10MB to prevent DoS
const maxSize = 10 * 1024 * 1024
if f.buffer.Len()+len(p) > maxSize {
return 0, errors.New("file size exceeds maximum allowed (10MB)")
}
return f.buffer.Write(p)
}
func (f *sqlFileBuffer) Close() error {
// Flush buffer to DB
_, err := db.Exec(`
@@ -247,6 +254,12 @@ func publicCalendarHandler(w http.ResponseWriter, r *http.Request) {
}
userID := parts[0]
// Add security headers
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "no-referrer")
var content []byte
err := db.QueryRow("SELECT content FROM calendars WHERE user_id = ?", userID).Scan(&content)
if err != nil {
@@ -258,6 +271,16 @@ func publicCalendarHandler(w http.ResponseWriter, r *http.Request) {
w.Write(content)
}
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
@@ -267,6 +290,13 @@ func authMiddleware(next http.Handler) http.Handler {
return
}
// Input validation for username
if len(user) > 255 || len(user) == 0 {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var id, hash string
err := db.QueryRow("SELECT id, password_hash FROM users WHERE username = ?", user).Scan(&id, &hash)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) != nil {
@@ -286,7 +316,10 @@ func generatePassword() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
b := make([]byte, 16)
for i := range b {
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
log.Fatalf("Failed to generate secure random password: %v", err)
}
b[i] = charset[num.Int64()]
}
return string(b)
@@ -318,6 +351,11 @@ func runREPL() {
username := ""
if len(parts) > 1 {
username = parts[1]
// Validate username length and characters
if len(username) > 255 || len(username) == 0 {
fmt.Println("Error: username must be 1-255 characters")
break
}
} else {
u, _ := uuid.NewV7()
username = u.String()
@@ -440,7 +478,7 @@ func main() {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/webdav/") {
// Strip prefix for WebDAV handler so it sees relative root
http.StripPrefix("/webdav", authMiddleware(davHandler)).ServeHTTP(w, r)
http.StripPrefix("/webdav", securityHeadersMiddleware(authMiddleware(davHandler))).ServeHTTP(w, r)
return
}
// Public Calendar