mirror of
https://github.com/nabbar/golib.git
synced 2025-12-24 11:51:02 +08:00
[ file/progress ]
- ADD flag to register temp file creation
- ADD function to check flag is temp
[ static ]
- FIX bugs & race detection
- UPDATE code: refactor & optimize code, improve security &
preformances
- ADD Path Security: add options & code to improve security
- ADD Rate Limiting: add option to limit capabilities of burst request
- ADD HTTP Security Headers: add option to customize header, improve
security & allow cache crontol
- ADD Suspicious Access Detection: add option to identify & log
suspicious request
- ADD Security Backend Integration: add option to plug WAF/IDF/EDR
backend (with CEF Format or not)
- ADD documentation: add enhanced README and TESTING guidelines
- ADD tests: complete test suites with benchmarks, concurrency, and edge cases
This commit is contained in:
54
README.md
54
README.md
@@ -6,8 +6,8 @@
|
||||
[](https://pkg.go.dev/github.com/nabbar/golib)
|
||||
[](https://goreportcard.com/report/github.com/nabbar/golib)
|
||||
[](https://snyk.io/test/github/nabbar/golib)
|
||||
[](TESTING.md)
|
||||
[](TESTING.md)
|
||||
[](TESTING.md)
|
||||
[](TESTING.md)
|
||||
|
||||
Comprehensive Go library collection providing production-ready packages for cloud services, web infrastructure, data management, security, monitoring, and development utilities. Built for enterprise-grade applications with extensive testing and documentation.
|
||||
|
||||
@@ -47,7 +47,7 @@ This library provides building blocks for cloud-native applications, web service
|
||||
### Design Philosophy
|
||||
|
||||
1. **Modularity**: Self-contained packages with minimal cross-dependencies
|
||||
2. **Production-Ready**: Comprehensive testing with 10,735 specs across 126 packages
|
||||
2. **Production-Ready**: Comprehensive testing with 10,964 specs across 127 packages
|
||||
3. **Performance-First**: Streaming operations, zero-allocation paths, optimized throughput
|
||||
4. **Thread-Safe**: All concurrent operations validated with race detector
|
||||
5. **Observable**: Structured logging, Prometheus metrics, health checks, monitoring
|
||||
@@ -56,16 +56,16 @@ This library provides building blocks for cloud-native applications, web service
|
||||
### Repository Statistics
|
||||
|
||||
```
|
||||
Total Packages: 165 (126 with tests, 39 utility/types packages)
|
||||
Documented Packages: 61 packages with individual README.md files
|
||||
Test Specifications: 10,735
|
||||
Test Assertions: 21,048
|
||||
Total Packages: 165 (127 with tests, 38 utility/types packages)
|
||||
Documented Packages: 62 packages with individual README.md files
|
||||
Test Specifications: 10,964
|
||||
Test Assertions: 21,470
|
||||
Benchmarks: 92
|
||||
Pending Tests: 18
|
||||
Average Coverage: 73.8%
|
||||
Packages ≥80%: 67/126 (53%)
|
||||
Packages ≥90%: 38/126 (30%)
|
||||
Go Version: 1.24+
|
||||
Average Coverage: 73.9%
|
||||
Packages ≥80%: 67/127 (52.8%)
|
||||
Packages ≥90%: 38/127 (29.9%)
|
||||
Go Version: 1.24+ (1.25+ recommended)
|
||||
Platforms: Linux, macOS, Windows
|
||||
Thread Safety: ✅ Zero race conditions
|
||||
CI/CD: GitHub Actions with race detection
|
||||
@@ -120,7 +120,7 @@ CI/CD: GitHub Actions with race detection
|
||||
- **CLI Framework**: Cobra extensions with enhanced features ([cobra](cobra/))
|
||||
- **Shell**: Interactive shell with command management, TTY handling ([shell](shell/))
|
||||
- **Console**: Terminal formatting, colored output, progress indicators ([console](console/))
|
||||
- **Static**: Static file embedding and serving ([static](static/))
|
||||
- **Static File Server**: Secure static file serving with WAF/IDS/EDR integration, rate limiting, path security ([static](static/))
|
||||
|
||||
---
|
||||
|
||||
@@ -187,7 +187,7 @@ golib/
|
||||
├── pprof/ Profiling utilities
|
||||
├── request/ HTTP request helpers
|
||||
├── shell/ Interactive shell
|
||||
└── static/ Static file embedding
|
||||
└── static/ Security-focused static file server with caching
|
||||
```
|
||||
|
||||
**Package Count**: 37 top-level, 165 total (including subpackages)
|
||||
@@ -287,7 +287,7 @@ go get -u github.com/nabbar/golib/logger
|
||||
|
||||
Detailed list of all packages with coverage statistics and links to documentation.
|
||||
|
||||
**Total**: 61 packages with individual README.md documentation across 165 Go packages.
|
||||
**Total**: 62 packages with individual README.md documentation across 165 Go packages.
|
||||
|
||||
**Note**: Many parent packages have multiple documented subpackages:
|
||||
- **encoding**: 6 documented packages (base + aes, hexa, mux, randRead, sha256) - [See encoding/README.md](encoding/)
|
||||
@@ -297,7 +297,7 @@ Detailed list of all packages with coverage statistics and links to documentatio
|
||||
- **prometheus**: 2 documented packages (base + webmetrics) - [See prometheus/README.md](prometheus/)
|
||||
- **socket**: 3 documented packages (base + client, server) - [See socket/README.md](socket/)
|
||||
|
||||
The table below lists all 165 Go packages with their test coverage and links to their 61 individual README.md documentation files.
|
||||
The table below lists all 165 Go packages with their test coverage and links to their 62 individual README.md documentation files.
|
||||
|
||||
### Cloud & Infrastructure
|
||||
|
||||
@@ -307,7 +307,7 @@ The table below lists all 165 Go packages with their test coverage and links to
|
||||
| **artifact** | 23.4% | 19 | Artifact management (GitHub/GitLab/JFrog/S3) | [README](artifact/) |
|
||||
| **artifact/client** | 98.6% | 21 | Generic artifact client interface | - |
|
||||
| **aws** | 5.4% | 220 | AWS SDK integration (S3, IAM, MinIO) | [README](aws/) |
|
||||
| **static** | No tests | - | Static file embedding and serving | [README](static/) |
|
||||
| **static** | 82.6% | 229 | Security-focused static file server with embed.FS, rate limiting, WAF integration | [README](static/) |
|
||||
|
||||
### Web & Networking
|
||||
|
||||
@@ -632,11 +632,11 @@ Based on complete coverage analysis with `coverage-report.sh`:
|
||||
|
||||
```
|
||||
Total Packages: 165
|
||||
Packages with Tests: 126 (76.4%)
|
||||
Packages without Tests: 39 (23.6%)
|
||||
Packages with Tests: 127 (77.0%)
|
||||
Packages without Tests: 38 (23.0%)
|
||||
|
||||
Test Specifications: 10,735
|
||||
Test Assertions: 21,048
|
||||
Test Specifications: 10,964
|
||||
Test Assertions: 21,470
|
||||
Benchmarks: 92
|
||||
Pending Tests: 18
|
||||
|
||||
@@ -650,14 +650,14 @@ Race Conditions: 0 (verified with -race flag)
|
||||
|
||||
| Coverage Range | Count | Percentage | Package Examples |
|
||||
|----------------|-------|------------|------------------|
|
||||
| **100%** | 14 | 11.1% | errors/pool, logger/gorm, router/authheader, semaphore/sem |
|
||||
| **90-99%** | 24 | 19.0% | atomic (91.8%), version (93.8%), size (95.4%) |
|
||||
| **80-89%** | 29 | 23.0% | ioutils (87.7%), mail/queuer (90.8%), context (87.5%) |
|
||||
| **70-79%** | 18 | 14.3% | cobra (76.7%), viper (73.3%), file/bandwidth (77.8%) |
|
||||
| **100%** | 14 | 11.0% | errors/pool, logger/gorm, router/authheader, semaphore/sem |
|
||||
| **90-99%** | 24 | 18.9% | atomic (91.8%), version (93.8%), size (95.4%) |
|
||||
| **80-89%** | 29 | 22.8% | ioutils (87.7%), mail/queuer (90.8%), static (82.6%), context (87.5%) |
|
||||
| **70-79%** | 19 | 15.0% | cobra (76.7%), viper (73.3%), file/bandwidth (77.8%) |
|
||||
| **60-69%** | 10 | 7.9% | config (61.9%), logger (68.0%), database/kvmap (66.7%) |
|
||||
| **<60%** | 31 | 24.6% | archive (8.6%), aws (5.4%), httpserver (52.5%) |
|
||||
| **<60%** | 31 | 24.4% | archive (8.6%), aws (5.4%), httpserver (52.5%) |
|
||||
|
||||
**Average Coverage**: 73.8% (weighted across all 126 tested packages)
|
||||
**Average Coverage**: 73.9% (weighted across all 127 tested packages)
|
||||
|
||||
### Performance Highlights
|
||||
|
||||
@@ -934,7 +934,7 @@ if err := json.Unmarshal(data, &result); err != nil {
|
||||
|
||||
## Testing
|
||||
|
||||
Comprehensive test suite with 10,735 specifications across 126 packages.
|
||||
Comprehensive test suite with 10,964 specifications across 127 packages.
|
||||
|
||||
See [TESTING.md](TESTING.md) for detailed testing documentation.
|
||||
|
||||
|
||||
29
TESTING.md
29
TESTING.md
@@ -34,18 +34,18 @@ Comprehensive testing guide for the `github.com/nabbar/golib` library and all it
|
||||
|
||||
```
|
||||
Total Packages: 165
|
||||
Packages with Tests: 126 (76.4%)
|
||||
Packages without Tests: 39 (23.6%)
|
||||
Packages with Tests: 127 (77.0%)
|
||||
Packages without Tests: 38 (23.0%)
|
||||
|
||||
Test Specifications: 10,735
|
||||
Test Assertions: 21,048
|
||||
Test Specifications: 10,964
|
||||
Test Assertions: 21,470
|
||||
Benchmarks: 92
|
||||
Pending Tests: 18
|
||||
Skipped Tests: 0
|
||||
|
||||
Average Coverage: 73.8%
|
||||
Packages ≥80%: 67/126 (53.2%)
|
||||
Packages at 100%: 14/126 (11.1%)
|
||||
Average Coverage: 73.9%
|
||||
Packages ≥80%: 67/127 (52.8%)
|
||||
Packages at 100%: 14/127 (11.0%)
|
||||
|
||||
Race Conditions: 0 (verified with CGO_ENABLED=1 go test -race)
|
||||
Thread Safety: ✅ All concurrent operations validated
|
||||
@@ -102,9 +102,9 @@ go test -timeout=10m -v -cover -covermode=atomic ./...
|
||||
|
||||
```
|
||||
Total Packages: 165
|
||||
Packages with Tests: 126
|
||||
Test Specifications: 10,735
|
||||
Average Coverage: 73.8%
|
||||
Packages with Tests: 127
|
||||
Test Specifications: 10,964
|
||||
Average Coverage: 73.9%
|
||||
|
||||
PACKAGES WITHOUT TESTS
|
||||
• archive/archive
|
||||
@@ -317,9 +317,9 @@ The repository includes `coverage-report.sh`, a comprehensive script that analyz
|
||||
|
||||
```
|
||||
Total Packages: 165
|
||||
Packages with Tests: 126 (76.4%)
|
||||
Test Specifications: 10,735
|
||||
Average Coverage: 73.8%
|
||||
Packages with Tests: 127 (77.0%)
|
||||
Test Specifications: 10,964
|
||||
Average Coverage: 73.9%
|
||||
|
||||
PACKAGES WITHOUT TESTS
|
||||
• archive/archive
|
||||
@@ -408,7 +408,7 @@ This script is used to generate all coverage statistics shown in this document a
|
||||
|
||||
### Untested Packages
|
||||
|
||||
**39 packages without test files:**
|
||||
**38 packages without test files:**
|
||||
|
||||
Infrastructure packages (primarily type definitions and utilities):
|
||||
- archive/archive, archive/archive/tar, archive/archive/types, archive/archive/zip
|
||||
@@ -426,7 +426,6 @@ Infrastructure packages (primarily type definitions and utilities):
|
||||
- request, runner (base package)
|
||||
- semaphore/types
|
||||
- socket (base package), socket/client, socket/config, socket/server
|
||||
- static
|
||||
|
||||
**Note:** Many untested packages are interface definitions, constants, or types packages that may not require separate tests if covered by parent package tests.
|
||||
|
||||
|
||||
@@ -165,9 +165,14 @@ type File interface {
|
||||
Sync() error
|
||||
}
|
||||
|
||||
type TempFile interface {
|
||||
IsTemp() bool
|
||||
}
|
||||
|
||||
type Progress interface {
|
||||
GenericIO
|
||||
File
|
||||
TempFile
|
||||
|
||||
// RegisterFctIncrement registers a function to be called when the progress of
|
||||
// a file being read or written reaches a certain number of bytes. The
|
||||
@@ -258,6 +263,7 @@ func New(name string, flags int, perm os.FileMode) (Progress, error) {
|
||||
return &progress{
|
||||
r: r,
|
||||
f: f,
|
||||
t: false,
|
||||
b: new(atomic.Int32),
|
||||
fi: new(atomic.Value),
|
||||
fe: new(atomic.Value),
|
||||
@@ -283,6 +289,7 @@ func Unique(basePath, pattern string) (Progress, error) {
|
||||
return &progress{
|
||||
r: nil,
|
||||
f: f,
|
||||
t: false,
|
||||
b: new(atomic.Int32),
|
||||
fi: new(atomic.Value),
|
||||
fe: new(atomic.Value),
|
||||
@@ -311,6 +318,7 @@ func Temp(pattern string) (Progress, error) {
|
||||
return &progress{
|
||||
r: nil,
|
||||
f: f,
|
||||
t: true,
|
||||
b: new(atomic.Int32),
|
||||
fi: new(atomic.Value),
|
||||
fe: new(atomic.Value),
|
||||
@@ -341,6 +349,7 @@ func Open(name string) (Progress, error) {
|
||||
return &progress{
|
||||
r: r,
|
||||
f: f,
|
||||
t: false,
|
||||
b: new(atomic.Int32),
|
||||
fi: new(atomic.Value),
|
||||
fe: new(atomic.Value),
|
||||
@@ -371,6 +380,7 @@ func Create(name string) (Progress, error) {
|
||||
return &progress{
|
||||
r: r,
|
||||
f: f,
|
||||
t: false,
|
||||
b: new(atomic.Int32),
|
||||
fi: new(atomic.Value),
|
||||
fe: new(atomic.Value),
|
||||
|
||||
@@ -34,14 +34,15 @@ import (
|
||||
)
|
||||
|
||||
type progress struct {
|
||||
r *os.Root
|
||||
f *os.File
|
||||
r *os.Root // os Root
|
||||
f *os.File // file
|
||||
t bool // is Temp file
|
||||
|
||||
b *atomic.Int32
|
||||
b *atomic.Int32 // buffer size
|
||||
|
||||
fi *atomic.Value
|
||||
fe *atomic.Value
|
||||
fr *atomic.Value
|
||||
fi *atomic.Value // func Increment
|
||||
fe *atomic.Value // func EOF
|
||||
fr *atomic.Value // func Reset
|
||||
}
|
||||
|
||||
func (o *progress) SetBufferSize(size int32) {
|
||||
@@ -63,6 +64,10 @@ func (o *progress) getBufferSize(size int) int {
|
||||
}
|
||||
}
|
||||
|
||||
func (o *progress) IsTemp() bool {
|
||||
return o.t
|
||||
}
|
||||
|
||||
func (o *progress) Path() string {
|
||||
return filepath.Clean(o.f.Name())
|
||||
}
|
||||
|
||||
804
static/README.md
804
static/README.md
@@ -1,47 +1,787 @@
|
||||
# Package Static
|
||||
This package help to manage static file router in an API to embedded files into the go binary api.
|
||||
This package requires `packr` tools, `golib/router` & go Gin Tonic API Framework.
|
||||
# Static File Server
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://golang.org/)
|
||||
|
||||
High-performance, security-focused static file server for Go with enterprise-grade features including WAF/IDS/EDR integration, rate limiting, path traversal protection, and advanced HTTP caching.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Key Features](#key-features)
|
||||
- [Installation](#installation)
|
||||
- [Architecture](#architecture)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Performance](#performance)
|
||||
- [Use Cases](#use-cases)
|
||||
- [API Reference](#api-reference)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Testing](#testing)
|
||||
- [Contributing](#contributing)
|
||||
- [Future Enhancements](#future-enhancements)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The **static** package provides a production-ready static file server built on top of Go's `embed.FS` with comprehensive security features designed for modern web applications. It seamlessly integrates with the Gin web framework and provides enterprise-grade security monitoring through WAF/IDS/EDR webhook integration.
|
||||
|
||||
### Design Philosophy
|
||||
|
||||
1. **Security First** - Multiple layers of protection against common web attacks
|
||||
2. **Zero Mutex** - Lock-free concurrency using atomic operations for maximum performance
|
||||
3. **Observable** - Built-in security event streaming to SIEM systems
|
||||
4. **Production Ready** - Battle-tested with 82.6% test coverage and zero race conditions
|
||||
5. **Developer Friendly** - Simple API with sensible defaults
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
### Core Features
|
||||
|
||||
- **Embedded Filesystem** - Serve files from Go's `embed.FS`
|
||||
- **Gin Integration** - Seamless integration with Gin web framework
|
||||
- **Multiple Base Paths** - Support for multiple embedded directories
|
||||
- **Index Files** - Automatic index file resolution for directories
|
||||
- **Download Mode** - Force download with Content-Disposition header
|
||||
- **URL Redirects** - HTTP 301 permanent redirects
|
||||
- **Custom Handlers** - Override default behavior for specific routes
|
||||
|
||||
### Security Features
|
||||
|
||||
#### 1. Path Security
|
||||
- **Path Traversal Protection** - Prevents `../` attacks
|
||||
- **Null Byte Injection Prevention** - Blocks null byte attacks
|
||||
- **Dot File Blocking** - Protects `.env`, `.git`, `.htaccess`
|
||||
- **Max Path Depth** - Limits directory traversal depth
|
||||
- **Pattern Blocking** - Blocks configurable path patterns
|
||||
|
||||
#### 2. Rate Limiting
|
||||
- **IP-based Limiting** - Tracks unique files per IP
|
||||
- **Sliding Window** - Accurate rate calculation
|
||||
- **Whitelist Support** - Bypass for trusted IPs
|
||||
- **Automatic Cleanup** - Prevents memory leaks
|
||||
- **Standard Headers** - `X-RateLimit-*`, `Retry-After`
|
||||
|
||||
#### 3. HTTP Security Headers
|
||||
- **ETag Support** - Efficient cache validation
|
||||
- **Cache-Control** - Fine-grained cache control
|
||||
- **Content-Type Validation** - MIME type filtering
|
||||
- **Custom MIME Types** - Override default detection
|
||||
- **Expires Headers** - HTTP/1.0 compatibility
|
||||
|
||||
#### 4. Suspicious Access Detection
|
||||
- **Pattern Recognition** - Detects common attack patterns
|
||||
- **Backup File Scanning** - Identifies `.bak`, `.old` attempts
|
||||
- **Config File Scanning** - Detects `config.php` attempts
|
||||
- **Admin Panel Scanning** - Identifies `/admin` probes
|
||||
- **Path Manipulation** - Detects double slashes, backslashes
|
||||
|
||||
#### 5. WAF/IDS/EDR Integration
|
||||
- **Webhook Support** - Real-time event streaming
|
||||
- **CEF Format** - Common Event Format for SIEM systems
|
||||
- **Batch Processing** - Efficient bulk event sending
|
||||
- **Go Callbacks** - Programmatic event handling
|
||||
- **Async Processing** - Non-blocking event delivery
|
||||
- **Severity Filtering** - Configurable event levels
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
## Example of implementation
|
||||
We will work on an example of file/folder tree like this :
|
||||
```bash
|
||||
/
|
||||
bin/
|
||||
api/
|
||||
config/
|
||||
routers/
|
||||
static/
|
||||
get.go
|
||||
static/
|
||||
static/
|
||||
...some_static_files...
|
||||
go get github.com/nabbar/golib/static
|
||||
```
|
||||
|
||||
in the `get.go` file, we will implement the static package call :
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
│
|
||||
├──> [Rate Limiter]
|
||||
│ │
|
||||
│ ├──> Limit Exceeded? ──> 429 Too Many Requests
|
||||
│ └──> OK
|
||||
│
|
||||
├──> [Path Security Validator]
|
||||
│ │
|
||||
│ ├──> Path Traversal? ──> 403 Forbidden + Event
|
||||
│ ├──> Dot File Access? ──> 403 Forbidden + Event
|
||||
│ ├──> Blocked Pattern? ──> 403 Forbidden + Event
|
||||
│ └──> OK
|
||||
│
|
||||
├──> [Redirect Handler]
|
||||
│ │
|
||||
│ └──> Redirect? ──> 301 Permanent Redirect
|
||||
│
|
||||
├──> [Custom Handler]
|
||||
│ │
|
||||
│ └──> Custom? ──> Execute Custom Handler
|
||||
│
|
||||
├──> [Index File Resolution]
|
||||
│ │
|
||||
│ └──> Directory? ──> Serve Index File
|
||||
│
|
||||
├──> [File Lookup]
|
||||
│ │
|
||||
│ ├──> Not Found? ──> 404 Not Found
|
||||
│ └──> Found
|
||||
│
|
||||
├──> [MIME Type Validation]
|
||||
│ │
|
||||
│ ├──> Denied Type? ──> 403 Forbidden + Event
|
||||
│ └──> OK
|
||||
│
|
||||
├──> [ETag Validation]
|
||||
│ │
|
||||
│ └──> Match? ──> 304 Not Modified
|
||||
│
|
||||
├──> [Suspicious Access Detection]
|
||||
│ │
|
||||
│ └──> Suspicious? ──> Log + Notify
|
||||
│
|
||||
└──> [File Delivery]
|
||||
│
|
||||
└──> 200 OK + File Content + Cache Headers
|
||||
```
|
||||
|
||||
### Security Event Processing
|
||||
|
||||
```
|
||||
Security Event
|
||||
│
|
||||
├──> [Severity Filter]
|
||||
│ │
|
||||
│ └──> Below Min? ──> Drop
|
||||
│
|
||||
├──> [Go Callbacks]
|
||||
│ │
|
||||
│ └──> async goroutine
|
||||
│
|
||||
└──> [Webhook Integration]
|
||||
│
|
||||
├──> Batch Enabled?
|
||||
│ │
|
||||
│ ├──> Add to Batch
|
||||
│ │ │
|
||||
│ │ ├──> Batch Full? ──> Send Immediately
|
||||
│ │ └──> Start Timer ──> Send on Timeout
|
||||
│ │
|
||||
│ └──> Real-time
|
||||
│ │
|
||||
│ ├──> JSON Format ──> POST to Webhook
|
||||
│ └──> CEF Format ──> POST to SIEM
|
||||
```
|
||||
|
||||
### Thread Safety Model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Static Handler (Lock-Free) │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Atomic Operations (libatm.Value, atomic.*) │
|
||||
│ ├─ Configuration (RateLimit, Security, Headers) │
|
||||
│ ├─ IP Tracking (libatm.MapTyped) │
|
||||
│ ├─ Event Batching (atomic counters + map) │
|
||||
│ └─ Router State │
|
||||
│ │
|
||||
│ Embedded Filesystem (read-only, inherently safe) │
|
||||
│ │
|
||||
│ Context-based Configuration (libctx.Config) │
|
||||
│ ├─ Index files │
|
||||
│ ├─ Downloads │
|
||||
│ ├─ Redirects │
|
||||
│ └─ Custom handlers │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
No mutexes required!
|
||||
Concurrent reads and writes are safe by design.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Minimum Go Version
|
||||
|
||||
**Go 1.21+** is required for:
|
||||
- `embed.FS` and `//go:embed` directive (Go 1.16)
|
||||
- Generics support for type-safe atomic wrappers (Go 1.18)
|
||||
- `atomic.Int64`/`atomic.Uint64` types with methods (Go 1.19)
|
||||
- `slices.Contains()` from standard library (Go 1.21)
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
package static
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gobuffalo/packr"
|
||||
"github.com/nabbar/golib/static"
|
||||
|
||||
"myapp/release"
|
||||
"myapp/bin/api/config"
|
||||
"myapp/bin/api/routers"
|
||||
"github.com/nabbar/golib/static"
|
||||
)
|
||||
|
||||
const UrlPrefix = "/static"
|
||||
//go:embed public
|
||||
var publicFS embed.FS
|
||||
|
||||
func init() {
|
||||
staticStcFile := static.NewStatic(false, UrlPrefix, packr.NewBox("../../../../static/static"), GetHeader)
|
||||
|
||||
staticStcFile.SetDownloadAll()
|
||||
staticStcFile.Register(routers.RouterList.Register)
|
||||
func main() {
|
||||
// Create static handler
|
||||
handler := static.New(context.Background(), publicFS, "public")
|
||||
|
||||
// Configure security
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
handler.SetRateLimit(static.DefaultRateLimitConfig())
|
||||
|
||||
// Setup Gin router
|
||||
router := gin.Default()
|
||||
handler.RegisterRouter("/static", router.GET)
|
||||
|
||||
router.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
func GetHeader(c *gin.Context) {
|
||||
// any function to return global & generic header (like CSP, HSTS, ...)
|
||||
}
|
||||
### With Security Integration
|
||||
|
||||
```go
|
||||
handler := static.New(ctx, fs, "assets")
|
||||
|
||||
// Path security
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: false,
|
||||
MaxPathDepth: 10,
|
||||
BlockedPatterns: []string{".git", ".env"},
|
||||
})
|
||||
|
||||
// Rate limiting
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 100,
|
||||
Window: time.Minute,
|
||||
})
|
||||
|
||||
// WAF integration
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: "https://waf.example.com/events",
|
||||
MinSeverity: "medium",
|
||||
})
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```go
|
||||
// HTTP caching
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 3600,
|
||||
CachePublic: true,
|
||||
EnableETag: true,
|
||||
DenyMimeTypes: []string{"application/x-executable"},
|
||||
})
|
||||
|
||||
// Suspicious access detection
|
||||
handler.SetSuspicious(static.SuspiciousConfig{
|
||||
Enabled: true,
|
||||
SuspiciousPatterns: []string{
|
||||
".env", ".git", "wp-admin",
|
||||
},
|
||||
})
|
||||
|
||||
// Index files
|
||||
handler.SetIndex("", "/", "index.html")
|
||||
handler.SetIndex("", "/docs", "docs/index.html")
|
||||
|
||||
// Downloads
|
||||
handler.SetDownload("/files/document.pdf", true)
|
||||
|
||||
// Redirects
|
||||
handler.SetRedirect("", "/old-path", "", "/new-path")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### Test Results
|
||||
|
||||
| Metric | Value | Notes |
|
||||
|--------|-------|-------|
|
||||
| **Test Coverage** | 82.6% | 229/229 tests passing |
|
||||
| **Race Conditions** | 0 | Verified with `-race` detector |
|
||||
| **Throughput** | 1,900-5,600 RPS | Single file, no caching |
|
||||
| **Latency (p50)** | ~100µs | File operation median |
|
||||
| **Latency (p99)** | <5ms | Large file operations |
|
||||
| **Memory** | O(1) per request | No allocation spikes |
|
||||
|
||||
### Benchmarks
|
||||
|
||||
```
|
||||
Static File Operations
|
||||
Name | N | Min | Median | Mean | StdDev | Max
|
||||
========================================================================================
|
||||
File-Has [duration] | 100 | 0s | 0s | 0s | 0s | 100µs
|
||||
File-Info [duration] | 100 | 0s | 0s | 0s | 0s | 100µs
|
||||
File-Find [duration] | 100 | 0s | 0s | 0s | 0s | 200µs
|
||||
PathSecurity [duration] | 100 | 0s | 0s | 0s | 0s | 100µs
|
||||
RateLimit-Allow [duration] | 100 | 0s | 0s | 0s | 0s | 200µs
|
||||
RateLimit-Block [duration] | 10 | 0s | 0s | 0s | 0s | 100µs
|
||||
ETag-Generate [duration] | 100 | 0s | 0s | 0s | 0s | 100µs
|
||||
ETag-Validate [duration] | 100 | 0s | 0s | 0s | 0s | 0s
|
||||
Redirect [duration] | 500 | 100µs | 100µs | 200µs | 100µs | 1.6ms
|
||||
SpecificHandler [duration] | 500 | 100µs | 100µs | 100µs | 100µs | 600µs
|
||||
Throughput-RPS | 1 | 1,938 | 5,692 | 3,815 | varies | 5,692
|
||||
```
|
||||
|
||||
### Performance Characteristics
|
||||
|
||||
- **Zero Mutex Overhead** - All operations use atomic primitives
|
||||
- **O(1) IP Lookup** - Constant time rate limit checks
|
||||
- **Lazy Initialization** - Configuration loaded on demand
|
||||
- **Efficient Batching** - Reduces webhook overhead by 90%+
|
||||
- **304 Responses** - Saves bandwidth with ETag validation
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Single Page Application (SPA)
|
||||
|
||||
```go
|
||||
handler := static.New(ctx, embedFS, "dist")
|
||||
|
||||
// Security
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
// Aggressive caching for immutable assets
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 31536000, // 1 year
|
||||
CachePublic: true,
|
||||
EnableETag: true,
|
||||
})
|
||||
|
||||
// Index file for all routes (SPA routing)
|
||||
handler.SetIndex("", "/", "index.html")
|
||||
```
|
||||
|
||||
### 2. API Documentation Server
|
||||
|
||||
```go
|
||||
handler := static.New(ctx, docsFS, "docs")
|
||||
|
||||
// Moderate caching
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
CacheMaxAge: 3600, // 1 hour
|
||||
EnableETag: true,
|
||||
})
|
||||
|
||||
// Rate limiting
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
MaxRequests: 1000,
|
||||
Window: time.Minute,
|
||||
})
|
||||
```
|
||||
|
||||
### 3. CDN Origin Server
|
||||
|
||||
```go
|
||||
handler := static.New(ctx, assetsFS, "assets")
|
||||
|
||||
// Maximum caching for CDN
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
CacheMaxAge: 31536000, // 1 year
|
||||
CachePublic: true,
|
||||
EnableETag: true,
|
||||
})
|
||||
|
||||
// Relaxed rate limiting (CDN handles most traffic)
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
MaxRequests: 10000,
|
||||
Window: time.Minute,
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Enterprise Web Application
|
||||
|
||||
```go
|
||||
handler := static.New(ctx, appFS, "public")
|
||||
|
||||
// Full security stack
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: false,
|
||||
MaxPathDepth: 10,
|
||||
BlockedPatterns: []string{".git", ".svn", "node_modules"},
|
||||
})
|
||||
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 100,
|
||||
Window: time.Minute,
|
||||
})
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
|
||||
// WAF/SIEM integration
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: "https://siem.company.com/events",
|
||||
BatchSize: 100,
|
||||
BatchTimeout: 30 * time.Second,
|
||||
EnableCEFFormat: true,
|
||||
})
|
||||
```
|
||||
|
||||
### 5. Development Server
|
||||
|
||||
```go
|
||||
handler := static.New(ctx, devFS, "src")
|
||||
|
||||
// Minimal security for local dev
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: false, // Still protect .env
|
||||
})
|
||||
|
||||
// No caching for fast iteration
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: false,
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Core Interface
|
||||
|
||||
```go
|
||||
type Static interface {
|
||||
StaticFileSystem
|
||||
StaticPathSecurity
|
||||
StaticRateLimit
|
||||
StaticHeaders
|
||||
StaticSuspicious
|
||||
StaticSecurityBackend
|
||||
StaticIndex
|
||||
StaticDownload
|
||||
StaticRedirect
|
||||
StaticSpecific
|
||||
StaticRouter
|
||||
StaticMonitor
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Types
|
||||
|
||||
#### PathSecurityConfig
|
||||
|
||||
```go
|
||||
type PathSecurityConfig struct {
|
||||
Enabled bool // Enable path validation
|
||||
AllowDotFiles bool // Allow .env, .git, etc.
|
||||
MaxPathDepth int // Maximum depth (0 = unlimited)
|
||||
BlockedPatterns []string // Patterns to block
|
||||
}
|
||||
```
|
||||
|
||||
#### RateLimitConfig
|
||||
|
||||
```go
|
||||
type RateLimitConfig struct {
|
||||
Enabled bool // Enable rate limiting
|
||||
MaxRequests int // Max unique files per window
|
||||
Window time.Duration // Time window
|
||||
CleanupInterval time.Duration // Cleanup frequency
|
||||
WhitelistIPs []string // Bypass IPs
|
||||
TrustedProxies []string // Trusted proxy IPs
|
||||
}
|
||||
```
|
||||
|
||||
#### HeadersConfig
|
||||
|
||||
```go
|
||||
type HeadersConfig struct {
|
||||
EnableCacheControl bool // Enable Cache-Control
|
||||
CacheMaxAge int // Cache duration (seconds)
|
||||
CachePublic bool // Public or private cache
|
||||
EnableETag bool // Enable ETag
|
||||
EnableContentType bool // Enable MIME validation
|
||||
AllowedMimeTypes []string // Whitelist (empty = all)
|
||||
DenyMimeTypes []string // Blacklist
|
||||
CustomMimeTypes map[string]string // Custom mappings
|
||||
}
|
||||
```
|
||||
|
||||
#### SecurityConfig
|
||||
|
||||
```go
|
||||
type SecurityConfig struct {
|
||||
Enabled bool // Enable security backend
|
||||
WebhookURL string // Webhook endpoint
|
||||
WebhookHeaders map[string]string // Custom headers
|
||||
WebhookTimeout time.Duration // Request timeout
|
||||
WebhookAsync bool // Async sending
|
||||
MinSeverity string // Minimum severity level
|
||||
BatchSize int // Batch size (0 = real-time)
|
||||
BatchTimeout time.Duration // Batch flush interval
|
||||
EnableCEFFormat bool // Use CEF format
|
||||
Callbacks []SecuEvtCallback // Go callbacks
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes
|
||||
|
||||
```go
|
||||
const (
|
||||
ErrorFileNotFound // File not found in embedded FS
|
||||
ErrorFileOpen // Cannot open file
|
||||
ErrorFileRead // Cannot read file
|
||||
ErrorFiletemp // Cannot create temp file
|
||||
ErrorParamEmpty // Required parameter empty
|
||||
ErrorPathInvalid // Invalid path
|
||||
ErrorPathTraversal // Path traversal attempt
|
||||
ErrorPathDotFile // Dot file access denied
|
||||
ErrorPathDepth // Path depth exceeded
|
||||
ErrorPathBlocked // Blocked pattern matched
|
||||
ErrorMimeTypeDenied // MIME type not allowed
|
||||
)
|
||||
```
|
||||
|
||||
### Security Event Types
|
||||
|
||||
```go
|
||||
const (
|
||||
EventTypePathTraversal // Path traversal attack
|
||||
EventTypeRateLimit // Rate limit exceeded
|
||||
EventTypeSuspicious // Suspicious access pattern
|
||||
EventTypeMimeTypeDenied // MIME type denied
|
||||
EventTypeDotFile // Dot file access attempt
|
||||
EventTypePatternBlocked // Blocked pattern matched
|
||||
EventTypePathDepth // Path depth exceeded
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ DO
|
||||
|
||||
```go
|
||||
// Use default configurations as starting point
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
handler.SetRateLimit(static.DefaultRateLimitConfig())
|
||||
|
||||
// Enable ETag for bandwidth savings
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableETag: true,
|
||||
CacheMaxAge: 3600,
|
||||
})
|
||||
|
||||
// Use batch processing for high-volume security events
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
BatchSize: 100,
|
||||
BatchTimeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
// Whitelist localhost for development
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
WhitelistIPs: []string{"127.0.0.1", "::1"},
|
||||
})
|
||||
|
||||
// Set appropriate cache duration per asset type
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
CacheMaxAge: 31536000, // 1 year for versioned assets
|
||||
})
|
||||
```
|
||||
|
||||
### ❌ DON'T
|
||||
|
||||
```go
|
||||
// Don't disable all security in production
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: false, // ❌ Unsafe
|
||||
})
|
||||
|
||||
// Don't allow dot files in production
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
AllowDotFiles: true, // ❌ Exposes .env, .git
|
||||
})
|
||||
|
||||
// Don't set unlimited rate limit
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
MaxRequests: 0, // ❌ No protection
|
||||
})
|
||||
|
||||
// Don't use sync webhooks in high-traffic scenarios
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
WebhookAsync: false, // ❌ Blocks requests
|
||||
})
|
||||
|
||||
// Don't forget cleanup interval
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
CleanupInterval: 0, // ❌ Memory leak
|
||||
})
|
||||
```
|
||||
|
||||
### Security Recommendations
|
||||
|
||||
1. **Always enable path security** - Even in development
|
||||
2. **Use rate limiting** - Protect against DoS attacks
|
||||
3. **Enable suspicious detection** - Identify attack patterns early
|
||||
4. **Integrate with SIEM** - Use webhook or CEF for monitoring
|
||||
5. **Regular cleanup** - Configure CleanupInterval for rate limiter
|
||||
6. **Whitelist carefully** - Only trusted IPs should bypass limits
|
||||
7. **Block dangerous MIME types** - Prevent executable uploads
|
||||
8. **Use batch processing** - Reduce security backend overhead
|
||||
|
||||
### Performance Recommendations
|
||||
|
||||
1. **Enable ETag** - Reduces bandwidth significantly
|
||||
2. **Use CDN** - Offload static file delivery
|
||||
3. **Appropriate cache TTL** - Balance freshness vs. performance
|
||||
4. **Async webhooks** - Non-blocking security event delivery
|
||||
5. **Batch events** - Reduce webhook call overhead
|
||||
6. **Monitor throughput** - Use built-in benchmarks
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
For comprehensive testing documentation, see [TESTING.md](TESTING.md).
|
||||
|
||||
**Test Suite:**
|
||||
- Total Tests: 229
|
||||
- Coverage: 82.6%
|
||||
- Race Detection: ✅ Zero data races
|
||||
- Execution Time: ~4.7s (standard), ~6.6s (with race)
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test -v
|
||||
|
||||
# With race detector
|
||||
CGO_ENABLED=1 go test -race
|
||||
|
||||
# With coverage
|
||||
go test -cover -coverprofile=coverage.out
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please follow these guidelines:
|
||||
|
||||
### Code Contributions
|
||||
|
||||
- **No AI-generated code** in core implementation
|
||||
- AI assistance is acceptable for tests, documentation, and bug fixes
|
||||
- All contributions must pass existing tests
|
||||
- Add tests for new features
|
||||
- Follow existing code style
|
||||
- Document public APIs with GoDoc
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
- Maintain >80% code coverage
|
||||
- Zero race conditions (`go test -race`)
|
||||
- All tests must pass
|
||||
- Add benchmarks for performance-critical code
|
||||
|
||||
### Documentation
|
||||
|
||||
- Update README.md for new features
|
||||
- Add examples to example_test.go
|
||||
- Document breaking changes
|
||||
- Keep TESTING.md current
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run tests: `go test -race -cover ./...`
|
||||
5. Update documentation
|
||||
6. Submit pull request with clear description
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
|
||||
- **Advanced Rate Limiting**
|
||||
- Token bucket algorithm
|
||||
- Distributed rate limiting (Redis integration)
|
||||
- Per-route rate limits
|
||||
|
||||
- **Enhanced Security**
|
||||
- Content Security Policy (CSP) headers
|
||||
- Subresource Integrity (SRI) support
|
||||
- CORS configuration
|
||||
|
||||
- **Performance Optimization**
|
||||
- Brotli compression support
|
||||
- HTTP/2 Server Push hints
|
||||
- Memory-mapped file serving for large files
|
||||
|
||||
- **Monitoring**
|
||||
- Prometheus metrics endpoint
|
||||
- Detailed access logging
|
||||
- Performance tracing integration
|
||||
|
||||
- **Developer Experience**
|
||||
- Hot reload support for development
|
||||
- Configuration validation
|
||||
- More detailed error messages
|
||||
|
||||
Suggestions and contributions are welcome via GitHub issues.
|
||||
|
||||
---
|
||||
|
||||
## AI Transparency Notice
|
||||
|
||||
In accordance with Article 50.4 of the EU AI Act, AI assistance has been used for testing, documentation, and bug fixing under human supervision.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - See [LICENSE](../../LICENSE) file for details.
|
||||
|
||||
Copyright (c) 2022 Nicolas JUHEL
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/nabbar/golib/issues)
|
||||
- **Documentation**: [GoDoc](https://pkg.go.dev/github.com/nabbar/golib/static)
|
||||
- **Testing Guide**: [TESTING.md](TESTING.md)
|
||||
- **Contributing**: [CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Related Packages**:
|
||||
- [github.com/nabbar/golib/router](../router) - Router utilities
|
||||
- [github.com/nabbar/golib/logger](../logger) - Logging integration
|
||||
- [github.com/nabbar/golib/errors](../errors) - Error handling
|
||||
- [github.com/nabbar/golib/atomic](../atomic) - Atomic primitives
|
||||
- [github.com/nabbar/golib/monitor](../monitor) - Health monitoring
|
||||
- **External Resources**:
|
||||
- [Gin Web Framework](https://github.com/gin-gonic/gin)
|
||||
- [Go embed package](https://pkg.go.dev/embed)
|
||||
- [Common Event Format (CEF)](https://www.microfocus.com/documentation/arcsight/arcsight-smartconnectors-8.3/cef-implementation-standard/)
|
||||
|
||||
916
static/TESTING.md
Normal file
916
static/TESTING.md
Normal file
@@ -0,0 +1,916 @@
|
||||
# Testing Guide
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://golang.org/)
|
||||
[]()
|
||||
[]()
|
||||
|
||||
Comprehensive testing documentation for the static package, covering test execution, race detection, and quality assurance.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Test Framework](#test-framework)
|
||||
- [Running Tests](#running-tests)
|
||||
- [Test Coverage](#test-coverage)
|
||||
- [Thread Safety](#thread-safety)
|
||||
- [Benchmarks](#benchmarks)
|
||||
- [Writing Tests](#writing-tests)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [CI Integration](#ci-integration)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The static package uses **Ginkgo v2** (BDD testing framework) and **Gomega** (matcher library) for comprehensive testing with expressive assertions.
|
||||
|
||||
**Test Suite**
|
||||
- Total Specs: 229
|
||||
- Coverage: 82.6%
|
||||
- Race Detection: ✅ Zero data races
|
||||
- Execution Time: ~4.7s (standard), ~6.6s (with race)
|
||||
|
||||
**Coverage Areas**
|
||||
- Path security validation (path traversal, dot files, patterns)
|
||||
- IP-based rate limiting with sliding window
|
||||
- HTTP headers (ETag, cache control, MIME validation)
|
||||
- Security backend integration (webhooks, CEF, batch processing)
|
||||
- Suspicious access detection and logging
|
||||
- File operations (Has, Find, Info, List, Map)
|
||||
- Router integration with Gin framework
|
||||
- Concurrency and thread safety
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install Ginkgo CLI (optional)
|
||||
go install github.com/onsi/ginkgo/v2/ginkgo@latest
|
||||
|
||||
# Run all tests
|
||||
go test -v
|
||||
|
||||
# With coverage
|
||||
go test -v -cover -coverprofile=coverage.out
|
||||
|
||||
# With race detector
|
||||
CGO_ENABLED=1 go test -race
|
||||
|
||||
# Generate HTML coverage report
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
# Using Ginkgo CLI
|
||||
ginkgo -r --cover --race
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Framework
|
||||
|
||||
### Ginkgo & Gomega
|
||||
|
||||
The test suite uses **Ginkgo v2** for BDD-style testing and **Gomega** for assertions.
|
||||
|
||||
```go
|
||||
var _ = Describe("Static File Server", func() {
|
||||
Context("when serving files", func() {
|
||||
It("should return 200 OK for existing files", func() {
|
||||
// Test implementation
|
||||
Expect(statusCode).To(Equal(200))
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### GinkgoRecover
|
||||
|
||||
All tests use `GinkgoRecover()` to prevent panics from crashing the test suite:
|
||||
|
||||
```go
|
||||
BeforeEach(func() {
|
||||
defer GinkgoRecover()
|
||||
// Setup
|
||||
})
|
||||
```
|
||||
|
||||
### Gmeasure
|
||||
|
||||
Benchmarks use **gmeasure** for precise performance measurements:
|
||||
|
||||
```go
|
||||
experiment := gmeasure.NewExperiment("File Operations")
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("operation", func() {
|
||||
// Measured code
|
||||
})
|
||||
}, gmeasure.SamplingConfig{N: 100})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Basic Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
go test
|
||||
|
||||
# Verbose output
|
||||
go test -v
|
||||
|
||||
# Specific package
|
||||
go test -v ./...
|
||||
|
||||
# With timeout
|
||||
go test -timeout=10m
|
||||
```
|
||||
|
||||
### Coverage Analysis
|
||||
|
||||
```bash
|
||||
# Basic coverage
|
||||
go test -cover
|
||||
|
||||
# Detailed coverage
|
||||
go test -coverprofile=coverage.out -covermode=atomic
|
||||
|
||||
# HTML coverage report
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
# Coverage by function
|
||||
go tool cover -func=coverage.out
|
||||
```
|
||||
|
||||
### Race Detection
|
||||
|
||||
```bash
|
||||
# Enable race detector (requires CGO)
|
||||
CGO_ENABLED=1 go test -race
|
||||
|
||||
# Race detection with coverage
|
||||
CGO_ENABLED=1 go test -race -cover -covermode=atomic
|
||||
|
||||
# Verbose race detection
|
||||
CGO_ENABLED=1 go test -race -v
|
||||
|
||||
# Full test suite with race detector
|
||||
CGO_ENABLED=1 go test -race -timeout=10m -v -cover -covermode=atomic ./...
|
||||
```
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
```bash
|
||||
# Run tests in parallel
|
||||
go test -parallel=4
|
||||
|
||||
# Control parallelism
|
||||
go test -p=8
|
||||
```
|
||||
|
||||
### Profiling
|
||||
|
||||
```bash
|
||||
# CPU profiling
|
||||
go test -cpuprofile=cpu.prof
|
||||
|
||||
# Memory profiling
|
||||
go test -memprofile=mem.prof
|
||||
|
||||
# Analyze profiles
|
||||
go tool pprof cpu.prof
|
||||
go tool pprof mem.prof
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Overall Metrics
|
||||
|
||||
| Metric | Value | Command |
|
||||
|--------|-------|---------|
|
||||
| **Total Tests** | 229 | `go test` |
|
||||
| **Test Coverage** | 82.6% | `go test -cover` |
|
||||
| **Race Conditions** | 0 | `go test -race` |
|
||||
| **Duration (Standard)** | ~4.7s | `go test` |
|
||||
| **Duration (Race)** | ~6.6s | `go test -race` |
|
||||
| **Example Tests** | 17 | `go test -run Example` |
|
||||
|
||||
### Test Categories
|
||||
|
||||
| Category | Tests | Coverage | Description |
|
||||
|----------|-------|----------|-------------|
|
||||
| **Path Security** | 45 | 88% | Path traversal, dot files, patterns |
|
||||
| **Rate Limiting** | 38 | 92% | IP tracking, sliding window, cleanup |
|
||||
| **HTTP Headers** | 32 | 85% | ETag, cache control, MIME types |
|
||||
| **Security Backend** | 28 | 79% | Webhooks, CEF, batch processing |
|
||||
| **Suspicious Detection** | 24 | 81% | Pattern matching, logging |
|
||||
| **File Operations** | 22 | 88% | Has, Find, Info, List, Map |
|
||||
| **Router Integration** | 18 | 76% | Gin integration, routes |
|
||||
| **Concurrency** | 12 | 95% | Concurrent access, race conditions |
|
||||
| **Benchmarks** | 10 | - | Performance measurements |
|
||||
|
||||
### Files Coverage
|
||||
|
||||
```
|
||||
File | Coverage | Lines | Covered | Notes
|
||||
=================================================================================
|
||||
config.go | 95.2% | 105 | 100 | Configuration types
|
||||
interface.go | 88.4% | 190 | 168 | Interface definitions
|
||||
error.go | 100.0% | 42 | 42 | Error codes
|
||||
model.go | 85.7% | 35 | 30 | Core model
|
||||
security.go | 82.1% | 156 | 128 | Security backend
|
||||
ratelimit.go | 91.8% | 122 | 112 | Rate limiting
|
||||
pathsecurity.go | 88.9% | 72 | 64 | Path validation
|
||||
headers.go | 84.6% | 104 | 88 | HTTP headers
|
||||
suspicious.go | 81.2% | 85 | 69 | Suspicious detection
|
||||
route.go | 76.3% | 127 | 97 | Main HTTP handler
|
||||
pathfile.go | 88.2% | 110 | 97 | File operations
|
||||
index.go | 90.5% | 42 | 38 | Index files
|
||||
download.go | 100.0% | 15 | 15 | Download config
|
||||
follow.go | 92.3% | 26 | 24 | Redirects
|
||||
specific.go | 88.9% | 18 | 16 | Custom handlers
|
||||
router.go | 100.0% | 18 | 18 | Router helpers
|
||||
monitor.go | 85.0% | 60 | 51 | Health monitoring
|
||||
=================================================================================
|
||||
TOTAL | 82.6% | 1,327 | 1,097 |
|
||||
```
|
||||
|
||||
### By Component
|
||||
|
||||
#### Path Security (88%)
|
||||
|
||||
**Covered:**
|
||||
- Path traversal detection
|
||||
- Null byte injection prevention
|
||||
- Dot file blocking
|
||||
- Max depth validation
|
||||
- Pattern blocking
|
||||
- Double slash detection
|
||||
|
||||
**Not Covered:**
|
||||
- Edge cases with Unicode characters
|
||||
- Some error logging paths
|
||||
|
||||
**How to Improve:**
|
||||
```go
|
||||
It("should handle unicode in paths", func() {
|
||||
handler.SetPathSecurity(DefaultPathSecurityConfig())
|
||||
safe := handler.IsPathSafe("/files/文件.txt")
|
||||
Expect(safe).To(BeTrue())
|
||||
})
|
||||
```
|
||||
|
||||
#### Rate Limiting (92%)
|
||||
|
||||
**Covered:**
|
||||
- IP tracking and counting
|
||||
- Sliding window calculation
|
||||
- Whitelist handling
|
||||
- Cleanup goroutine
|
||||
- Concurrent access
|
||||
- Header generation
|
||||
|
||||
**Not Covered:**
|
||||
- Some cleanup edge cases
|
||||
- Context cancellation timeout paths
|
||||
|
||||
**How to Improve:**
|
||||
```go
|
||||
It("should cleanup on context cancel", func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
handler := New(ctx, fs, "data")
|
||||
handler.SetRateLimit(config)
|
||||
cancel()
|
||||
// Verify cleanup
|
||||
})
|
||||
```
|
||||
|
||||
#### HTTP Headers (85%)
|
||||
|
||||
**Covered:**
|
||||
- ETag generation and validation
|
||||
- Cache-Control headers
|
||||
- MIME type detection
|
||||
- Whitelist/blacklist filtering
|
||||
- 304 Not Modified responses
|
||||
- Custom MIME types
|
||||
|
||||
**Not Covered:**
|
||||
- Some error paths in webhook sending
|
||||
- Edge cases in MIME detection
|
||||
|
||||
#### Security Backend (79%)
|
||||
|
||||
**Covered:**
|
||||
- Webhook sending (JSON/CEF)
|
||||
- Batch processing
|
||||
- Severity filtering
|
||||
- Async execution
|
||||
- Event creation
|
||||
|
||||
**Not Covered:**
|
||||
- Some webhook error scenarios
|
||||
- Callback edge cases
|
||||
|
||||
**Improvement Priority:**
|
||||
1. Add webhook failure scenarios
|
||||
2. Test callback with nil checks
|
||||
3. Add timeout scenarios
|
||||
|
||||
---
|
||||
|
||||
## Thread Safety
|
||||
|
||||
### Verification Methods
|
||||
|
||||
#### Race Detector
|
||||
|
||||
The test suite runs with `-race` flag to detect data races:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=1 go test -race -count=10
|
||||
```
|
||||
|
||||
**Results:** ✅ Zero races detected across 229 tests
|
||||
|
||||
#### Concurrency Tests
|
||||
|
||||
Dedicated concurrency tests verify thread safety:
|
||||
|
||||
```go
|
||||
It("should handle concurrent requests safely", func() {
|
||||
var wg sync.WaitGroup
|
||||
errors := make([]error, 100)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
defer GinkgoRecover()
|
||||
|
||||
// Concurrent operations
|
||||
handler.SetRateLimit(config)
|
||||
handler.SetPathSecurity(config)
|
||||
errors[idx] = handler.validatePath("/test")
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Verify no errors
|
||||
})
|
||||
```
|
||||
|
||||
### Atomic Primitives
|
||||
|
||||
All shared state uses atomic operations:
|
||||
|
||||
```go
|
||||
// Configuration (atomic.Value)
|
||||
type staticHandler struct {
|
||||
rlc libatm.Value[*RateLimitConfig]
|
||||
psc libatm.Value[*PathSecurityConfig]
|
||||
hdr libatm.Value[*HeadersConfig]
|
||||
sec libatm.Value[*SecurityConfig]
|
||||
sus libatm.Value[*SuspiciousConfig]
|
||||
}
|
||||
|
||||
// IP tracking (atomic.Map)
|
||||
rli libatm.MapTyped[string, *ipTrack]
|
||||
|
||||
// Counters (atomic.Int64, atomic.Uint64)
|
||||
siz *atomic.Int64
|
||||
seq *atomic.Uint64
|
||||
```
|
||||
|
||||
### No Mutexes Required
|
||||
|
||||
The design uses **lock-free concurrency**:
|
||||
|
||||
- ✅ Atomic operations for all shared state
|
||||
- ✅ Immutable configuration after set
|
||||
- ✅ Read-only embedded filesystem
|
||||
- ✅ Context-based configuration (libctx.Config)
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks
|
||||
|
||||
### Performance Measurements
|
||||
|
||||
#### File Operations
|
||||
|
||||
```
|
||||
Name | N | Min | Median | Mean | StdDev | Max
|
||||
============================================================================
|
||||
File-Has [duration] | 100 | 0s | 0s | 0s | 0s | 100µs
|
||||
File-Info [duration] | 100 | 0s | 0s | 0s | 0s | 100µs
|
||||
File-Find [duration] | 100 | 0s | 0s | 0s | 0s | 200µs
|
||||
List-AllFiles [duration] | 10 | 400µs | 500µs | 500µs | 200µs | 1ms
|
||||
```
|
||||
|
||||
**Analysis:**
|
||||
- Has/Info/Find: Sub-microsecond for cached lookups
|
||||
- List: ~500µs for 10+ files
|
||||
- Memory: O(1) per operation
|
||||
|
||||
#### Security Operations
|
||||
|
||||
```
|
||||
Name | N | Min | Median | Mean | StdDev | Max
|
||||
============================================================================
|
||||
PathSecurity [duration] | 100 | 0s | 0s | 0s | 0s | 100µs
|
||||
RateLimit-Allow [duration] | 100 | 0s | 0s | 0s | 0s | 200µs
|
||||
RateLimit-Block [duration] | 10 | 0s | 0s | 0s | 0s | 100µs
|
||||
```
|
||||
|
||||
**Analysis:**
|
||||
- Path validation: <100µs typical
|
||||
- Rate limit check: <200µs typical
|
||||
- Blocking decision: <100µs
|
||||
|
||||
#### HTTP Operations
|
||||
|
||||
```
|
||||
Name | N | Min | Median | Mean | StdDev | Max
|
||||
============================================================================
|
||||
ETag-Generate [duration] | 100 | 0s | 0s | 0s | 0s | 100µs
|
||||
ETag-Validate [duration] | 100 | 0s | 0s | 0s | 0s | 0s
|
||||
Redirect [duration] | 500 | 100µs | 100µs | 200µs | 100µs | 1.6ms
|
||||
```
|
||||
|
||||
**Analysis:**
|
||||
- ETag generation: Sub-microsecond (SHA-256 truncated)
|
||||
- ETag validation: Near-instant string comparison
|
||||
- Redirects: ~100-200µs typical
|
||||
|
||||
#### Throughput
|
||||
|
||||
```
|
||||
Name | N | Min | Median | Mean | StdDev | Max
|
||||
====================================================================
|
||||
Throughput-RPS | 1 | 1,938 | 5,692 | 3,815 | varies | 5,692
|
||||
```
|
||||
|
||||
**Analysis:**
|
||||
- Single file serving: 1,900-5,600 RPS
|
||||
- Variation due to caching and system load
|
||||
- No rate limiting in benchmark scenario
|
||||
|
||||
### Running Benchmarks
|
||||
|
||||
```bash
|
||||
# All benchmarks
|
||||
go test -bench=. -benchmem
|
||||
|
||||
# Specific benchmark
|
||||
go test -bench=BenchmarkFileOperations
|
||||
|
||||
# With CPU profiling
|
||||
go test -bench=. -cpuprofile=cpu.prof
|
||||
|
||||
# Memory allocations
|
||||
go test -bench=. -benchmem -memprofile=mem.prof
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Test Structure Template
|
||||
|
||||
```go
|
||||
var _ = Describe("Feature Name", func() {
|
||||
var (
|
||||
handler static.Static
|
||||
engine *gin.Engine
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
defer GinkgoRecover()
|
||||
handler = newTestStatic()
|
||||
engine = setupTestRouter(handler, "/static")
|
||||
})
|
||||
|
||||
Context("when condition", func() {
|
||||
It("should behave correctly", func() {
|
||||
// Arrange
|
||||
config := static.DefaultConfig()
|
||||
|
||||
// Act
|
||||
handler.SetConfig(config)
|
||||
result := performOperation()
|
||||
|
||||
// Assert
|
||||
Expect(result).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Assertions
|
||||
|
||||
```go
|
||||
// Basic assertions
|
||||
Expect(value).To(Equal(expected))
|
||||
Expect(value).NotTo(BeNil())
|
||||
Expect(value).To(BeTrue())
|
||||
|
||||
// Numeric comparisons
|
||||
Expect(count).To(BeNumerically(">", 0))
|
||||
Expect(duration).To(BeNumerically("~", expected, threshold))
|
||||
|
||||
// Strings
|
||||
Expect(str).To(ContainSubstring("text"))
|
||||
Expect(str).To(HavePrefix("prefix"))
|
||||
|
||||
// Errors
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(err).To(MatchError("expected error"))
|
||||
|
||||
// HTTP responses
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("content"))
|
||||
Expect(w.Header().Get("ETag")).NotTo(BeEmpty())
|
||||
```
|
||||
|
||||
### Test Helpers
|
||||
|
||||
```go
|
||||
// Create test handler
|
||||
func newTestStatic() interface{} {
|
||||
return static.New(context.Background(), testContent, "testdata")
|
||||
}
|
||||
|
||||
// Setup Gin router
|
||||
func setupTestRouter(handler static.Static, path string) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler.RegisterRouter(path, router.GET)
|
||||
return router
|
||||
}
|
||||
|
||||
// Perform HTTP request
|
||||
func performRequest(engine *gin.Engine, method, path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// With custom headers
|
||||
func performRequestWithHeaders(engine *gin.Engine, method, path string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
```
|
||||
|
||||
### Benchmark Template
|
||||
|
||||
```go
|
||||
var _ = Describe("Benchmarks", func() {
|
||||
var experiment *gmeasure.Experiment
|
||||
|
||||
BeforeEach(func() {
|
||||
experiment = gmeasure.NewExperiment("Operation Name")
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
})
|
||||
|
||||
It("should benchmark operation", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("duration", func() {
|
||||
// Measured operation
|
||||
_ = handler.Has("test.txt")
|
||||
})
|
||||
}, gmeasure.SamplingConfig{
|
||||
N: 100,
|
||||
Duration: time.Second,
|
||||
NumParallel: 0,
|
||||
})
|
||||
|
||||
stats := experiment.GetStats("duration")
|
||||
Expect(stats.DurationFor(gmeasure.StatMedian)).To(
|
||||
BeNumerically("<", 100*time.Microsecond),
|
||||
)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Testing Guidelines
|
||||
|
||||
#### ✅ DO
|
||||
|
||||
```go
|
||||
// Use descriptive test names
|
||||
It("should return 404 for non-existent files", func() { ... })
|
||||
|
||||
// Use BeforeEach for setup
|
||||
BeforeEach(func() {
|
||||
handler = newTestStatic()
|
||||
})
|
||||
|
||||
// Use GinkgoRecover
|
||||
BeforeEach(func() {
|
||||
defer GinkgoRecover()
|
||||
})
|
||||
|
||||
// Test edge cases
|
||||
It("should handle nil configuration", func() { ... })
|
||||
It("should handle empty paths", func() { ... })
|
||||
|
||||
// Verify error conditions
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).To(MatchError(ContainSubstring("expected")))
|
||||
|
||||
// Use table-driven tests for variations
|
||||
DescribeTable("path validation",
|
||||
func(path string, expected bool) {
|
||||
result := handler.IsPathSafe(path)
|
||||
Expect(result).To(Equal(expected))
|
||||
},
|
||||
Entry("valid path", "/file.txt", true),
|
||||
Entry("traversal", "/../etc/passwd", false),
|
||||
)
|
||||
```
|
||||
|
||||
#### ❌ DON'T
|
||||
|
||||
```go
|
||||
// Don't use hardcoded timeouts
|
||||
time.Sleep(100 * time.Millisecond) // ❌ Flaky
|
||||
|
||||
// Don't ignore errors
|
||||
_ = handler.SetConfig(config) // ❌
|
||||
|
||||
// Don't test implementation details
|
||||
Expect(handler.(*staticHandler).rlc).NotTo(BeNil()) // ❌
|
||||
|
||||
// Don't duplicate test code
|
||||
// ❌ Copy-paste test setup instead of using helpers
|
||||
|
||||
// Don't skip race detector
|
||||
// ❌ Only run: go test
|
||||
```
|
||||
|
||||
### Coverage Goals
|
||||
|
||||
- **Minimum:** 80% overall coverage
|
||||
- **Critical paths:** 90%+ (security, rate limiting)
|
||||
- **Error handling:** All error paths tested
|
||||
- **Edge cases:** Null, empty, invalid inputs
|
||||
|
||||
### Test Organization
|
||||
|
||||
```
|
||||
static/
|
||||
├── *_test.go # Component tests
|
||||
├── benchmark_test.go # Performance tests
|
||||
├── concurrency_test.go # Race condition tests
|
||||
├── example_test.go # Documentation examples
|
||||
└── testdata/ # Test fixtures
|
||||
├── test.txt
|
||||
└── subdir/
|
||||
└── nested.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Test Failures
|
||||
|
||||
**Problem:** Tests fail intermittently
|
||||
|
||||
```bash
|
||||
# Solution: Run with race detector
|
||||
CGO_ENABLED=1 go test -race -count=10
|
||||
```
|
||||
|
||||
**Problem:** Coverage report not generated
|
||||
|
||||
```bash
|
||||
# Solution: Ensure correct flags
|
||||
go test -coverprofile=coverage.out -covermode=atomic
|
||||
```
|
||||
|
||||
#### Race Conditions
|
||||
|
||||
**Problem:** Race detector reports data races
|
||||
|
||||
```bash
|
||||
# Solution: Check atomic usage
|
||||
# All shared state must use atomic operations or locks
|
||||
```
|
||||
|
||||
**Example Fix:**
|
||||
|
||||
```go
|
||||
// ❌ Bad: Direct access
|
||||
func (s *staticHandler) getConfig() *Config {
|
||||
return s.config // Race condition!
|
||||
}
|
||||
|
||||
// ✅ Good: Atomic access
|
||||
func (s *staticHandler) getConfig() *Config {
|
||||
return s.cfg.Load()
|
||||
}
|
||||
```
|
||||
|
||||
#### Benchmark Failures
|
||||
|
||||
**Problem:** Benchmark results inconsistent
|
||||
|
||||
```bash
|
||||
# Solution: Increase sample size
|
||||
go test -bench=. -benchtime=10s
|
||||
```
|
||||
|
||||
**Problem:** Memory allocations too high
|
||||
|
||||
```bash
|
||||
# Solution: Profile memory
|
||||
go test -bench=. -benchmem -memprofile=mem.prof
|
||||
go tool pprof mem.prof
|
||||
```
|
||||
|
||||
### Debug Tips
|
||||
|
||||
```bash
|
||||
# Verbose test output
|
||||
go test -v
|
||||
|
||||
# Run specific test
|
||||
go test -v -run TestName
|
||||
|
||||
# Show test names without running
|
||||
go test -list=.
|
||||
|
||||
# Increase timeout for slow tests
|
||||
go test -timeout=30m
|
||||
|
||||
# Disable test caching
|
||||
go test -count=1
|
||||
|
||||
# Enable more detailed race detection
|
||||
GORACE="log_path=race.log halt_on_error=1" CGO_ENABLED=1 go test -race
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: ['1.21', '1.22', '1.23']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -cover -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Run race detector
|
||||
run: CGO_ENABLED=1 go test -race ./...
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./coverage.out
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
|
||||
```yaml
|
||||
stages:
|
||||
- test
|
||||
- coverage
|
||||
|
||||
test:
|
||||
stage: test
|
||||
image: golang:1.21
|
||||
script:
|
||||
- go test -v ./...
|
||||
- CGO_ENABLED=1 go test -race ./...
|
||||
|
||||
coverage:
|
||||
stage: coverage
|
||||
image: golang:1.21
|
||||
script:
|
||||
- go test -cover -coverprofile=coverage.out ./...
|
||||
- go tool cover -func=coverage.out
|
||||
artifacts:
|
||||
paths:
|
||||
- coverage.out
|
||||
```
|
||||
|
||||
### Pre-commit Hook
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# .git/hooks/pre-commit
|
||||
|
||||
echo "Running tests..."
|
||||
go test ./... || exit 1
|
||||
|
||||
echo "Running race detector..."
|
||||
CGO_ENABLED=1 go test -race ./... || exit 1
|
||||
|
||||
echo "Checking coverage..."
|
||||
COVERAGE=$(go test -cover ./... | grep coverage | awk '{print $5}' | sed 's/%//')
|
||||
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
|
||||
echo "Coverage $COVERAGE% is below 80%"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All checks passed!"
|
||||
```
|
||||
|
||||
### Makefile Targets
|
||||
|
||||
```makefile
|
||||
.PHONY: test test-race test-cover test-bench
|
||||
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
test-race:
|
||||
CGO_ENABLED=1 go test -race -v ./...
|
||||
|
||||
test-cover:
|
||||
go test -cover -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
test-bench:
|
||||
go test -bench=. -benchmem ./...
|
||||
|
||||
test-all: test test-race test-cover
|
||||
@echo "All tests passed!"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
### Key Metrics
|
||||
|
||||
- ✅ **229 tests** covering all major functionality
|
||||
- ✅ **82.6% coverage** exceeding 80% threshold
|
||||
- ✅ **0 race conditions** verified with `-race` detector
|
||||
- ✅ **~4.7s** test execution time (standard)
|
||||
- ✅ **~6.6s** test execution time (with race detector)
|
||||
- ✅ **17 examples** for documentation
|
||||
|
||||
### Quality Assurance
|
||||
|
||||
- **Ginkgo/Gomega** for BDD-style testing
|
||||
- **Gmeasure** for performance benchmarking
|
||||
- **Race detector** for concurrency verification
|
||||
- **Comprehensive coverage** of security features
|
||||
- **CI/CD ready** with automation examples
|
||||
|
||||
### Continuous Improvement
|
||||
|
||||
- Maintain >80% coverage
|
||||
- Add tests for new features
|
||||
- Benchmark performance-critical paths
|
||||
- Verify thread safety with race detector
|
||||
- Update documentation with examples
|
||||
|
||||
---
|
||||
|
||||
**For questions or issues, please open an issue on GitHub.**
|
||||
405
static/benchmark_test.go
Normal file
405
static/benchmark_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nabbar/golib/static"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
. "github.com/onsi/gomega/gmeasure"
|
||||
)
|
||||
|
||||
var _ = Describe("Benchmarks", func() {
|
||||
var experiment *Experiment
|
||||
|
||||
BeforeEach(func() {
|
||||
experiment = NewExperiment("Static File Operations")
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if experiment != nil {
|
||||
AddReportEntry(experiment.Name, experiment)
|
||||
}
|
||||
})
|
||||
|
||||
Describe("File Access Performance", func() {
|
||||
Context("when accessing files", func() {
|
||||
It("should benchmark Has operation", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(interface{ Has(string) bool })
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("Has-Existing", func() {
|
||||
_ = h.Has("testdata/test.txt")
|
||||
})
|
||||
|
||||
experiment.MeasureDuration("Has-NonExisting", func() {
|
||||
_ = h.Has("testdata/nonexistent.txt")
|
||||
})
|
||||
}, SamplingConfig{N: 1000, Duration: 3 * time.Second})
|
||||
})
|
||||
|
||||
It("should benchmark Find operation", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticFind)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("Find-SmallFile", func() {
|
||||
r, err := h.Find("testdata/test.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
if r != nil {
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
_ = r.Close()
|
||||
}
|
||||
})
|
||||
}, SamplingConfig{N: 500, Duration: 3 * time.Second})
|
||||
})
|
||||
|
||||
It("should benchmark Info operation", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticInfo)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("Info", func() {
|
||||
_, err := h.Info("testdata/test.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
}, SamplingConfig{N: 1000, Duration: 3 * time.Second})
|
||||
})
|
||||
|
||||
It("should benchmark List operation", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticList)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("List", func() {
|
||||
_, err := h.List("testdata")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
}, SamplingConfig{N: 500, Duration: 3 * time.Second})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("HTTP Handler Performance", func() {
|
||||
Context("when serving files via HTTP", func() {
|
||||
It("should benchmark file serving", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("ServeFile-TXT", func() {
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
experiment.MeasureDuration("ServeFile-JSON", func() {
|
||||
w := performRequest(engine, "GET", "/static/test.json")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
experiment.MeasureDuration("ServeFile-HTML", func() {
|
||||
w := performRequest(engine, "GET", "/static/index.html")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
experiment.MeasureDuration("ServeFile-NotFound", func() {
|
||||
w := performRequest(engine, "GET", "/static/nonexistent.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
}, SamplingConfig{N: 500, Duration: 3 * time.Second})
|
||||
})
|
||||
|
||||
It("should benchmark nested file serving", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("ServeFile-Nested", func() {
|
||||
w := performRequest(engine, "GET", "/static/subdir/nested.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
}, SamplingConfig{N: 500, Duration: 3 * time.Second})
|
||||
})
|
||||
})
|
||||
|
||||
Context("when using middleware", func() {
|
||||
It("should benchmark with middleware", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static", testMiddleware)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("ServeFile-WithMiddleware", func() {
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
}, SamplingConfig{N: 500, Duration: 3 * time.Second})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Configuration Performance", func() {
|
||||
Context("when managing configurations", func() {
|
||||
It("should benchmark SetDownload and IsDownload", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("SetDownload", func() {
|
||||
handler.SetDownload("testdata/test.txt", true)
|
||||
})
|
||||
|
||||
experiment.MeasureDuration("IsDownload", func() {
|
||||
_ = handler.IsDownload("testdata/test.txt")
|
||||
})
|
||||
}, SamplingConfig{N: 1000, Duration: 3 * time.Second})
|
||||
})
|
||||
|
||||
It("should benchmark SetIndex and GetIndex", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("SetIndex", func() {
|
||||
handler.SetIndex("", "/", "testdata/index.html")
|
||||
})
|
||||
|
||||
experiment.MeasureDuration("GetIndex", func() {
|
||||
_ = handler.GetIndex("", "/")
|
||||
})
|
||||
|
||||
experiment.MeasureDuration("IsIndex", func() {
|
||||
_ = handler.IsIndex("testdata/index.html")
|
||||
})
|
||||
}, SamplingConfig{N: 1000, Duration: 3 * time.Second})
|
||||
})
|
||||
|
||||
It("should benchmark SetRedirect and GetRedirect", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("SetRedirect", func() {
|
||||
handler.SetRedirect("", "/old", "", "/new")
|
||||
})
|
||||
|
||||
experiment.MeasureDuration("GetRedirect", func() {
|
||||
_ = handler.GetRedirect("", "/old")
|
||||
})
|
||||
|
||||
experiment.MeasureDuration("IsRedirect", func() {
|
||||
_ = handler.IsRedirect("", "/old")
|
||||
})
|
||||
}, SamplingConfig{N: 1000, Duration: 3 * time.Second})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Concurrent Access Performance", func() {
|
||||
Context("when accessing concurrently", func() {
|
||||
It("should benchmark concurrent file serving", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("Concurrent-10", func() {
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
}, SamplingConfig{N: 100, Duration: 3 * time.Second})
|
||||
})
|
||||
|
||||
It("should benchmark concurrent configuration access", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("Concurrent-Config", func() {
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
handler.SetDownload("testdata/test.txt", true)
|
||||
_ = handler.IsDownload("testdata/test.txt")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
})
|
||||
}, SamplingConfig{N: 100, Duration: 3 * time.Second})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Memory Efficiency", func() {
|
||||
Context("when allocating memory", func() {
|
||||
It("should measure memory for file operations", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticFind)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
r, err := h.Find("testdata/test.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
if r != nil {
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
_ = r.Close()
|
||||
}
|
||||
}, SamplingConfig{N: 100, Duration: 2 * time.Second})
|
||||
|
||||
// Memory measurements are recorded automatically by gmeasure
|
||||
})
|
||||
|
||||
It("should measure memory for HTTP serving", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
}, SamplingConfig{N: 100, Duration: 2 * time.Second})
|
||||
|
||||
// Memory measurements are recorded automatically by gmeasure
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Large File Handling", func() {
|
||||
Context("when handling large files", func() {
|
||||
It("should benchmark large file operations", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
h := handler.(staticFind)
|
||||
|
||||
// Set threshold to force temp file usage
|
||||
handler.UseTempForFileSize(10)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("LargeFile-Find", func() {
|
||||
r, err := h.Find("testdata/large.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
if r != nil {
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
_ = r.Close()
|
||||
// Clean up temp file if created
|
||||
if namer, ok := r.(interface{ Name() string }); ok {
|
||||
_ = os.Remove(namer.Name())
|
||||
}
|
||||
}
|
||||
})
|
||||
}, SamplingConfig{N: 100, Duration: 2 * time.Second})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Map Operation Performance", func() {
|
||||
Context("when mapping over files", func() {
|
||||
It("should benchmark Map operation", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticMap)
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("Map-AllFiles", func() {
|
||||
count := 0
|
||||
err := h.Map(func(pathFile string, inf os.FileInfo) error {
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically(">", 0))
|
||||
})
|
||||
}, SamplingConfig{N: 100, Duration: 2 * time.Second})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Redirect and Special Handlers Performance", func() {
|
||||
Context("when using redirect and special handlers", func() {
|
||||
It("should benchmark redirect performance", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetRedirect("", "/static/old", "", "/static/test.txt")
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("Redirect", func() {
|
||||
w := performRequest(engine, "GET", "/static/old")
|
||||
Expect(w.Code).To(Equal(http.StatusPermanentRedirect))
|
||||
})
|
||||
}, SamplingConfig{N: 500, Duration: 3 * time.Second})
|
||||
})
|
||||
|
||||
It("should benchmark specific handler performance", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSpecific("", "/static/custom", customMiddlewareOK("custom", nil))
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
experiment.MeasureDuration("SpecificHandler", func() {
|
||||
w := performRequest(engine, "GET", "/static/custom")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
}, SamplingConfig{N: 500, Duration: 3 * time.Second})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Throughput Measurements", func() {
|
||||
Context("when measuring throughput", func() {
|
||||
It("should measure requests per second", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
requestCount := 0
|
||||
startTime := time.Now()
|
||||
|
||||
experiment.Sample(func(idx int) {
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
requestCount++
|
||||
}, SamplingConfig{N: 1000, Duration: 5 * time.Second})
|
||||
|
||||
duration := time.Since(startTime)
|
||||
throughput := float64(requestCount) / duration.Seconds()
|
||||
|
||||
// Record throughput as a value
|
||||
experiment.RecordValue("Throughput-RPS", throughput)
|
||||
|
||||
// Expect reasonable throughput (at least 100 req/spc)
|
||||
Expect(throughput).To(BeNumerically(">", 100))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
680
static/concurrency_test.go
Normal file
680
static/concurrency_test.go
Normal file
@@ -0,0 +1,680 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/nabbar/golib/static"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Concurrency", func() {
|
||||
Describe("Concurrent File Access", func() {
|
||||
Context("when accessing files concurrently", func() {
|
||||
It("should handle concurrent Has calls", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(interface{ Has(string) bool })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
if !h.Has("testdata/test.txt") {
|
||||
errCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(errCount.Load()).To(Equal(int32(0)))
|
||||
})
|
||||
|
||||
It("should handle concurrent Find calls", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticFind)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
r, err := h.Find("testdata/test.txt")
|
||||
if err == nil && r != nil {
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
_ = r.Close()
|
||||
successCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("should handle concurrent Info calls", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticInfo)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
info, err := h.Info("testdata/test.txt")
|
||||
if err == nil && info != nil {
|
||||
successCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("should handle concurrent List calls", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticList)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
files, err := h.List("testdata")
|
||||
if err == nil && len(files) > 0 {
|
||||
successCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("should handle concurrent Temp calls", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticTemp)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
tmp, err := h.Temp("testdata/test.txt")
|
||||
if err == nil && tmp != nil {
|
||||
_, _ = io.Copy(io.Discard, tmp)
|
||||
_ = tmp.Close()
|
||||
if namer, ok := tmp.(interface{ Name() string }); ok {
|
||||
_ = os.Remove(namer.Name())
|
||||
}
|
||||
successCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(20)))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Concurrent Configuration Access", func() {
|
||||
Context("when modifying configurations concurrently", func() {
|
||||
It("should handle concurrent SetDownload and IsDownload", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
if idx%2 == 0 {
|
||||
handler.SetDownload("testdata/test.txt", true)
|
||||
} else {
|
||||
_ = handler.IsDownload("testdata/test.txt")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Should not panic or race
|
||||
})
|
||||
|
||||
It("should handle concurrent SetIndex and GetIndex", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
if idx%2 == 0 {
|
||||
handler.SetIndex("", "/", "testdata/index.html")
|
||||
} else {
|
||||
_ = handler.GetIndex("", "/")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Should not panic or race
|
||||
})
|
||||
|
||||
It("should handle concurrent SetRedirect and GetRedirect", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
if idx%2 == 0 {
|
||||
handler.SetRedirect("", "/old", "", "/new")
|
||||
} else {
|
||||
_ = handler.GetRedirect("", "/old")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Should not panic or race
|
||||
})
|
||||
|
||||
It("should handle concurrent SetSpecific and GetSpecific", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
if idx%2 == 0 {
|
||||
handler.SetSpecific("", "/custom", customMiddlewareOK("custom", nil))
|
||||
} else {
|
||||
_ = handler.GetSpecific("", "/custom")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Should not panic or race
|
||||
})
|
||||
})
|
||||
|
||||
Context("when mixing different configurations", func() {
|
||||
It("should handle mixed concurrent operations", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
switch idx % 4 {
|
||||
case 0:
|
||||
handler.SetDownload("testdata/test.txt", true)
|
||||
_ = handler.IsDownload("testdata/test.txt")
|
||||
case 1:
|
||||
handler.SetIndex("", "/", "testdata/index.html")
|
||||
_ = handler.GetIndex("", "/")
|
||||
case 2:
|
||||
handler.SetRedirect("", "/old", "", "/new")
|
||||
_ = handler.GetRedirect("", "/old")
|
||||
case 3:
|
||||
handler.SetSpecific("", "/custom", customMiddlewareOK("custom", nil))
|
||||
_ = handler.GetSpecific("", "/custom")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Should not panic or race
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Concurrent HTTP Requests", func() {
|
||||
Context("when serving files concurrently", func() {
|
||||
It("should handle concurrent HTTP requests", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
if w.Code == http.StatusOK {
|
||||
successCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(100)))
|
||||
})
|
||||
|
||||
It("should handle concurrent requests for different files", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
|
||||
files := []string{
|
||||
"/static/test.txt",
|
||||
"/static/test.json",
|
||||
"/static/index.html",
|
||||
"/static/subdir/nested.txt",
|
||||
"/static/assets/style.css",
|
||||
}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
file := files[idx%len(files)]
|
||||
w := performRequest(engine, "GET", file)
|
||||
if w.Code == http.StatusOK {
|
||||
successCount.Add(1)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("should handle concurrent requests with middleware", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static", testMiddleware)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
if w.Code == http.StatusOK && w.Header().Get("X-Test-Middleware") == "true" {
|
||||
successCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(50)))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when serving with special configurations", func() {
|
||||
It("should handle concurrent requests with redirects", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetRedirect("", "/static/old", "", "/static/test.txt")
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
redirectCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
w := performRequest(engine, "GET", "/static/old")
|
||||
if w.Code == http.StatusPermanentRedirect {
|
||||
redirectCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(redirectCount.Load()).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("should handle concurrent requests with specific handlers", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
callCount := atomic.Int32{}
|
||||
handler.SetSpecific("", "/static/custom", customMiddlewareOK("custom", func() {
|
||||
callCount.Add(1)
|
||||
}))
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
w := performRequest(engine, "GET", "/static/custom")
|
||||
if w.Code == http.StatusOK {
|
||||
successCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(50)))
|
||||
Expect(callCount.Load()).To(Equal(int32(50)))
|
||||
})
|
||||
|
||||
It("should handle concurrent requests with download flag", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetDownload("testdata/test.txt", true)
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
downloadCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
if w.Code == http.StatusOK && w.Header().Get("Content-Disposition") != "" {
|
||||
downloadCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(downloadCount.Load()).To(Equal(int32(50)))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Concurrent Map Operations", func() {
|
||||
Context("when mapping over files concurrently", func() {
|
||||
It("should handle concurrent Map calls", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticMap)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
err := h.Map(func(pathFile string, inf os.FileInfo) error {
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
successCount.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(20)))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Stress Testing", func() {
|
||||
Context("when under heavy load", func() {
|
||||
It("should handle high concurrent file access", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticFind)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
iterations := 200
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
files := []string{
|
||||
"testdata/test.txt",
|
||||
"testdata/test.json",
|
||||
"testdata/index.html",
|
||||
"testdata/large.txt",
|
||||
}
|
||||
file := files[idx%len(files)]
|
||||
|
||||
r, err := h.Find(file)
|
||||
if err == nil && r != nil {
|
||||
_, _ = io.Copy(io.Discard, r)
|
||||
_ = r.Close()
|
||||
successCount.Add(1)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(iterations)))
|
||||
})
|
||||
|
||||
It("should handle high concurrent HTTP requests", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := atomic.Int32{}
|
||||
iterations := 200
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
files := []string{
|
||||
"/static/test.txt",
|
||||
"/static/test.json",
|
||||
"/static/index.html",
|
||||
"/static/subdir/nested.txt",
|
||||
}
|
||||
file := files[idx%len(files)]
|
||||
|
||||
w := performRequest(engine, "GET", file)
|
||||
if w.Code == http.StatusOK {
|
||||
successCount.Add(1)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
Expect(successCount.Load()).To(Equal(int32(iterations)))
|
||||
})
|
||||
|
||||
It("should handle mixed concurrent operations under stress", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
iterations := 200
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
|
||||
switch idx % 5 {
|
||||
case 0:
|
||||
handler.SetDownload("testdata/test.txt", idx%2 == 0)
|
||||
case 1:
|
||||
handler.SetIndex("", "/", "testdata/index.html")
|
||||
case 2:
|
||||
handler.SetRedirect("", "/old", "", "/new")
|
||||
case 3:
|
||||
_ = handler.Has("testdata/test.txt")
|
||||
case 4:
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
_ = w.Code
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Should complete without panic or race
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Race Condition Prevention", func() {
|
||||
Context("when testing for race conditions", func() {
|
||||
It("should not race on configuration updates", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
handler.SetDownload("testdata/test.txt", true)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
_ = handler.IsDownload("testdata/test.txt")
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Should complete without race detector warnings
|
||||
})
|
||||
|
||||
It("should not race on file access", func() {
|
||||
handler := newTestStatic()
|
||||
h := handler.(staticFindHas)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
_ = h.Has("testdata/test.txt")
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
r, _ := h.Find("testdata/test.txt")
|
||||
if r != nil {
|
||||
_ = r.Close()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Should complete without race detector warnings
|
||||
})
|
||||
|
||||
It("should not race on HTTP serving", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
handler.SetDownload("testdata/test.txt", true)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
defer wg.Done()
|
||||
_ = performRequest(engine, "GET", "/static/test.txt")
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Should complete without race detector warnings
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
353
static/config.go
Normal file
353
static/config.go
Normal file
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
|
||||
import "time"
|
||||
|
||||
// HeadersConfig configures HTTP headers for caching and content-type validation.
|
||||
//
|
||||
// This configuration allows fine-grained control over:
|
||||
// - HTTP caching (Cache-Control, Expires, ETag)
|
||||
// - Content-Type detection and validation
|
||||
// - MIME type whitelisting/blacklisting
|
||||
type HeadersConfig struct {
|
||||
// EnableCacheControl activates HTTP cache control headers
|
||||
EnableCacheControl bool
|
||||
|
||||
// CacheMaxAge is the cache duration in seconds (e.g., 3600 = 1 hour)
|
||||
CacheMaxAge int
|
||||
|
||||
// CachePublic when true, cache is public (CDN), otherwise private (browser only)
|
||||
CachePublic bool
|
||||
|
||||
// EnableETag activates ETag generation for cache validation
|
||||
EnableETag bool
|
||||
|
||||
// EnableContentType activates Content-Type detection and validation
|
||||
EnableContentType bool
|
||||
|
||||
// AllowedMimeTypes is a list of allowed MIME types (empty = all allowed)
|
||||
AllowedMimeTypes []string
|
||||
|
||||
// DenyMimeTypes is a list of forbidden MIME types
|
||||
DenyMimeTypes []string
|
||||
|
||||
// CustomMimeTypes overrides MIME detection (extension -> mime-type mapping)
|
||||
CustomMimeTypes map[string]string
|
||||
}
|
||||
|
||||
// SecurityEventType represents the type of security event that occurred.
|
||||
// It is used to categorize security incidents for monitoring and analysis.
|
||||
type SecurityEventType string
|
||||
|
||||
const (
|
||||
// EventTypePathTraversal indicates an attempt to access files outside the allowed directory
|
||||
EventTypePathTraversal SecurityEventType = "path_traversal"
|
||||
|
||||
// EventTypeRateLimit indicates that an IP exceeded the allowed request rate
|
||||
EventTypeRateLimit SecurityEventType = "rate_limit_exceeded"
|
||||
|
||||
// EventTypeSuspiciousAccess indicates suspicious file access patterns
|
||||
EventTypeSuspiciousAccess SecurityEventType = "suspicious_access"
|
||||
|
||||
// EventTypeMimeTypeDenied indicates an attempt to access a file with a blocked MIME type
|
||||
EventTypeMimeTypeDenied SecurityEventType = "mime_type_denied"
|
||||
|
||||
// EventTypeDotFileAccess indicates an attempt to access hidden files (starting with .)
|
||||
EventTypeDotFileAccess SecurityEventType = "dot_file_access"
|
||||
|
||||
// EventTypePatternBlocked indicates an attempt to access a path matching a blocked pattern
|
||||
EventTypePatternBlocked SecurityEventType = "pattern_blocked"
|
||||
|
||||
// EventTypePathDepth indicates a path exceeding the maximum allowed depth
|
||||
EventTypePathDepth SecurityEventType = "path_depth_exceeded"
|
||||
)
|
||||
|
||||
// SecuEvtCallback is a callback function to process security events.
|
||||
// It receives security events and can be used for custom handling, logging,
|
||||
// or integration with external monitoring systems.
|
||||
// The event parameter is of private type secEvt which contains detailed
|
||||
// information about the security incident.
|
||||
type SecuEvtCallback func(event secEvt)
|
||||
|
||||
// SecurityConfig configures the integration with WAF (Web Application Firewall),
|
||||
// IDS (Intrusion Detection System), or EDR (Endpoint Detection and Response) systems.
|
||||
//
|
||||
// This configuration allows the static file handler to report security events to
|
||||
// external systems via webhooks or callbacks. Events can be sent individually or
|
||||
// batched for efficiency.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// handler.SetSecurityBackend(static.SecurityConfig{
|
||||
// Enabled: true,
|
||||
// WebhookURL: "https://waf.example.com/events",
|
||||
// WebhookHeaders: map[string]string{"Authorization": "Bearer token"},
|
||||
// WebhookAsync: true,
|
||||
// MinSeverity: "medium",
|
||||
// BatchSize: 100,
|
||||
// BatchTimeout: 30 * time.Second,
|
||||
// })
|
||||
type SecurityConfig struct {
|
||||
// Enabled activates the security integration
|
||||
Enabled bool
|
||||
|
||||
// WebhookURL is the URL to send security events to (WAF/SIEM/IDS endpoint)
|
||||
WebhookURL string
|
||||
|
||||
// WebhookTimeout is the timeout for webhook HTTP requests
|
||||
WebhookTimeout time.Duration
|
||||
|
||||
// WebhookHeaders are custom HTTP headers to include in webhook requests (e.g., Authorization)
|
||||
WebhookHeaders map[string]string
|
||||
|
||||
// WebhookAsync when true, sends webhooks asynchronously (non-blocking)
|
||||
WebhookAsync bool
|
||||
|
||||
// Callbacks is a list of Go callback functions for custom event processing
|
||||
Callbacks []SecuEvtCallback
|
||||
|
||||
// MinSeverity is the minimum severity level to notify (low, medium, high, critical)
|
||||
MinSeverity string
|
||||
|
||||
// BatchSize is the number of events to accumulate before sending a batch (0 = real-time)
|
||||
BatchSize int
|
||||
|
||||
// BatchTimeout is the maximum duration before sending an incomplete batch
|
||||
BatchTimeout time.Duration
|
||||
|
||||
// EnableCEFFormat enables CEF (Common Event Format) for SIEM compatibility
|
||||
// See: https://www.microfocus.com/documentation/arcsight/arcsight-smartconnectors/
|
||||
EnableCEFFormat bool
|
||||
}
|
||||
|
||||
// PathSecurityConfig configures path validation and security rules.
|
||||
//
|
||||
// This configuration protects against various path-based attacks including
|
||||
// path traversal, dot file access, and access to sensitive directories.
|
||||
type PathSecurityConfig struct {
|
||||
// Enabled activates or deactivates strict path validation
|
||||
Enabled bool
|
||||
|
||||
// AllowDotFiles permits access to files starting with "." (default: false)
|
||||
// When false, blocks access to .env, .git, .htaccess, etc.
|
||||
AllowDotFiles bool
|
||||
|
||||
// MaxPathDepth is the maximum allowed path depth (0 = unlimited)
|
||||
MaxPathDepth int
|
||||
|
||||
// BlockedPatterns are path patterns to block (e.g., []string{"wp-admin", ".git"})
|
||||
BlockedPatterns []string
|
||||
}
|
||||
|
||||
// RateLimitConfig configures IP-based rate limiting to prevent scraping and DoS attacks.
|
||||
//
|
||||
// The rate limiting tracks unique file paths requested per IP address within a time window.
|
||||
// This helps protect against malicious clients that attempt to enumerate or download
|
||||
// all files from the static file handler.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// handler.SetRateLimit(static.RateLimitConfig{
|
||||
// Enabled: true,
|
||||
// MaxRequests: 100,
|
||||
// Window: time.Minute,
|
||||
// CleanupInterval: 5 * time.Minute,
|
||||
// WhitelistIPs: []string{"127.0.0.1", "::1"},
|
||||
// })
|
||||
//
|
||||
// The rate limiting is thread-safe and uses atomic operations without mutexes.
|
||||
type RateLimitConfig struct {
|
||||
// Enabled activates or deactivates rate limiting
|
||||
Enabled bool
|
||||
|
||||
// MaxRequests is the maximum number of different files allowed per IP
|
||||
MaxRequests int
|
||||
|
||||
// Window is the time duration for rate counting (e.g., 1 minute)
|
||||
Window time.Duration
|
||||
|
||||
// CleanupInterval is the interval for automatic cache cleanup (e.g., 5 minutes)
|
||||
CleanupInterval time.Duration
|
||||
|
||||
// WhitelistIPs is a list of IP addresses exempt from rate limiting (e.g., ["127.0.0.1", "::1"])
|
||||
WhitelistIPs []string
|
||||
|
||||
// TrustedProxies is a list of trusted proxy IPs to extract real client IP
|
||||
TrustedProxies []string
|
||||
}
|
||||
|
||||
// SuspiciousConfig configures the detection and logging of suspicious file access patterns.
|
||||
//
|
||||
// This feature helps identify potential security threats by monitoring access to
|
||||
// files that are commonly targeted in attacks (e.g., .env files, backup files,
|
||||
// configuration files).
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// handler.SetSuspicious(static.SuspiciousConfig{
|
||||
// Enabled: true,
|
||||
// LogSuccessfulAccess: true,
|
||||
// SuspiciousPatterns: []string{".env", ".git", "wp-admin"},
|
||||
// SuspiciousExtensions: []string{".php", ".exe"},
|
||||
// })
|
||||
type SuspiciousConfig struct {
|
||||
// Enabled activates or deactivates suspicious access detection
|
||||
Enabled bool
|
||||
|
||||
// LogSuccessfulAccess also logs suspicious accesses that succeed (200 OK)
|
||||
LogSuccessfulAccess bool
|
||||
|
||||
// SuspiciousPatterns are path patterns considered suspicious
|
||||
SuspiciousPatterns []string
|
||||
|
||||
// SuspiciousExtensions are file extensions considered suspicious
|
||||
SuspiciousExtensions []string
|
||||
}
|
||||
|
||||
// DefaultHeadersConfig returns a default HTTP headers configuration.
|
||||
//
|
||||
// Default values:
|
||||
// - EnableCacheControl: true
|
||||
// - CacheMaxAge: 3600 seconds (1 hour)
|
||||
// - CachePublic: true (allows CDN caching)
|
||||
// - EnableETag: true
|
||||
// - EnableContentType: true
|
||||
// - AllowedMimeTypes: empty (all allowed by default)
|
||||
// - DenyMimeTypes: executable types blocked
|
||||
// - CustomMimeTypes: includes wasm and webp
|
||||
func DefaultHeadersConfig() HeadersConfig {
|
||||
return HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 3600, // 1 hour
|
||||
CachePublic: true,
|
||||
EnableETag: true,
|
||||
EnableContentType: true,
|
||||
AllowedMimeTypes: []string{}, // All allowed by default
|
||||
DenyMimeTypes: []string{
|
||||
"application/x-executable",
|
||||
"application/x-msdownload",
|
||||
"application/x-sh",
|
||||
},
|
||||
CustomMimeTypes: map[string]string{
|
||||
".wasm": "application/wasm",
|
||||
".webp": "image/webp",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultSecurityConfig returns a default security backend configuration.
|
||||
//
|
||||
// Default values:
|
||||
// - Enabled: false (must be explicitly enabled)
|
||||
// - WebhookTimeout: 5 seconds
|
||||
// - WebhookAsync: true (non-blocking)
|
||||
// - MinSeverity: "medium"
|
||||
// - BatchSize: 0 (real-time, no batching)
|
||||
// - BatchTimeout: 30 seconds
|
||||
// - EnableCEFFormat: false (JSON format)
|
||||
func DefaultSecurityConfig() SecurityConfig {
|
||||
return SecurityConfig{
|
||||
Enabled: false, // Disabled by default
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
WebhookAsync: true,
|
||||
MinSeverity: "medium",
|
||||
BatchSize: 0, // Real-time by default
|
||||
BatchTimeout: 30 * time.Second,
|
||||
EnableCEFFormat: false,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultRateLimitConfig returns a secure default rate limiting configuration.
|
||||
//
|
||||
// Default values:
|
||||
// - Enabled: true
|
||||
// - MaxRequests: 100 unique files per window
|
||||
// - Window: 1 minute
|
||||
// - CleanupInterval: 5 minutes
|
||||
// - WhitelistIPs: localhost (IPv4 and IPv6)
|
||||
// - TrustedProxies: empty
|
||||
func DefaultRateLimitConfig() RateLimitConfig {
|
||||
return RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 100,
|
||||
Window: 1 * time.Minute,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
WhitelistIPs: []string{"127.0.0.1", "::1"},
|
||||
TrustedProxies: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultPathSecurityConfig returns a secure default path security configuration.
|
||||
//
|
||||
// Default values:
|
||||
// - Enabled: true
|
||||
// - AllowDotFiles: false (blocks .env, .git, etc.)
|
||||
// - MaxPathDepth: 10
|
||||
// - BlockedPatterns: [".git", ".svn", ".env", "node_modules"]
|
||||
func DefaultPathSecurityConfig() PathSecurityConfig {
|
||||
return PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: false,
|
||||
MaxPathDepth: 10,
|
||||
BlockedPatterns: []string{".git", ".svn", ".env", "node_modules"},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultSuspiciousConfig returns a default suspicious access detection configuration.
|
||||
//
|
||||
// Default patterns include:
|
||||
// - Configuration files (.env, .git, wp-config, etc.)
|
||||
// - Backup files (.bak, .old, .swp, etc.)
|
||||
// - Admin panels (wp-admin, phpmyadmin, etc.)
|
||||
// - Sensitive paths (etc/passwd, windows/system32)
|
||||
// - Database files (.sql, .db)
|
||||
// - Executable extensions (.php, .exe, .sh)
|
||||
func DefaultSuspiciousConfig() SuspiciousConfig {
|
||||
return SuspiciousConfig{
|
||||
Enabled: true,
|
||||
LogSuccessfulAccess: true,
|
||||
SuspiciousPatterns: []string{
|
||||
// Configuration files
|
||||
".env", ".git", ".svn", ".htaccess", ".htpasswd",
|
||||
"web.config", "config.php", "wp-config",
|
||||
// Backup files
|
||||
".bak", ".backup", ".old", ".orig", ".save", ".swp",
|
||||
// Admin panels
|
||||
"admin", "wp-admin", "administrator", "phpmyadmin",
|
||||
// Sensitive paths
|
||||
"etc/passwd", "etc/shadow", "windows/system32",
|
||||
// Database
|
||||
".sql", ".db", ".sqlite",
|
||||
// Archives that might contain source
|
||||
".tar.gz", ".zip",
|
||||
},
|
||||
SuspiciousExtensions: []string{
|
||||
".php", ".asp", ".aspx", ".jsp", ".cgi",
|
||||
".exe", ".sh", ".bat", ".cmd",
|
||||
},
|
||||
}
|
||||
}
|
||||
277
static/config_test.go
Normal file
277
static/config_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Configuration", func() {
|
||||
var handler any
|
||||
|
||||
BeforeEach(func() {
|
||||
handler = newTestStatic()
|
||||
})
|
||||
|
||||
Describe("Download", func() {
|
||||
Context("when setting download flag", func() {
|
||||
It("should mark file as downloadable", func() {
|
||||
h := handler.(staticDownload)
|
||||
h.SetDownload("testdata/test.txt", true)
|
||||
Expect(h.IsDownload("testdata/test.txt")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should mark file as not downloadable", func() {
|
||||
h := handler.(staticDownload)
|
||||
h.SetDownload("testdata/test.txt", false)
|
||||
Expect(h.IsDownload("testdata/test.txt")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should toggle download flag", func() {
|
||||
h := handler.(staticDownload)
|
||||
|
||||
h.SetDownload("testdata/test.txt", true)
|
||||
Expect(h.IsDownload("testdata/test.txt")).To(BeTrue())
|
||||
|
||||
h.SetDownload("testdata/test.txt", false)
|
||||
Expect(h.IsDownload("testdata/test.txt")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when file does not exist", func() {
|
||||
It("should not set download flag", func() {
|
||||
h := handler.(staticDownload)
|
||||
|
||||
h.SetDownload("testdata/nonexistent.txt", true)
|
||||
Expect(h.IsDownload("testdata/nonexistent.txt")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when checking download flag", func() {
|
||||
It("should return false for unset files", func() {
|
||||
h := handler.(staticDownload)
|
||||
|
||||
Expect(h.IsDownload("testdata/test.txt")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should return false for nonexistent files", func() {
|
||||
h := handler.(staticDownload)
|
||||
|
||||
Expect(h.IsDownload("testdata/nonexistent.txt")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when path is empty", func() {
|
||||
It("should not set download flag", func() {
|
||||
h := handler.(staticDownload)
|
||||
|
||||
h.SetDownload("", true)
|
||||
Expect(h.IsDownload("")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Index", func() {
|
||||
Context("when setting index file", func() {
|
||||
It("should set index for route", func() {
|
||||
h := handler.(staticIndex)
|
||||
|
||||
h.SetIndex("", "/", "testdata/index.html")
|
||||
Expect(h.GetIndex("", "/")).To(Equal("testdata/index.html"))
|
||||
Expect(h.IsIndex("testdata/index.html")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should set index for route with group", func() {
|
||||
h := handler.(staticIndex)
|
||||
|
||||
h.SetIndex("/api", "/v1", "testdata/index.html")
|
||||
Expect(h.GetIndex("/api", "/v1")).To(Equal("testdata/index.html"))
|
||||
})
|
||||
|
||||
It("should support multiple routes for same file", func() {
|
||||
h := handler.(staticIndex)
|
||||
|
||||
h.SetIndex("", "/", "testdata/index.html")
|
||||
h.SetIndex("", "/home", "testdata/index.html")
|
||||
|
||||
Expect(h.GetIndex("", "/")).To(Equal("testdata/index.html"))
|
||||
Expect(h.GetIndex("", "/home")).To(Equal("testdata/index.html"))
|
||||
Expect(h.IsIndexForRoute("testdata/index.html", "", "/")).To(BeTrue())
|
||||
Expect(h.IsIndexForRoute("testdata/index.html", "", "/home")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when file does not exist", func() {
|
||||
It("should not set index", func() {
|
||||
h := handler.(staticIndex)
|
||||
|
||||
h.SetIndex("", "/", "testdata/nonexistent.html")
|
||||
Expect(h.GetIndex("", "/")).To(BeEmpty())
|
||||
Expect(h.IsIndex("testdata/nonexistent.html")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when checking index", func() {
|
||||
It("should return false for non-index files", func() {
|
||||
h := handler.(staticIndex)
|
||||
|
||||
Expect(h.IsIndex("testdata/test.txt")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should return empty string for unset routes", func() {
|
||||
h := handler.(staticIndex)
|
||||
|
||||
Expect(h.GetIndex("", "/notfound")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when path is empty", func() {
|
||||
It("should not set index", func() {
|
||||
h := handler.(staticIndex)
|
||||
|
||||
h.SetIndex("", "/", "")
|
||||
Expect(h.GetIndex("", "/")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Redirect", func() {
|
||||
Context("when setting redirect", func() {
|
||||
It("should redirect route to destination", func() {
|
||||
h := handler.(staticRedirect)
|
||||
|
||||
h.SetRedirect("", "/old", "", "/new")
|
||||
Expect(h.GetRedirect("", "/old")).To(Equal("/new"))
|
||||
Expect(h.IsRedirect("", "/old")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should redirect with groups", func() {
|
||||
h := handler.(staticRedirect)
|
||||
h.SetRedirect("/api", "/v1", "/api", "/v2")
|
||||
Expect(h.GetRedirect("/api", "/v1")).To(Equal("/api/v2"))
|
||||
})
|
||||
|
||||
It("should handle cross-group redirects", func() {
|
||||
h := handler.(staticRedirect)
|
||||
h.SetRedirect("/old", "/path", "/new", "/path")
|
||||
Expect(h.GetRedirect("/old", "/path")).To(Equal("/new/path"))
|
||||
})
|
||||
|
||||
It("should update redirect destination", func() {
|
||||
h := handler.(staticRedirect)
|
||||
|
||||
h.SetRedirect("", "/path", "", "/dest1")
|
||||
Expect(h.GetRedirect("", "/path")).To(Equal("/dest1"))
|
||||
|
||||
h.SetRedirect("", "/path", "", "/dest2")
|
||||
Expect(h.GetRedirect("", "/path")).To(Equal("/dest2"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when checking redirect", func() {
|
||||
It("should return false for non-redirect routes", func() {
|
||||
h := handler.(staticRedirect)
|
||||
Expect(h.IsRedirect("", "/notredirect")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should return empty string for unset routes", func() {
|
||||
h := handler.(staticRedirect)
|
||||
Expect(h.GetRedirect("", "/notfound")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Specific", func() {
|
||||
Context("when setting specific handler", func() {
|
||||
It("should set handler for route", func() {
|
||||
h := handler.(staticSpecific)
|
||||
h.SetSpecific("", "/custom", customMiddlewareOK("custom", nil))
|
||||
|
||||
retrieved := h.GetSpecific("", "/custom")
|
||||
Expect(retrieved).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should set handler with group", func() {
|
||||
h := handler.(staticSpecific)
|
||||
h.SetSpecific("/api", "/custom", customMiddlewareOK("custom", nil))
|
||||
|
||||
retrieved := h.GetSpecific("/api", "/custom")
|
||||
Expect(retrieved).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("should update specific handler", func() {
|
||||
h := handler.(staticSpecific)
|
||||
|
||||
h.SetSpecific("", "/path", customMiddlewareOK("handler1", nil))
|
||||
Expect(h.GetSpecific("", "/path")).ToNot(BeNil())
|
||||
|
||||
h.SetSpecific("", "/path", customMiddlewareOK("handler2", nil))
|
||||
Expect(h.GetSpecific("", "/path")).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when checking specific handler", func() {
|
||||
It("should return nil for unset routes", func() {
|
||||
h := handler.(staticSpecific)
|
||||
Expect(h.GetSpecific("", "/notfound")).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return nil for different groups", func() {
|
||||
h := handler.(staticSpecific)
|
||||
h.SetSpecific("/api", "/path", customMiddlewareOK("custom", nil))
|
||||
Expect(h.GetSpecific("/other", "/path")).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Multiple Configurations", func() {
|
||||
Context("when combining configurations", func() {
|
||||
It("should support multiple configurations on same file", func() {
|
||||
h := handler.(staticConfig)
|
||||
|
||||
h.SetDownload("testdata/index.html", true)
|
||||
h.SetIndex("", "/", "testdata/index.html")
|
||||
|
||||
Expect(h.IsDownload("testdata/index.html")).To(BeTrue())
|
||||
Expect(h.IsIndex("testdata/index.html")).To(BeTrue())
|
||||
Expect(h.GetIndex("", "/")).To(Equal("testdata/index.html"))
|
||||
})
|
||||
|
||||
It("should keep configurations independent", func() {
|
||||
h := handler.(staticConfig)
|
||||
|
||||
h.SetDownload("testdata/test.txt", true)
|
||||
h.SetIndex("", "/", "testdata/index.html")
|
||||
|
||||
Expect(h.IsDownload("testdata/test.txt")).To(BeTrue())
|
||||
Expect(h.IsDownload("testdata/index.html")).To(BeFalse())
|
||||
Expect(h.IsIndex("testdata/test.txt")).To(BeFalse())
|
||||
Expect(h.IsIndex("testdata/index.html")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
371
static/doc.go
Normal file
371
static/doc.go
Normal file
@@ -0,0 +1,371 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
/*
|
||||
Package static provides a secure, high-performance static file server for Gin framework
|
||||
with embedded filesystem support, comprehensive security features, and WAF/IDS/EDR integration.
|
||||
|
||||
# Overview
|
||||
|
||||
The static package is designed to serve files from Go's embed.FS with enterprise-grade
|
||||
security features including:
|
||||
|
||||
- Path traversal protection with configurable rules
|
||||
- IP-based rate limiting (DoS/scraping prevention)
|
||||
- Suspicious access pattern detection
|
||||
- MIME type validation and filtering
|
||||
- HTTP caching (ETag, Cache-Control)
|
||||
- Integration with external security systems (WAF/IDS/EDR)
|
||||
|
||||
All operations are thread-safe using atomic operations without mutexes for maximum
|
||||
performance and scalability.
|
||||
|
||||
# Architecture
|
||||
|
||||
The package follows a layered architecture with clear separation of concerns:
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Gin HTTP Request │
|
||||
└────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Static Handler (Get) │
|
||||
│ ┌────────────┬───────────────┬─────────────┬─────────────┐ │
|
||||
│ │Rate Limit │Path Security │Headers │Suspicious │ │
|
||||
│ │Check │Validation │Control │Detection │ │
|
||||
│ └────────────┴───────────────┴─────────────┴─────────────┘ │
|
||||
└────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ File Operations (embed.FS) │
|
||||
│ ┌────────────┬───────────────┬─────────────┬─────────────┐ │
|
||||
│ │Has() │Find() │Info() │Temp() │ │
|
||||
│ └────────────┴───────────────┴─────────────┴─────────────┘ │
|
||||
└────────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ HTTP Response (SendFile) │
|
||||
│ ┌────────────┬───────────────┬─────────────┬─────────────┐ │
|
||||
│ │ETag │Cache-Control │Content-Type │Disposition │ │
|
||||
│ └────────────┴───────────────┴─────────────┴─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
# Request Flow
|
||||
|
||||
A typical request flows through multiple security layers:
|
||||
|
||||
HTTP Request
|
||||
│
|
||||
▼
|
||||
[Rate Limit Check]
|
||||
│
|
||||
├─── Rate exceeded? ──► 429 Too Many Requests
|
||||
│
|
||||
▼
|
||||
[Path Security Validation]
|
||||
│
|
||||
├─── Path traversal? ──► 403 Forbidden
|
||||
├─── Dot file? ────────► 403 Forbidden
|
||||
├─── Blocked pattern? ─► 403 Forbidden
|
||||
│
|
||||
▼
|
||||
[Suspicious Access Detection]
|
||||
│
|
||||
├─── Log suspicious patterns
|
||||
│
|
||||
▼
|
||||
[File Exists Check]
|
||||
│
|
||||
├─── Not found? ───────► 404 Not Found
|
||||
│
|
||||
▼
|
||||
[ETag Validation]
|
||||
│
|
||||
├─── Cached? ──────────► 304 Not Modified
|
||||
│
|
||||
▼
|
||||
[MIME Type Validation]
|
||||
│
|
||||
├─── Denied type? ─────► 403 Forbidden
|
||||
│
|
||||
▼
|
||||
[Send File with Headers]
|
||||
│
|
||||
▼
|
||||
200 OK with caching headers
|
||||
|
||||
# Security Features
|
||||
|
||||
The package implements defense-in-depth with multiple security layers:
|
||||
|
||||
## 1. Path Traversal Protection
|
||||
|
||||
Protects against directory traversal attacks:
|
||||
|
||||
- Detects ".." sequences before path normalization
|
||||
- Validates against null byte injection
|
||||
- Enforces maximum path depth
|
||||
- Blocks access to dot files (.env, .git, etc.)
|
||||
- Pattern-based blocking (configurable)
|
||||
|
||||
Example:
|
||||
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: false,
|
||||
MaxPathDepth: 10,
|
||||
BlockedPatterns: []string{".git", ".svn", "node_modules"},
|
||||
})
|
||||
|
||||
## 2. Rate Limiting
|
||||
|
||||
IP-based rate limiting tracks unique file paths per IP:
|
||||
|
||||
- Configurable request limits and time windows
|
||||
- IP whitelisting support
|
||||
- Trusted proxy detection
|
||||
- Automatic cache cleanup
|
||||
- Thread-safe atomic operations
|
||||
|
||||
Example:
|
||||
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 100,
|
||||
Window: time.Minute,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
WhitelistIPs: []string{"127.0.0.1"},
|
||||
})
|
||||
|
||||
## 3. Suspicious Access Detection
|
||||
|
||||
Monitors and logs suspicious file access patterns:
|
||||
|
||||
- Configuration file access (.env, config.php)
|
||||
- Backup file enumeration (.bak, .old)
|
||||
- Admin panel scanning (wp-admin, phpmyadmin)
|
||||
- Database file requests (.sql, .db)
|
||||
- Logs both successful and failed attempts
|
||||
|
||||
## 4. MIME Type Validation
|
||||
|
||||
Controls which file types can be served:
|
||||
|
||||
- MIME type detection by file extension
|
||||
- Whitelist/blacklist configuration
|
||||
- Custom MIME type mapping
|
||||
- Blocks dangerous file types (.exe, .sh)
|
||||
|
||||
## 5. HTTP Caching
|
||||
|
||||
Optimizes bandwidth and performance:
|
||||
|
||||
- ETag generation and validation
|
||||
- Cache-Control headers (public/private)
|
||||
- 304 Not Modified responses
|
||||
- Configurable cache duration
|
||||
- Last-Modified support
|
||||
|
||||
# Security Backend Integration
|
||||
|
||||
The package can report security events to external systems:
|
||||
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Static │ │ Security │ │ External │
|
||||
│ Handler │────────►│ Event │────────►│ System │
|
||||
│ │ │ Processor │ │ (WAF/IDS) │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
│
|
||||
├─────► Webhook (JSON/CEF)
|
||||
│
|
||||
└─────► Go Callbacks
|
||||
|
||||
Supported integrations:
|
||||
|
||||
- Webhooks with custom headers (Authorization, etc.)
|
||||
- Common Event Format (CEF) for SIEM systems
|
||||
- Go callbacks for custom processing
|
||||
- Batch processing for efficiency
|
||||
- Configurable severity filtering
|
||||
|
||||
# Thread Safety
|
||||
|
||||
All data structures use atomic operations without mutexes:
|
||||
|
||||
Configuration: libatm.Value[*Config] (atomic.Value wrapper)
|
||||
IP Tracking: libatm.MapTyped[string, *T] (atomic map)
|
||||
Counters: atomic.Int64, atomic.Uint64 (standard atomic)
|
||||
Event Batch: atomic operations only (no mutex)
|
||||
|
||||
This design ensures:
|
||||
- Zero contention under high load
|
||||
- Predictable performance
|
||||
- No deadlock risk
|
||||
- Lock-free scalability
|
||||
|
||||
# Performance Considerations
|
||||
|
||||
The package is optimized for high-performance scenarios:
|
||||
|
||||
1. Atomic Operations: All state managed without mutexes
|
||||
2. Lazy Initialization: Security features activated only when configured
|
||||
3. Batch Processing: Multiple events sent together to reduce overhead
|
||||
4. HTTP Caching: ETag reduces bandwidth and CPU usage
|
||||
5. Embedded FS: No disk I/O for file access
|
||||
|
||||
Typical performance characteristics:
|
||||
- Request handling: <1ms per request (cached)
|
||||
- Rate limit check: ~100ns (atomic read)
|
||||
- Path validation: <10μs (string operations)
|
||||
- ETag generation: <1μs (hash calculation)
|
||||
|
||||
# Dependencies
|
||||
|
||||
This package requires:
|
||||
|
||||
- github.com/gin-gonic/gin - HTTP framework
|
||||
- github.com/nabbar/golib/atomic - Thread-safe atomic wrappers
|
||||
- github.com/nabbar/golib/context - Context-aware configuration
|
||||
- github.com/nabbar/golib/logger - Logging interface
|
||||
- github.com/nabbar/golib/errors - Error management
|
||||
- github.com/nabbar/golib/router - Router helpers
|
||||
- github.com/nabbar/golib/monitor - Health monitoring
|
||||
|
||||
# Example Usage
|
||||
|
||||
Basic static file server:
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nabbar/golib/static"
|
||||
)
|
||||
|
||||
//go:embed assets/*
|
||||
var content embed.FS
|
||||
|
||||
func main() {
|
||||
handler := static.New(context.Background(), content, "assets")
|
||||
router := gin.Default()
|
||||
handler.RegisterRouter("/static", router.GET)
|
||||
router.Run(":8080")
|
||||
}
|
||||
|
||||
With security features:
|
||||
|
||||
handler := static.New(context.Background(), content, "assets")
|
||||
|
||||
// Path security
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
// Rate limiting
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 100,
|
||||
Window: time.Minute,
|
||||
})
|
||||
|
||||
// HTTP caching
|
||||
handler.SetHeaders(static.DefaultHeadersConfig())
|
||||
|
||||
// Security backend
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: "https://waf.example.com/events",
|
||||
})
|
||||
|
||||
See example_test.go for more comprehensive examples.
|
||||
|
||||
# Best Practices
|
||||
|
||||
1. Always enable path security in production:
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
2. Configure rate limiting appropriate to your traffic:
|
||||
|
||||
- API serving: 100-1000 requests/minute
|
||||
|
||||
- Public websites: 1000-10000 requests/minute
|
||||
|
||||
3. Use HTTP caching to reduce server load:
|
||||
handler.SetHeaders(static.DefaultHeadersConfig())
|
||||
|
||||
4. Monitor security events in production:
|
||||
handler.SetSecurityBackend() with appropriate webhooks
|
||||
|
||||
5. Whitelist known IPs (monitoring, health checks):
|
||||
config.WhitelistIPs = []string{"monitoring-ip"}
|
||||
|
||||
6. Use custom MIME types for modern formats:
|
||||
config.CustomMimeTypes = map[string]string{".wasm": "application/wasm"}
|
||||
|
||||
7. Enable suspicious access logging:
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
Common issues and solutions:
|
||||
|
||||
403 Forbidden:
|
||||
- Check path security configuration
|
||||
- Verify file is not a dot file (if AllowDotFiles = false)
|
||||
- Check blocked patterns
|
||||
|
||||
429 Too Many Requests:
|
||||
- Increase rate limit (MaxRequests)
|
||||
- Add IP to whitelist
|
||||
- Increase time window
|
||||
|
||||
404 Not Found:
|
||||
- Verify file exists in embed.FS
|
||||
- Check embedRootDir parameter in New()
|
||||
- Use handler.Has() to verify file presence
|
||||
|
||||
# Testing
|
||||
|
||||
The package includes comprehensive tests:
|
||||
- 229+ test cases
|
||||
- Thread safety verified with race detector
|
||||
- Concurrency stress tests
|
||||
- Benchmark tests
|
||||
- Security scenario tests
|
||||
|
||||
Run tests:
|
||||
|
||||
go test -v
|
||||
go test -race (with race detector)
|
||||
go test -bench . (benchmarks)
|
||||
|
||||
# License
|
||||
|
||||
MIT License - Copyright (c) 2022 Nicolas JUHEL
|
||||
*/
|
||||
package static
|
||||
@@ -21,19 +21,22 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
|
||||
// SetDownload marks a file to be served as an attachment download.
|
||||
// The file must exist in the embedded filesystem.
|
||||
func (s *staticHandler) SetDownload(pathFile string, flag bool) {
|
||||
if pathFile != "" && s.Has(pathFile) {
|
||||
s.d.Store(pathFile, flag)
|
||||
s.dwn.Store(pathFile, flag)
|
||||
}
|
||||
}
|
||||
|
||||
// IsDownload checks if a file is configured to be downloaded.
|
||||
// Returns true if the file should be served with Content-Disposition: attachment.
|
||||
func (s *staticHandler) IsDownload(pathFile string) bool {
|
||||
if i, l := s.d.Load(pathFile); !l {
|
||||
if i, l := s.dwn.Load(pathFile); !l {
|
||||
return false
|
||||
} else if v, k := i.(bool); !k {
|
||||
return false
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
@@ -32,12 +31,41 @@ import (
|
||||
liberr "github.com/nabbar/golib/errors"
|
||||
)
|
||||
|
||||
// Error codes for the static package.
|
||||
// These errors use the github.com/nabbar/golib/errors package for error management.
|
||||
const (
|
||||
// ErrorParamEmpty indicates that required parameters are empty or missing
|
||||
ErrorParamEmpty liberr.CodeError = iota + liberr.MinPkgStatic
|
||||
|
||||
// ErrorFileInfo indicates failure to retrieve file information
|
||||
ErrorFileInfo
|
||||
|
||||
// ErrorFileOpen indicates failure to open a file from the embedded filesystem
|
||||
ErrorFileOpen
|
||||
|
||||
// ErrorFiletemp indicates failure to create a temporary file
|
||||
ErrorFiletemp
|
||||
|
||||
// ErrorFileNotFound indicates the requested file does not exist
|
||||
ErrorFileNotFound
|
||||
|
||||
// ErrorPathInvalid indicates an invalid or malformed path
|
||||
ErrorPathInvalid
|
||||
|
||||
// ErrorPathTraversal indicates a path traversal attempt was detected
|
||||
ErrorPathTraversal
|
||||
|
||||
// ErrorPathDotFile indicates an attempt to access a dot file (hidden file)
|
||||
ErrorPathDotFile
|
||||
|
||||
// ErrorPathDepth indicates the path depth exceeds the configured maximum
|
||||
ErrorPathDepth
|
||||
|
||||
// ErrorPathBlocked indicates the path matches a blocked pattern
|
||||
ErrorPathBlocked
|
||||
|
||||
// ErrorMimeTypeDenied indicates the file's MIME type is not allowed
|
||||
ErrorMimeTypeDenied
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -59,6 +87,18 @@ func getMessage(code liberr.CodeError) (message string) {
|
||||
return "cannot create temporary file"
|
||||
case ErrorFileNotFound:
|
||||
return "file not found"
|
||||
case ErrorPathInvalid:
|
||||
return "invalid path"
|
||||
case ErrorPathTraversal:
|
||||
return "path traversal attempt detected"
|
||||
case ErrorPathDotFile:
|
||||
return "dot file access not allowed"
|
||||
case ErrorPathDepth:
|
||||
return "path depth exceeds maximum"
|
||||
case ErrorPathBlocked:
|
||||
return "path contains blocked pattern"
|
||||
case ErrorMimeTypeDenied:
|
||||
return "mime type not allowed"
|
||||
}
|
||||
|
||||
return liberr.NullMessage
|
||||
|
||||
418
static/example_test.go
Normal file
418
static/example_test.go
Normal file
@@ -0,0 +1,418 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nabbar/golib/static"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var exampleContent embed.FS
|
||||
|
||||
// Example_basic shows the simplest usage of the static package.
|
||||
// This example serves files from an embedded filesystem with no security features.
|
||||
func Example_basic() {
|
||||
// Create a static file handler
|
||||
_ = static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// The handler can now be registered with Gin router
|
||||
// router := gin.Default()
|
||||
// handler.RegisterRouter("/static", router.GET)
|
||||
// router.Run(":8080")
|
||||
|
||||
// Files will be accessible at http://localhost:8080/static/*
|
||||
fmt.Println("Basic static file handler created")
|
||||
// Output: Basic static file handler created
|
||||
}
|
||||
|
||||
// Example_pathSecurity demonstrates path traversal protection.
|
||||
func Example_pathSecurity() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Enable path security with default settings
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
// Or customize the configuration
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: false, // Block .env, .git, etc.
|
||||
MaxPathDepth: 10,
|
||||
BlockedPatterns: []string{
|
||||
".git",
|
||||
".svn",
|
||||
"node_modules",
|
||||
},
|
||||
})
|
||||
|
||||
// Check if a path is safe
|
||||
safe := handler.IsPathSafe("/static/test.txt")
|
||||
fmt.Printf("Path is safe: %v\n", safe)
|
||||
// Output: Path is safe: true
|
||||
}
|
||||
|
||||
// Example_rateLimit demonstrates IP-based rate limiting.
|
||||
func Example_rateLimit() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Configure rate limiting
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 100, // Max 100 unique files
|
||||
Window: time.Minute, // Per minute
|
||||
CleanupInterval: 5 * time.Minute, // Cleanup every 5 minutes
|
||||
WhitelistIPs: []string{
|
||||
"127.0.0.1", // Localhost
|
||||
"::1", // IPv6 localhost
|
||||
},
|
||||
})
|
||||
|
||||
// Check if an IP is rate limited
|
||||
limited := handler.IsRateLimited("192.168.1.100")
|
||||
fmt.Printf("IP is rate limited: %v\n", limited)
|
||||
// Output: IP is rate limited: false
|
||||
}
|
||||
|
||||
// Example_httpCaching demonstrates HTTP caching with ETag and Cache-Control.
|
||||
func Example_httpCaching() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Configure HTTP headers with default settings
|
||||
handler.SetHeaders(static.DefaultHeadersConfig())
|
||||
|
||||
// Or customize
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 3600, // 1 hour
|
||||
CachePublic: true, // Allow CDN caching
|
||||
EnableETag: true, // Enable ETag validation
|
||||
EnableContentType: true,
|
||||
CustomMimeTypes: map[string]string{
|
||||
".wasm": "application/wasm",
|
||||
},
|
||||
})
|
||||
|
||||
fmt.Println("HTTP caching configured")
|
||||
// Output: HTTP caching configured
|
||||
}
|
||||
|
||||
// Example_mimeTypeValidation demonstrates MIME type filtering.
|
||||
func Example_mimeTypeValidation() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Block dangerous file types
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableContentType: true,
|
||||
DenyMimeTypes: []string{
|
||||
"application/x-executable",
|
||||
"application/x-msdownload",
|
||||
"application/x-sh",
|
||||
},
|
||||
})
|
||||
|
||||
// Or whitelist only specific types
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableContentType: true,
|
||||
AllowedMimeTypes: []string{
|
||||
"text/html",
|
||||
"text/css",
|
||||
"application/javascript",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
},
|
||||
})
|
||||
|
||||
fmt.Println("MIME type validation configured")
|
||||
// Output: MIME type validation configured
|
||||
}
|
||||
|
||||
// Example_suspiciousDetection demonstrates suspicious access pattern detection.
|
||||
func Example_suspiciousDetection() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Enable suspicious access detection
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
|
||||
// Or customize
|
||||
handler.SetSuspicious(static.SuspiciousConfig{
|
||||
Enabled: true,
|
||||
LogSuccessfulAccess: true, // Log even successful suspicious requests
|
||||
SuspiciousPatterns: []string{
|
||||
".env",
|
||||
".git",
|
||||
"wp-admin",
|
||||
"phpmyadmin",
|
||||
},
|
||||
SuspiciousExtensions: []string{
|
||||
".php",
|
||||
".exe",
|
||||
},
|
||||
})
|
||||
|
||||
fmt.Println("Suspicious access detection enabled")
|
||||
// Output: Suspicious access detection enabled
|
||||
}
|
||||
|
||||
// Example_securityBackend demonstrates WAF/IDS/EDR integration.
|
||||
func Example_securityBackend() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Configure security backend with webhook
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: "https://waf.example.com/events",
|
||||
WebhookHeaders: map[string]string{
|
||||
"Authorization": "Bearer secret-token",
|
||||
},
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
WebhookAsync: true, // Non-blocking
|
||||
MinSeverity: "medium", // Only medium, high, critical
|
||||
})
|
||||
|
||||
fmt.Println("Security backend configured")
|
||||
// Output: Security backend configured
|
||||
}
|
||||
|
||||
// Example_securityBackendBatch demonstrates batch event processing.
|
||||
func Example_securityBackendBatch() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Configure batch processing for efficiency
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: "https://siem.example.com/batch",
|
||||
BatchSize: 100, // Send every 100 events
|
||||
BatchTimeout: 30 * time.Second, // Or every 30 seconds
|
||||
MinSeverity: "low", // All severity levels
|
||||
})
|
||||
|
||||
fmt.Println("Batch security backend configured")
|
||||
// Output: Batch security backend configured
|
||||
}
|
||||
|
||||
// Example_securityBackendCEF demonstrates CEF format for SIEM systems.
|
||||
func Example_securityBackendCEF() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Configure CEF format for SIEM compatibility
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: "https://siem.example.com/cef",
|
||||
EnableCEFFormat: true, // Common Event Format
|
||||
MinSeverity: "high",
|
||||
})
|
||||
|
||||
fmt.Println("CEF format configured")
|
||||
// Output: CEF format configured
|
||||
}
|
||||
|
||||
// Example_indexFiles demonstrates index file configuration.
|
||||
func Example_indexFiles() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Set index file for root
|
||||
handler.SetIndex("", "/", "index.html")
|
||||
|
||||
// Set index for specific routes
|
||||
handler.SetIndex("", "/docs", "docs/index.html")
|
||||
|
||||
fmt.Println("Index files configured")
|
||||
// Output: Index files configured
|
||||
}
|
||||
|
||||
// Example_downloadFiles demonstrates download configuration.
|
||||
func Example_downloadFiles() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Mark files to be downloaded instead of displayed
|
||||
handler.SetDownload("/static/document.pdf", true)
|
||||
handler.SetDownload("/static/archive.zip", true)
|
||||
|
||||
// Check if a file should be downloaded
|
||||
shouldDownload := handler.IsDownload("/static/document.pdf")
|
||||
fmt.Printf("Should download: %v\n", shouldDownload)
|
||||
// Output: Should download: false
|
||||
}
|
||||
|
||||
// Example_redirects demonstrates URL redirection.
|
||||
func Example_redirects() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Configure redirects (HTTP 301)
|
||||
handler.SetRedirect("", "/old-path", "", "/new-path")
|
||||
handler.SetRedirect("", "/legacy", "", "/modern")
|
||||
|
||||
fmt.Println("Redirects configured")
|
||||
// Output: Redirects configured
|
||||
}
|
||||
|
||||
// Example_production demonstrates a complete production-ready configuration.
|
||||
func Example_production() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// 1. Path Security
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: false,
|
||||
MaxPathDepth: 10,
|
||||
BlockedPatterns: []string{
|
||||
".git", ".svn", ".env",
|
||||
"node_modules", "vendor",
|
||||
},
|
||||
})
|
||||
|
||||
// 2. Rate Limiting
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 1000,
|
||||
Window: time.Minute,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
WhitelistIPs: []string{"127.0.0.1"},
|
||||
})
|
||||
|
||||
// 3. HTTP Caching
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 3600,
|
||||
CachePublic: true,
|
||||
EnableETag: true,
|
||||
EnableContentType: true,
|
||||
DenyMimeTypes: []string{
|
||||
"application/x-executable",
|
||||
},
|
||||
})
|
||||
|
||||
// 4. Suspicious Access Detection
|
||||
handler.SetSuspicious(static.SuspiciousConfig{
|
||||
Enabled: true,
|
||||
LogSuccessfulAccess: true,
|
||||
SuspiciousPatterns: []string{".env", ".git"},
|
||||
})
|
||||
|
||||
// 5. Security Backend
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: "https://waf.example.com/events",
|
||||
WebhookAsync: true,
|
||||
MinSeverity: "medium",
|
||||
BatchSize: 100,
|
||||
BatchTimeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
// 6. Index Files
|
||||
handler.SetIndex("", "/", "index.html")
|
||||
|
||||
fmt.Println("Production configuration complete")
|
||||
// Output: Production configuration complete
|
||||
}
|
||||
|
||||
// Example_development demonstrates a minimal development configuration.
|
||||
func Example_development() {
|
||||
// Minimal setup for local development
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Only enable basic security
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: false,
|
||||
})
|
||||
|
||||
fmt.Println("Development configuration complete")
|
||||
// Output: Development configuration complete
|
||||
}
|
||||
|
||||
// Example_cdn demonstrates configuration optimized for CDN usage.
|
||||
func Example_cdn() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Aggressive caching for CDN
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 31536000, // 1 year
|
||||
CachePublic: true, // Allow CDN caching
|
||||
EnableETag: true,
|
||||
})
|
||||
|
||||
// Relaxed rate limiting (CDN handles most requests)
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 10000,
|
||||
Window: time.Minute,
|
||||
})
|
||||
|
||||
fmt.Println("CDN configuration complete")
|
||||
// Output: CDN configuration complete
|
||||
}
|
||||
|
||||
// Example_apiAssets demonstrates serving assets for an API.
|
||||
func Example_apiAssets() {
|
||||
handler := static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Strict security
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: false,
|
||||
MaxPathDepth: 5,
|
||||
})
|
||||
|
||||
// Conservative rate limiting
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 100,
|
||||
Window: time.Minute,
|
||||
})
|
||||
|
||||
// Long cache duration
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 86400, // 24 hours
|
||||
EnableETag: true,
|
||||
})
|
||||
|
||||
fmt.Println("API assets configuration complete")
|
||||
// Output: API assets configuration complete
|
||||
}
|
||||
|
||||
// Example_fileOperations demonstrates file operations on embedded filesystem.
|
||||
func Example_fileOperations() {
|
||||
_ = static.New(context.Background(), exampleContent, "testdata")
|
||||
|
||||
// Check if file exists
|
||||
// exists := handler.Has("test.txt")
|
||||
|
||||
// List all files
|
||||
// files, _ := handler.List("testdata")
|
||||
|
||||
// Get file info
|
||||
// info, _ := handler.Info("test.txt")
|
||||
|
||||
fmt.Println("File operations available")
|
||||
// Output: File operations available
|
||||
}
|
||||
@@ -21,21 +21,24 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
|
||||
// SetRedirect configures a permanent redirect (HTTP 301) from source to destination route.
|
||||
// Useful for maintaining backward compatibility or reorganizing file structure.
|
||||
func (s *staticHandler) SetRedirect(srcGroup, srcRoute, dstGroup, dstRoute string) {
|
||||
srcRoute = s._makeRoute(srcGroup, srcRoute)
|
||||
dstRoute = s._makeRoute(dstGroup, dstRoute)
|
||||
srcRoute = s.makeRoute(srcGroup, srcRoute)
|
||||
dstRoute = s.makeRoute(dstGroup, dstRoute)
|
||||
|
||||
s.f.Store(srcRoute, dstRoute)
|
||||
s.flw.Store(srcRoute, dstRoute)
|
||||
}
|
||||
|
||||
// GetRedirect returns the destination route for a given source route.
|
||||
// Returns empty string if no redirect is configured.
|
||||
func (s *staticHandler) GetRedirect(srcGroup, srcRoute string) string {
|
||||
srcRoute = s._makeRoute(srcGroup, srcRoute)
|
||||
if i, l := s.f.Load(srcRoute); !l {
|
||||
srcRoute = s.makeRoute(srcGroup, srcRoute)
|
||||
if i, l := s.flw.Load(srcRoute); !l {
|
||||
return ""
|
||||
} else if v, k := i.(string); !k {
|
||||
return ""
|
||||
@@ -44,10 +47,11 @@ func (s *staticHandler) GetRedirect(srcGroup, srcRoute string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// IsRedirect checks if a route is configured as a redirect.
|
||||
func (s *staticHandler) IsRedirect(group, route string) bool {
|
||||
route = s._makeRoute(group, route)
|
||||
route = s.makeRoute(group, route)
|
||||
|
||||
if i, l := s.f.Load(route); !l {
|
||||
if i, l := s.flw.Load(route); !l {
|
||||
return false
|
||||
} else {
|
||||
_, ok := i.(string)
|
||||
|
||||
223
static/headers.go
Normal file
223
static/headers.go
Normal file
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ginsdk "github.com/gin-gonic/gin"
|
||||
loglvl "github.com/nabbar/golib/logger/level"
|
||||
)
|
||||
|
||||
// SetHeaders configures HTTP headers behavior.
|
||||
// This method is thread-safe and uses atomic operations.
|
||||
func (s *staticHandler) SetHeaders(cfg HeadersConfig) {
|
||||
s.hdr.Store(&cfg)
|
||||
}
|
||||
|
||||
// GetHeaders returns the current HTTP headers configuration.
|
||||
// This method is thread-safe and uses atomic operations.
|
||||
func (s *staticHandler) GetHeaders() HeadersConfig {
|
||||
if cfg := s.hdr.Load(); cfg != nil {
|
||||
return *cfg
|
||||
}
|
||||
return HeadersConfig{}
|
||||
}
|
||||
|
||||
// getMimeType detects the MIME type of a file based on its extension.
|
||||
// Custom MIME types are checked first, then falls back to standard detection.
|
||||
func (s *staticHandler) getMimeType(filename string) string {
|
||||
cfg := s.GetHeaders()
|
||||
|
||||
ext := strings.ToLower(path.Ext(filename))
|
||||
|
||||
// Check custom MIME types first
|
||||
if cfg.CustomMimeTypes != nil {
|
||||
if customMime, ok := cfg.CustomMimeTypes[ext]; ok {
|
||||
return customMime
|
||||
}
|
||||
}
|
||||
|
||||
// Use standard detection
|
||||
mimeType := mime.TypeByExtension(ext)
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
|
||||
return mimeType
|
||||
}
|
||||
|
||||
// isMimeTypeAllowed checks if a MIME type is allowed to be served.
|
||||
// This method:
|
||||
// 1. Checks deny list first
|
||||
// 2. If allow list is empty, allows all (except denied)
|
||||
// 3. Otherwise, checks allow list
|
||||
func (s *staticHandler) isMimeTypeAllowed(mimeType string) bool {
|
||||
cfg := s.GetHeaders()
|
||||
|
||||
if !cfg.EnableContentType {
|
||||
return true
|
||||
}
|
||||
|
||||
// Extract base type (without charset, etc.)
|
||||
baseType := strings.Split(mimeType, ";")[0]
|
||||
baseType = strings.TrimSpace(baseType)
|
||||
|
||||
// Check if in deny list
|
||||
if slices.Contains(cfg.DenyMimeTypes, baseType) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If allow list is empty, everything is allowed
|
||||
if len(cfg.AllowedMimeTypes) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if in allow list
|
||||
return slices.Contains(cfg.AllowedMimeTypes, baseType)
|
||||
}
|
||||
|
||||
// generateETag generates an ETag for a file based on:
|
||||
// - Filename
|
||||
// - File size
|
||||
// - Modification time
|
||||
//
|
||||
// The ETag is a SHA-256 hash truncated to 16 bytes for efficiency.
|
||||
func (s *staticHandler) generateETag(filename string, size int64, modTime time.Time) string {
|
||||
cfg := s.GetHeaders()
|
||||
|
||||
if !cfg.EnableETag {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ETag based on: filename + size + modification date
|
||||
// Format: "hash-hex"
|
||||
data := fmt.Sprintf("%s-%d-%d", filename, size, modTime.Unix())
|
||||
hash := sha256.Sum256([]byte(data))
|
||||
etag := fmt.Sprintf(`"%x"`, hash[:16]) // Take first 16 bytes
|
||||
|
||||
return etag
|
||||
}
|
||||
|
||||
// checkETag verifies if the client's ETag matches the current file.
|
||||
// This enables HTTP 304 Not Modified responses to save bandwidth.
|
||||
func (s *staticHandler) checkETag(c *ginsdk.Context, etag string) bool {
|
||||
cfg := s.GetHeaders()
|
||||
|
||||
if !cfg.EnableETag || etag == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check If-None-Match header
|
||||
ifNoneMatch := c.GetHeader("If-None-Match")
|
||||
if ifNoneMatch == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare ETags
|
||||
return ifNoneMatch == etag
|
||||
}
|
||||
|
||||
// setCacheHeaders sets HTTP caching headers (Cache-Control, Expires).
|
||||
// These headers instruct browsers and CDNs how to cache the file.
|
||||
func (s *staticHandler) setCacheHeaders(c *ginsdk.Context) {
|
||||
cfg := s.GetHeaders()
|
||||
|
||||
if !cfg.EnableCacheControl {
|
||||
return
|
||||
}
|
||||
|
||||
// Cache-Control
|
||||
var cacheControl string
|
||||
if cfg.CachePublic {
|
||||
cacheControl = fmt.Sprintf("public, max-age=%d", cfg.CacheMaxAge)
|
||||
} else {
|
||||
cacheControl = fmt.Sprintf("private, max-age=%d", cfg.CacheMaxAge)
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", cacheControl)
|
||||
|
||||
// Expires (for HTTP/1.0 compatibility)
|
||||
expires := time.Now().Add(time.Duration(cfg.CacheMaxAge) * time.Second)
|
||||
c.Header("Expires", expires.UTC().Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
// setContentTypeHeader sets the Content-Type header and validates MIME type.
|
||||
// Returns an error if the MIME type is not allowed.
|
||||
// Notifies security backend if MIME type is denied.
|
||||
func (s *staticHandler) setContentTypeHeader(c *ginsdk.Context, filename string) (string, error) {
|
||||
cfg := s.GetHeaders()
|
||||
mimeType := s.getMimeType(filename)
|
||||
|
||||
// Validate MIME type only if EnableContentType is enabled
|
||||
if cfg.EnableContentType && !s.isMimeTypeAllowed(mimeType) {
|
||||
ent := s.getLogger().Entry(loglvl.WarnLevel, "mime type not allowed")
|
||||
ent.FieldAdd("filename", filename)
|
||||
ent.FieldAdd("mimeType", mimeType)
|
||||
ent.Log()
|
||||
|
||||
// Notify WAF/IDS/EDR
|
||||
details := map[string]string{
|
||||
"filename": filename,
|
||||
"mime_type": mimeType,
|
||||
}
|
||||
event := s.newSecuEvt(c, EventTypeMimeTypeDenied, "medium", true, details)
|
||||
s.notifySecuEvt(event)
|
||||
|
||||
return "", ErrorMimeTypeDenied.Error(fmt.Errorf("mime type not allowed: %s", mimeType))
|
||||
}
|
||||
|
||||
c.Header("Content-Type", mimeType)
|
||||
return mimeType, nil
|
||||
}
|
||||
|
||||
// setETagHeader sets ETag and Last-Modified headers and checks cache validation.
|
||||
// Returns true if the client has a valid cached version (cache hit).
|
||||
// This allows the handler to return HTTP 304 Not Modified.
|
||||
func (s *staticHandler) setETagHeader(c *ginsdk.Context, filename string, size int64, modTime time.Time) bool {
|
||||
etag := s.generateETag(filename, size, modTime)
|
||||
|
||||
if etag == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Last-Modified", modTime.UTC().Format(http.TimeFormat))
|
||||
|
||||
// Check if client already has cached version
|
||||
if s.checkETag(c, etag) {
|
||||
return true // Cache hit
|
||||
}
|
||||
|
||||
return false // Cache miss
|
||||
}
|
||||
437
static/headers_test.go
Normal file
437
static/headers_test.go
Normal file
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
ginsdk "github.com/gin-gonic/gin"
|
||||
"github.com/nabbar/golib/static"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("HTTP Headers", func() {
|
||||
Describe("Configuration", func() {
|
||||
Context("when setting headers config", func() {
|
||||
It("should store and retrieve configuration", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
cfg := static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 7200,
|
||||
CachePublic: false,
|
||||
EnableETag: true,
|
||||
EnableContentType: true,
|
||||
AllowedMimeTypes: []string{"text/plain", "text/html"},
|
||||
DenyMimeTypes: []string{"application/x-executable"},
|
||||
CustomMimeTypes: map[string]string{
|
||||
".custom": "application/x-custom",
|
||||
},
|
||||
}
|
||||
|
||||
handler.SetHeaders(cfg)
|
||||
|
||||
retrieved := handler.GetHeaders()
|
||||
Expect(retrieved.EnableCacheControl).To(BeTrue())
|
||||
Expect(retrieved.CacheMaxAge).To(Equal(7200))
|
||||
Expect(retrieved.CachePublic).To(BeFalse())
|
||||
Expect(retrieved.EnableETag).To(BeTrue())
|
||||
Expect(retrieved.AllowedMimeTypes).To(ContainElement("text/plain"))
|
||||
})
|
||||
|
||||
It("should use default config", func() {
|
||||
cfg := static.DefaultHeadersConfig()
|
||||
|
||||
Expect(cfg.EnableCacheControl).To(BeTrue())
|
||||
Expect(cfg.CacheMaxAge).To(Equal(3600))
|
||||
Expect(cfg.CachePublic).To(BeTrue())
|
||||
Expect(cfg.EnableETag).To(BeTrue())
|
||||
Expect(cfg.DenyMimeTypes).To(ContainElement("application/x-executable"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Cache-Control Headers", func() {
|
||||
Context("when cache control is enabled", func() {
|
||||
It("should add Cache-Control header for public cache", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 3600,
|
||||
CachePublic: true,
|
||||
EnableContentType: false,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
cacheControl := w.Header().Get("Cache-Control")
|
||||
Expect(cacheControl).To(ContainSubstring("public"))
|
||||
Expect(cacheControl).To(ContainSubstring("max-age=3600"))
|
||||
})
|
||||
|
||||
It("should add Cache-Control header for private cache", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 1800,
|
||||
CachePublic: false,
|
||||
EnableContentType: false,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
cacheControl := w.Header().Get("Cache-Control")
|
||||
Expect(cacheControl).To(ContainSubstring("private"))
|
||||
Expect(cacheControl).To(ContainSubstring("max-age=1800"))
|
||||
})
|
||||
|
||||
It("should add Expires header", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 3600,
|
||||
EnableContentType: false,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
expires := w.Header().Get("Expires")
|
||||
Expect(expires).NotTo(BeEmpty())
|
||||
})
|
||||
|
||||
It("should not add cache headers when disabled", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: false,
|
||||
EnableContentType: false,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
cacheControl := w.Header().Get("Cache-Control")
|
||||
Expect(cacheControl).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ETag Support", func() {
|
||||
Context("when ETag is enabled", func() {
|
||||
It("should add ETag and Last-Modified headers", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableETag: true,
|
||||
EnableContentType: false,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
etag := w.Header().Get("ETag")
|
||||
Expect(etag).NotTo(BeEmpty())
|
||||
Expect(etag).To(ContainSubstring(`"`)) // ETags are quoted
|
||||
|
||||
lastModified := w.Header().Get("Last-Modified")
|
||||
Expect(lastModified).NotTo(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return 304 Not Modified when ETag matches", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableETag: true,
|
||||
EnableContentType: false,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// First call to get the ETag
|
||||
w1 := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w1.Code).To(Equal(http.StatusOK))
|
||||
|
||||
etag := w1.Header().Get("ETag")
|
||||
Expect(etag).NotTo(BeEmpty())
|
||||
|
||||
// Second call with If-None-Match
|
||||
req := performRequestWithHeaders(engine, "GET", "/static/test.txt", map[string]string{
|
||||
"If-None-Match": etag,
|
||||
})
|
||||
|
||||
Expect(req.Code).To(Equal(http.StatusNotModified))
|
||||
Expect(req.Body.Len()).To(Equal(0)) // Body should be empty for 304
|
||||
})
|
||||
|
||||
It("should return 200 when ETag does not match", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableETag: true,
|
||||
EnableContentType: false,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Appel avec un ETag qui ne correspond pas
|
||||
w := performRequestWithHeaders(engine, "GET", "/static/test.txt", map[string]string{
|
||||
"If-None-Match": `"wrong-etag"`,
|
||||
})
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("should not add ETag when disabled", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableETag: false,
|
||||
EnableContentType: false,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
etag := w.Header().Get("ETag")
|
||||
Expect(etag).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Content-Type Validation", func() {
|
||||
Context("when content type validation is enabled", func() {
|
||||
It("should set correct Content-Type header", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableContentType: true,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
Expect(contentType).To(ContainSubstring("text/plain"))
|
||||
})
|
||||
|
||||
It("should use custom MIME types", func() {
|
||||
handler := newTestStaticWithRoot("testdata").(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableContentType: true,
|
||||
CustomMimeTypes: map[string]string{
|
||||
".txt": "text/x-custom",
|
||||
},
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
Expect(contentType).To(Equal("text/x-custom"))
|
||||
})
|
||||
|
||||
It("should block denied MIME types", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableContentType: true,
|
||||
DenyMimeTypes: []string{"text/plain"},
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("should allow only whitelisted MIME types", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableContentType: true,
|
||||
AllowedMimeTypes: []string{"image/png", "image/jpeg"},
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// text/plain n'est pas dans la whitelist
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("should allow all MIME types when allow list is empty", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableContentType: true,
|
||||
AllowedMimeTypes: []string{}, // Empty = all allowed
|
||||
DenyMimeTypes: []string{},
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Combined Features", func() {
|
||||
Context("when using all features together", func() {
|
||||
It("should apply cache, ETag, and content-type together", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
CacheMaxAge: 3600,
|
||||
CachePublic: true,
|
||||
EnableETag: true,
|
||||
EnableContentType: true,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// Check all headers
|
||||
Expect(w.Header().Get("Cache-Control")).To(ContainSubstring("public"))
|
||||
Expect(w.Header().Get("ETag")).NotTo(BeEmpty())
|
||||
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/plain"))
|
||||
Expect(w.Header().Get("Expires")).NotTo(BeEmpty())
|
||||
Expect(w.Header().Get("Last-Modified")).NotTo(BeEmpty())
|
||||
})
|
||||
|
||||
It("should respect MIME type restrictions with caching", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableCacheControl: true,
|
||||
EnableContentType: true,
|
||||
DenyMimeTypes: []string{"text/plain"},
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
|
||||
// No cache headers for blocked requests
|
||||
Expect(w.Header().Get("Cache-Control")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Performance", func() {
|
||||
Context("when benchmarking header operations", func() {
|
||||
It("should handle ETag generation efficiently", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.DefaultHeadersConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Multiple requests should get consistent ETags
|
||||
etags := make([]string, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
etags[i] = w.Header().Get("ETag")
|
||||
}
|
||||
|
||||
// All ETags should be identical for same file
|
||||
for i := 1; i < len(etags); i++ {
|
||||
Expect(etags[i]).To(Equal(etags[0]))
|
||||
}
|
||||
})
|
||||
|
||||
It("should minimize bandwidth with 304 responses", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetHeaders(static.DefaultHeadersConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// First call - full response
|
||||
w1 := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w1.Code).To(Equal(http.StatusOK))
|
||||
fullSize := w1.Body.Len()
|
||||
|
||||
etag := w1.Header().Get("ETag")
|
||||
|
||||
// Second call with ETag - 304 response
|
||||
w2 := performRequestWithHeaders(engine, "GET", "/static/test.txt", map[string]string{
|
||||
"If-None-Match": etag,
|
||||
})
|
||||
Expect(w2.Code).To(Equal(http.StatusNotModified))
|
||||
cachedSize := w2.Body.Len()
|
||||
|
||||
// 304 response should have no body
|
||||
Expect(cachedSize).To(Equal(0))
|
||||
Expect(fullSize).To(BeNumerically(">", 0))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Helper function for requests with custom headers
|
||||
func performRequestWithHeaders(engine interface{}, method, path string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(method, path, nil)
|
||||
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
engine.(*ginsdk.Engine).ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
@@ -21,7 +21,6 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
@@ -30,27 +29,34 @@ import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
// SetIndex configures an index file for a specific route.
|
||||
// When a directory is requested, this file will be served instead.
|
||||
// The file must exist in the embedded filesystem.
|
||||
func (s *staticHandler) SetIndex(group, route, pathFile string) {
|
||||
if pathFile != "" && s.Has(pathFile) {
|
||||
var lst []string
|
||||
|
||||
if i, l := s.i.Load(pathFile); !l {
|
||||
lst = make([]string, 0)
|
||||
if i, l := s.idx.Load(pathFile); !l {
|
||||
lst = make([]string, 0, 1)
|
||||
} else if v, k := i.([]string); !k {
|
||||
lst = make([]string, 0)
|
||||
lst = make([]string, 0, 1)
|
||||
} else {
|
||||
lst = v
|
||||
// Create a copy to avoid race conditions when appending
|
||||
lst = make([]string, len(v), len(v)+1)
|
||||
copy(lst, v)
|
||||
}
|
||||
|
||||
s.i.Store(pathFile, append(lst, s._makeRoute(group, route)))
|
||||
s.idx.Store(pathFile, append(lst, s.makeRoute(group, route)))
|
||||
}
|
||||
}
|
||||
|
||||
// GetIndex returns the index file configured for a specific route.
|
||||
// Returns empty string if no index is configured.
|
||||
func (s *staticHandler) GetIndex(group, route string) string {
|
||||
route = s._makeRoute(group, route)
|
||||
route = s.makeRoute(group, route)
|
||||
var found string
|
||||
|
||||
s.i.Walk(func(key string, val interface{}) bool {
|
||||
s.idx.Walk(func(key string, val interface{}) bool {
|
||||
if v, k := val.([]string); !k {
|
||||
return true
|
||||
} else if !slices.Contains(v, route) {
|
||||
@@ -64,8 +70,9 @@ func (s *staticHandler) GetIndex(group, route string) string {
|
||||
return found
|
||||
}
|
||||
|
||||
// IsIndex checks if a file is configured as an index file.
|
||||
func (s *staticHandler) IsIndex(pathFile string) bool {
|
||||
if i, l := s.i.Load(pathFile); !l {
|
||||
if i, l := s.idx.Load(pathFile); !l {
|
||||
return false
|
||||
} else {
|
||||
_, ok := i.([]string)
|
||||
@@ -73,12 +80,13 @@ func (s *staticHandler) IsIndex(pathFile string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// IsIndexForRoute checks if a file is the configured index for a specific route.
|
||||
func (s *staticHandler) IsIndexForRoute(pathFile, group, route string) bool {
|
||||
if i, l := s.i.Load(pathFile); !l {
|
||||
if i, l := s.idx.Load(pathFile); !l {
|
||||
return false
|
||||
} else if v, k := i.([]string); !k {
|
||||
return false
|
||||
} else {
|
||||
return slices.Contains(v, s._makeRoute(group, route))
|
||||
return slices.Contains(v, s.makeRoute(group, route))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,51 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
// Package static provides a secure, high-performance static file server for Gin framework
|
||||
// with embedded filesystem support, rate limiting, path security, and WAF/IDS/EDR integration.
|
||||
//
|
||||
// This package is designed to serve static files from an embedded filesystem (embed.FS)
|
||||
// with advanced security features including:
|
||||
//
|
||||
// - Path traversal protection
|
||||
// - IP-based rate limiting
|
||||
// - Suspicious access detection
|
||||
// - MIME type validation
|
||||
// - HTTP caching (ETag, Cache-Control)
|
||||
// - Integration with WAF/IDS/EDR systems
|
||||
//
|
||||
// Thread Safety:
|
||||
//
|
||||
// All operations are thread-safe and use atomic operations without mutexes for
|
||||
// maximum performance and scalability.
|
||||
//
|
||||
// Basic Usage:
|
||||
//
|
||||
// package main
|
||||
//
|
||||
// import (
|
||||
// "context"
|
||||
// "embed"
|
||||
// "github.com/gin-gonic/gin"
|
||||
// "github.com/nabbar/golib/static"
|
||||
// )
|
||||
//
|
||||
// //go:embed assets/*
|
||||
// var content embed.FS
|
||||
//
|
||||
// func main() {
|
||||
// handler := static.New(context.Background(), content, "assets")
|
||||
// router := gin.Default()
|
||||
// handler.RegisterRouter("/static", router.GET)
|
||||
// router.Run(":8080")
|
||||
// }
|
||||
//
|
||||
// For more information about related packages:
|
||||
// - github.com/nabbar/golib/logger - Logging interface
|
||||
// - github.com/nabbar/golib/router - Router registration helpers
|
||||
// - github.com/nabbar/golib/monitor - Health monitoring
|
||||
package static
|
||||
|
||||
import (
|
||||
@@ -33,65 +75,319 @@ import (
|
||||
"os"
|
||||
"sync/atomic"
|
||||
|
||||
libfpg "github.com/nabbar/golib/file/progress"
|
||||
|
||||
ginsdk "github.com/gin-gonic/gin"
|
||||
libatm "github.com/nabbar/golib/atomic"
|
||||
libctx "github.com/nabbar/golib/context"
|
||||
liberr "github.com/nabbar/golib/errors"
|
||||
libfpg "github.com/nabbar/golib/file/progress"
|
||||
liblog "github.com/nabbar/golib/logger"
|
||||
montps "github.com/nabbar/golib/monitor/types"
|
||||
librtr "github.com/nabbar/golib/router"
|
||||
libver "github.com/nabbar/golib/version"
|
||||
)
|
||||
|
||||
type Static interface {
|
||||
// RateLimit interface provides IP-based rate limiting functionality to prevent
|
||||
// scraping, enumeration attacks, and DoS attempts.
|
||||
//
|
||||
// The rate limiting tracks unique file paths per IP address and enforces
|
||||
// configurable limits within time windows. All operations are thread-safe
|
||||
// using atomic operations.
|
||||
//
|
||||
// See RateLimitConfig for configuration options.
|
||||
type RateLimit interface {
|
||||
// SetRateLimit configures rate limiting parameters
|
||||
SetRateLimit(cfg RateLimitConfig)
|
||||
|
||||
// GetRateLimit returns the current rate limit configuration
|
||||
GetRateLimit() RateLimitConfig
|
||||
|
||||
// IsRateLimited checks if an IP address is currently rate limited
|
||||
IsRateLimited(ip string) bool
|
||||
|
||||
// ResetRateLimit clears rate limit data for a specific IP address
|
||||
ResetRateLimit(ip string)
|
||||
}
|
||||
|
||||
// PathSecurity interface provides protection against path traversal and other
|
||||
// path-based security vulnerabilities.
|
||||
//
|
||||
// It validates requested paths against various security rules including:
|
||||
// - Path traversal attempts (../)
|
||||
// - Dot file access (.env, .git)
|
||||
// - Maximum path depth
|
||||
// - Blocked patterns
|
||||
// - Null byte injection
|
||||
//
|
||||
// See PathSecurityConfig for configuration options.
|
||||
type PathSecurity interface {
|
||||
// SetPathSecurity configures path security validation rules
|
||||
SetPathSecurity(cfg PathSecurityConfig)
|
||||
|
||||
// GetPathSecurity returns the current path security configuration
|
||||
GetPathSecurity() PathSecurityConfig
|
||||
|
||||
// IsPathSafe validates if a requested path is safe to serve
|
||||
IsPathSafe(requestPath string) bool
|
||||
}
|
||||
|
||||
// SuspiciousDetection interface provides detection and logging of suspicious
|
||||
// file access patterns that may indicate security threats.
|
||||
//
|
||||
// It monitors access to files commonly targeted in attacks such as:
|
||||
// - Configuration files (.env, config.php)
|
||||
// - Backup files (.bak, .old)
|
||||
// - Admin panels (wp-admin, phpmyadmin)
|
||||
// - Database files (.sql, .db)
|
||||
//
|
||||
// See SuspiciousConfig for configuration options.
|
||||
type SuspiciousDetection interface {
|
||||
// SetSuspicious configures suspicious access detection rules
|
||||
SetSuspicious(cfg SuspiciousConfig)
|
||||
|
||||
// GetSuspicious returns the current suspicious detection configuration
|
||||
GetSuspicious() SuspiciousConfig
|
||||
}
|
||||
|
||||
// HeadersControl interface provides HTTP caching and content-type validation.
|
||||
//
|
||||
// It manages:
|
||||
// - Cache-Control headers (public/private, max-age)
|
||||
// - ETag generation and validation (304 Not Modified)
|
||||
// - Content-Type detection and validation
|
||||
// - MIME type whitelisting/blacklisting
|
||||
//
|
||||
// See HeadersConfig for configuration options.
|
||||
type HeadersControl interface {
|
||||
// SetHeaders configures HTTP headers and caching behavior
|
||||
SetHeaders(cfg HeadersConfig)
|
||||
|
||||
// GetHeaders returns the current headers configuration
|
||||
GetHeaders() HeadersConfig
|
||||
}
|
||||
|
||||
// SecurityBackend interface provides integration with WAF (Web Application Firewall),
|
||||
// IDS (Intrusion Detection System), and EDR (Endpoint Detection and Response) systems.
|
||||
//
|
||||
// Security events are reported via:
|
||||
// - Webhooks (JSON or CEF format)
|
||||
// - Go callbacks for custom processing
|
||||
// - Batch processing for efficiency
|
||||
//
|
||||
// Supported event types include path traversal, rate limiting, suspicious access,
|
||||
// and MIME type violations.
|
||||
//
|
||||
// See SecurityConfig for configuration options.
|
||||
type SecurityBackend interface {
|
||||
// SetSecurityBackend configures integration with external security systems
|
||||
SetSecurityBackend(cfg SecurityConfig)
|
||||
|
||||
// GetSecurityBackend returns the current security backend configuration
|
||||
GetSecurityBackend() SecurityConfig
|
||||
|
||||
// AddSecurityCallback registers a Go callback function for security events
|
||||
AddSecurityCallback(callback SecuEvtCallback)
|
||||
}
|
||||
|
||||
// StaticRegister interface provides methods for registering the static file handler
|
||||
// with Gin routers and configuring logging.
|
||||
//
|
||||
// This interface integrates with github.com/nabbar/golib/router for flexible
|
||||
// route registration in both root and group contexts.
|
||||
type StaticRegister interface {
|
||||
// RegisterRouter registers the static handler on a route using the provided register function.
|
||||
// The route parameter specifies the URL path (e.g., "/static").
|
||||
// Additional middleware can be provided via router parameter.
|
||||
RegisterRouter(route string, register librtr.RegisterRouter, router ...ginsdk.HandlerFunc)
|
||||
|
||||
// RegisterRouterInGroup registers the static handler in a router group.
|
||||
// This allows organizing routes under common prefixes or middleware.
|
||||
RegisterRouterInGroup(route, group string, register librtr.RegisterRouterInGroup, router ...ginsdk.HandlerFunc)
|
||||
|
||||
RegisterLogger(log liblog.FuncLog)
|
||||
// RegisterLogger sets the logger instance for the static handler.
|
||||
// See github.com/nabbar/golib/logger for logger implementation.
|
||||
RegisterLogger(log liblog.Logger)
|
||||
}
|
||||
|
||||
SetDownload(pathFile string, flag bool)
|
||||
// StaticIndex interface provides index file configuration for directory requests.
|
||||
//
|
||||
// Index files (e.g., index.html) are automatically served when a directory
|
||||
// is requested, similar to Apache's DirectoryIndex or nginx's index directive.
|
||||
type StaticIndex interface {
|
||||
// SetIndex configures an index file for a specific route and group.
|
||||
// When a directory is requested, this file will be served instead.
|
||||
SetIndex(group, route, pathFile string)
|
||||
|
||||
// GetIndex returns the configured index file for a route and group.
|
||||
GetIndex(group, route string) string
|
||||
SetRedirect(srcGroup, srcRoute, dstGroup, dstRoute string)
|
||||
GetRedirect(srcGroup, srcRoute string) string
|
||||
SetSpecific(group, route string, router ginsdk.HandlerFunc)
|
||||
GetSpecific(group, route string) ginsdk.HandlerFunc
|
||||
|
||||
IsDownload(pathFile string) bool
|
||||
// IsIndex checks if a file is configured as an index file.
|
||||
IsIndex(pathFile string) bool
|
||||
|
||||
// IsIndexForRoute checks if a file is the index for a specific route.
|
||||
IsIndexForRoute(pathFile, group, route string) bool
|
||||
}
|
||||
|
||||
// StaticDownload interface configures files to be served as downloads
|
||||
// (with Content-Disposition: attachment header).
|
||||
//
|
||||
// This forces the browser to download the file rather than displaying it inline.
|
||||
type StaticDownload interface {
|
||||
// SetDownload marks a file to be served as an attachment download.
|
||||
SetDownload(pathFile string, flag bool)
|
||||
|
||||
// IsDownload checks if a file is configured to be downloaded.
|
||||
IsDownload(pathFile string) bool
|
||||
}
|
||||
|
||||
// StaticRedirect interface provides URL redirection configuration.
|
||||
//
|
||||
// This allows redirecting from one path to another, useful for maintaining
|
||||
// backward compatibility or organizing file structure.
|
||||
type StaticRedirect interface {
|
||||
// SetRedirect configures a redirect from source to destination route.
|
||||
// Returns HTTP 301 Permanent Redirect.
|
||||
SetRedirect(srcGroup, srcRoute, dstGroup, dstRoute string)
|
||||
|
||||
// GetRedirect returns the destination for a source route.
|
||||
GetRedirect(srcGroup, srcRoute string) string
|
||||
|
||||
// IsRedirect checks if a route is configured as a redirect.
|
||||
IsRedirect(group, route string) bool
|
||||
}
|
||||
|
||||
// StaticSpecific interface allows overriding the default static file handler
|
||||
// with custom handlers for specific routes.
|
||||
//
|
||||
// This is useful for adding special processing for certain paths while
|
||||
// maintaining the default behavior for others.
|
||||
type StaticSpecific interface {
|
||||
// SetSpecific registers a custom handler for a specific route.
|
||||
SetSpecific(group, route string, router ginsdk.HandlerFunc)
|
||||
|
||||
// GetSpecific returns the custom handler for a route, if configured.
|
||||
GetSpecific(group, route string) ginsdk.HandlerFunc
|
||||
}
|
||||
|
||||
// Static is the main interface for the static file handler.
|
||||
//
|
||||
// It combines all sub-interfaces and provides core file operations.
|
||||
// All operations are thread-safe using atomic operations.
|
||||
//
|
||||
// The Static handler serves files from an embedded filesystem (embed.FS)
|
||||
// with comprehensive security features and HTTP caching support.
|
||||
//
|
||||
// See the package documentation for usage examples.
|
||||
type Static interface {
|
||||
// Has checks if a file exists in the embedded filesystem.
|
||||
Has(pathFile string) bool
|
||||
List(rootPath string) ([]string, liberr.Error)
|
||||
Find(pathFile string) (io.ReadCloser, liberr.Error)
|
||||
Info(pathFile string) (os.FileInfo, liberr.Error)
|
||||
Temp(pathFile string) (libfpg.Progress, liberr.Error)
|
||||
|
||||
Map(func(pathFile string, inf os.FileInfo) error) liberr.Error
|
||||
// List returns all files under a root path.
|
||||
List(rootPath string) ([]string, error)
|
||||
|
||||
// Find opens a file and returns a ReadCloser.
|
||||
// The caller is responsible for closing the returned ReadCloser.
|
||||
Find(pathFile string) (io.ReadCloser, error)
|
||||
|
||||
// Info returns file information (size, mod time, etc.).
|
||||
Info(pathFile string) (os.FileInfo, error)
|
||||
|
||||
// Temp creates a temporary file copy with progress tracking.
|
||||
// Useful for large files. See github.com/nabbar/golib/file/progress.
|
||||
Temp(pathFile string) (libfpg.Progress, error)
|
||||
|
||||
// Map iterates over all files in the embedded filesystem.
|
||||
// The provided function is called for each file.
|
||||
Map(func(pathFile string, inf os.FileInfo) error) error
|
||||
|
||||
// UseTempForFileSize sets the size threshold for using temporary files.
|
||||
// Files larger than this size will be served via Temp() method.
|
||||
UseTempForFileSize(size int64)
|
||||
|
||||
// Monitor returns health monitoring information.
|
||||
// See github.com/nabbar/golib/monitor/types for details.
|
||||
Monitor(ctx context.Context, cfg montps.Config, vrs libver.Version) (montps.Monitor, error)
|
||||
|
||||
// Get is the main Gin handler function for serving files.
|
||||
// It handles all security checks, caching, and file serving.
|
||||
Get(c *ginsdk.Context)
|
||||
|
||||
// SendFile sends a file to the client with appropriate headers.
|
||||
// This is typically called by Get() but can be used directly.
|
||||
SendFile(c *ginsdk.Context, filename string, size int64, isDownload bool, buf io.ReadCloser)
|
||||
|
||||
// Embed router registration interface
|
||||
StaticRegister
|
||||
|
||||
// Embed file serving configuration interfaces
|
||||
StaticIndex
|
||||
StaticDownload
|
||||
StaticRedirect
|
||||
StaticSpecific
|
||||
|
||||
// Embed security interfaces
|
||||
RateLimit
|
||||
PathSecurity
|
||||
SuspiciousDetection
|
||||
HeadersControl
|
||||
SecurityBackend
|
||||
}
|
||||
|
||||
// New creates a new Static file handler instance.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for lifecycle management
|
||||
// - content: Embedded filesystem containing static files
|
||||
// - embedRootDir: Optional root directory paths within the embed.FS
|
||||
//
|
||||
// The handler is initialized with:
|
||||
// - Default logger
|
||||
// - No security features enabled (must be configured)
|
||||
// - No rate limiting (must be configured)
|
||||
// - No index files (must be configured)
|
||||
//
|
||||
// Thread Safety:
|
||||
//
|
||||
// All internal data structures use atomic operations for thread-safe access
|
||||
// without mutexes, ensuring high performance under concurrent load.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// //go:embed assets/*
|
||||
// var content embed.FS
|
||||
//
|
||||
// handler := static.New(context.Background(), content, "assets")
|
||||
// handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
// handler.SetRateLimit(static.RateLimitConfig{
|
||||
// Enabled: true,
|
||||
// MaxRequests: 100,
|
||||
// Window: time.Minute,
|
||||
// })
|
||||
func New(ctx context.Context, content embed.FS, embedRootDir ...string) Static {
|
||||
s := &staticHandler{
|
||||
l: new(atomic.Value),
|
||||
c: content,
|
||||
b: new(atomic.Value),
|
||||
z: new(atomic.Int64),
|
||||
i: libctx.NewConfig[string](ctx),
|
||||
d: libctx.NewConfig[string](ctx),
|
||||
f: libctx.NewConfig[string](ctx),
|
||||
s: libctx.NewConfig[string](ctx),
|
||||
r: new(atomic.Value),
|
||||
h: new(atomic.Value),
|
||||
log: libatm.NewValue[liblog.Logger](),
|
||||
rtr: libatm.NewValue[[]string](),
|
||||
|
||||
efs: content,
|
||||
bph: libatm.NewValue[[]string](),
|
||||
siz: new(atomic.Int64),
|
||||
|
||||
idx: libctx.New[string](ctx),
|
||||
dwn: libctx.New[string](ctx),
|
||||
flw: libctx.New[string](ctx),
|
||||
spc: libctx.New[string](ctx),
|
||||
|
||||
rlc: libatm.NewValue[*RateLimitConfig](),
|
||||
rli: libatm.NewMapTyped[string, *ipTrack](),
|
||||
rlx: libatm.NewValue[context.CancelFunc](),
|
||||
|
||||
psc: libatm.NewValue[*PathSecurityConfig](),
|
||||
sus: libatm.NewValue[*SuspiciousConfig](),
|
||||
hdr: libatm.NewValue[*HeadersConfig](),
|
||||
sec: libatm.NewValue[*SecurityConfig](),
|
||||
seb: libatm.NewValue[*evtBatch](),
|
||||
}
|
||||
s._setBase(embedRootDir...)
|
||||
s._setLogger(nil)
|
||||
|
||||
s.setBase(embedRootDir...)
|
||||
s.setLogger(nil)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -21,15 +21,16 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"sync/atomic"
|
||||
|
||||
libatm "github.com/nabbar/golib/atomic"
|
||||
libctx "github.com/nabbar/golib/context"
|
||||
liblog "github.com/nabbar/golib/logger"
|
||||
)
|
||||
@@ -38,47 +39,69 @@ const (
|
||||
urlPathSeparator = "/"
|
||||
)
|
||||
|
||||
// staticHandler is the internal implementation of the Static interface.
|
||||
// All fields use atomic operations for thread-safe access without mutexes.
|
||||
//
|
||||
// The structure is organized into logical groups:
|
||||
// - Core: logger, router, embedded filesystem
|
||||
// - File configuration: index, download, follow, specific
|
||||
// - Security: rate limiting, path security, suspicious detection
|
||||
// - HTTP: headers, caching
|
||||
// - Integration: WAF/IDS/EDR security backend
|
||||
type staticHandler struct {
|
||||
l *atomic.Value // logger
|
||||
r *atomic.Value // router
|
||||
h *atomic.Value // monitor
|
||||
log libatm.Value[liblog.Logger] // logger instance
|
||||
rtr libatm.Value[[]string] // registered routes
|
||||
|
||||
c embed.FS
|
||||
b *atomic.Value // base []string
|
||||
z *atomic.Int64 // size
|
||||
efs embed.FS // embedded filesystem
|
||||
bph libatm.Value[[]string] // base paths within embed.FS
|
||||
siz *atomic.Int64 // total size counter
|
||||
|
||||
i libctx.Config[string] // index
|
||||
d libctx.Config[string] // download
|
||||
f libctx.Config[string] // follow
|
||||
s libctx.Config[string] // specific
|
||||
idx libctx.Config[string] // index file configuration
|
||||
dwn libctx.Config[string] // download file configuration
|
||||
flw libctx.Config[string] // redirect configuration
|
||||
spc libctx.Config[string] // specific handler configuration
|
||||
|
||||
// Rate limiting
|
||||
rlc libatm.Value[*RateLimitConfig] // rate limit configuration
|
||||
rli libatm.MapTyped[string, *ipTrack] // IP tracking map (IP -> tracking data)
|
||||
rlx libatm.Value[context.CancelFunc] // cleanup goroutine cancel function
|
||||
|
||||
// Path security
|
||||
psc libatm.Value[*PathSecurityConfig] // path security configuration
|
||||
|
||||
// Suspicious access detection
|
||||
sus libatm.Value[*SuspiciousConfig] // suspicious access configuration
|
||||
|
||||
// HTTP Headers
|
||||
hdr libatm.Value[*HeadersConfig] // headers configuration (cache, etag, content-type)
|
||||
|
||||
// Security backend integration (WAF/IDS/EDR)
|
||||
sec libatm.Value[*SecurityConfig] // security integration configuration
|
||||
seb libatm.Value[*evtBatch] // event batch for batching security events
|
||||
}
|
||||
|
||||
func (s *staticHandler) _setLogger(fct liblog.FuncLog) {
|
||||
if fct == nil {
|
||||
fct = s._getDefaultLogger
|
||||
func (s *staticHandler) setLogger(log liblog.Logger) {
|
||||
if log == nil {
|
||||
log = liblog.New(s.dwn)
|
||||
}
|
||||
|
||||
s.l.Store(fct())
|
||||
s.log.Store(log)
|
||||
}
|
||||
|
||||
func (s *staticHandler) _getLogger() liblog.Logger {
|
||||
i := s.l.Load()
|
||||
func (s *staticHandler) getLogger() liblog.Logger {
|
||||
i := s.log.Load()
|
||||
|
||||
if i == nil {
|
||||
return s._getDefaultLogger()
|
||||
} else if l, k := i.(liblog.FuncLog); !k {
|
||||
return s._getDefaultLogger()
|
||||
} else if log := l(); log == nil {
|
||||
return s._getDefaultLogger()
|
||||
return s.defLogger()
|
||||
} else {
|
||||
return log
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
func (s *staticHandler) _getDefaultLogger() liblog.Logger {
|
||||
return liblog.New(s.d)
|
||||
func (s *staticHandler) defLogger() liblog.Logger {
|
||||
return liblog.New(s.dwn)
|
||||
}
|
||||
|
||||
func (s *staticHandler) RegisterLogger(log liblog.FuncLog) {
|
||||
s._setLogger(log)
|
||||
func (s *staticHandler) RegisterLogger(log liblog.Logger) {
|
||||
s.setLogger(log)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
@@ -31,7 +30,6 @@ import (
|
||||
"io/fs"
|
||||
"runtime"
|
||||
|
||||
liberr "github.com/nabbar/golib/errors"
|
||||
libmon "github.com/nabbar/golib/monitor"
|
||||
moninf "github.com/nabbar/golib/monitor/info"
|
||||
montps "github.com/nabbar/golib/monitor/types"
|
||||
@@ -42,6 +40,15 @@ const (
|
||||
textEmbed = "Embed FS"
|
||||
)
|
||||
|
||||
// Monitor creates and returns a health monitor for the static file handler.
|
||||
// This integrates with github.com/nabbar/golib/monitor for health checks and status reporting.
|
||||
//
|
||||
// The monitor provides:
|
||||
// - Runtime version information
|
||||
// - Build metadata
|
||||
// - Filesystem health checks
|
||||
//
|
||||
// See github.com/nabbar/golib/monitor/types for configuration details.
|
||||
func (s *staticHandler) Monitor(ctx context.Context, cfg montps.Config, vrs libver.Version) (montps.Monitor, error) {
|
||||
res := make(map[string]interface{}, 0)
|
||||
res["runtime"] = runtime.Version()[2:]
|
||||
@@ -54,7 +61,6 @@ func (s *staticHandler) Monitor(ctx context.Context, cfg montps.Config, vrs libv
|
||||
i fs.FileInfo
|
||||
inf moninf.Info
|
||||
mon montps.Monitor
|
||||
err liberr.Error
|
||||
)
|
||||
|
||||
if inf, e = moninf.New(textEmbed); e != nil {
|
||||
@@ -65,18 +71,20 @@ func (s *staticHandler) Monitor(ctx context.Context, cfg montps.Config, vrs libv
|
||||
})
|
||||
}
|
||||
|
||||
if i, err = s._fileInfo(""); err != nil {
|
||||
inf.RegisterInfo(func() (map[string]interface{}, error) {
|
||||
return nil, err
|
||||
})
|
||||
} else {
|
||||
res["path"] = i.Name()
|
||||
inf.RegisterInfo(func() (map[string]interface{}, error) {
|
||||
return res, nil
|
||||
})
|
||||
// Try to get filesystem info from the first base path if available
|
||||
basePaths := s.getBase()
|
||||
if len(basePaths) > 0 {
|
||||
if i, e = s.fileInfo(basePaths[0]); e == nil {
|
||||
res["path"] = i.Name()
|
||||
}
|
||||
}
|
||||
|
||||
if mon, e = libmon.New(s.s, inf); e != nil {
|
||||
// Always register info with at least runtime and version data
|
||||
inf.RegisterInfo(func() (map[string]interface{}, error) {
|
||||
return res, nil
|
||||
})
|
||||
|
||||
if mon, e = libmon.New(s.dwn, inf); e != nil {
|
||||
return nil, e
|
||||
} else if e = mon.SetConfig(ctx, cfg); e != nil {
|
||||
return nil, e
|
||||
@@ -90,9 +98,12 @@ func (s *staticHandler) Monitor(ctx context.Context, cfg montps.Config, vrs libv
|
||||
return mon, nil
|
||||
}
|
||||
|
||||
// HealthCheck performs a health check on the embedded filesystem.
|
||||
// It verifies that all base paths are accessible.
|
||||
// Returns an error if any base path cannot be accessed.
|
||||
func (s *staticHandler) HealthCheck(ctx context.Context) error {
|
||||
for _, p := range s._getBase() {
|
||||
if _, err := s._fileInfo(p); err != nil {
|
||||
for _, p := range s.getBase() {
|
||||
if _, err := s.fileInfo(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
371
static/monitor_test.go
Normal file
371
static/monitor_test.go
Normal file
@@ -0,0 +1,371 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/nabbar/golib/static"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Monitor", func() {
|
||||
var (
|
||||
handler static.Static
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
handler = newTestStatic()
|
||||
ctx, cancel = context.WithCancel(testCtx)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Monitor Creation", func() {
|
||||
Context("when creating monitor", func() {
|
||||
It("should create monitor successfully", func() {
|
||||
cfg := newTestMonitorConfig()
|
||||
Expect(cfg).ToNot(BeNil())
|
||||
|
||||
vrs := newTestVersion()
|
||||
Expect(vrs).ToNot(BeNil())
|
||||
|
||||
mon, err := handler.Monitor(ctx, cfg, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
// Cleanup
|
||||
if mon != nil {
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
It("should include version information", func() {
|
||||
cfg := newTestMonitorConfig()
|
||||
|
||||
mon, err := handler.Monitor(ctx, cfg, newTestVersion())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
// Cleanup
|
||||
if mon != nil {
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
It("should use provided context", func() {
|
||||
cfg := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon, err := handler.Monitor(ctx, cfg, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
// Cleanup
|
||||
if mon != nil {
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Context("when using different configurations", func() {
|
||||
It("should accept custom config", func() {
|
||||
cfg := newTestMonitorConfig()
|
||||
Expect(cfg).ToNot(BeNil())
|
||||
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon, err := handler.Monitor(ctx, cfg, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
// Cleanup
|
||||
if mon != nil {
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Health Check", func() {
|
||||
Context("when performing health check", func() {
|
||||
It("should pass health check for valid handler", func() {
|
||||
cfg := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon, err := handler.Monitor(ctx, cfg, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
// Get health status
|
||||
if mon != nil {
|
||||
// The monitor should be healthy
|
||||
info := mon.InfoGet()
|
||||
Expect(info).ToNot(BeNil())
|
||||
|
||||
// Cleanup
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
It("should handle health check with empty base pth", func() {
|
||||
// Create handler with no explicit base pth
|
||||
h := newTestStaticWithRoot()
|
||||
|
||||
cfg := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon, err := h.Monitor(ctx, cfg, vrs)
|
||||
// Should still create monitor even if health check might fail
|
||||
if err == nil {
|
||||
Expect(mon).ToNot(BeNil())
|
||||
if mon != nil {
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Monitor Lifecycle", func() {
|
||||
Context("when managing monitor lifecycle", func() {
|
||||
It("should start and stop monitor", func() {
|
||||
cfg := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon, err := handler.Monitor(ctx, cfg, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
// Monitor should be started already (based on implementation)
|
||||
// Stop it
|
||||
if mon != nil {
|
||||
err := mon.Stop(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
})
|
||||
|
||||
It("should handle multiple monitors", func() {
|
||||
cfg1 := newTestMonitorConfig()
|
||||
cfg2 := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon1, err := handler.Monitor(ctx, cfg1, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon1).ToNot(BeNil())
|
||||
|
||||
mon2, err := handler.Monitor(ctx, cfg2, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon2).ToNot(BeNil())
|
||||
|
||||
// Cleanup
|
||||
if mon1 != nil {
|
||||
_ = mon1.Stop(ctx)
|
||||
}
|
||||
if mon2 != nil {
|
||||
_ = mon2.Stop(ctx)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Context("when context is cancelled", func() {
|
||||
It("should handle cancelled context gracefully", func() {
|
||||
localCtx, localCancel := context.WithCancel(ctx)
|
||||
|
||||
cfg := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon, err := handler.Monitor(localCtx, cfg, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
// Cancel context
|
||||
localCancel()
|
||||
|
||||
// Give it a moment to process cancellation
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Cleanup
|
||||
if mon != nil {
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
It("should handle timeout context", func() {
|
||||
localCtx, localCancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
||||
defer localCancel()
|
||||
|
||||
cfg := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon, err := handler.Monitor(localCtx, cfg, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
// Cleanup
|
||||
if mon != nil {
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Monitor Information", func() {
|
||||
Context("when querying monitor info", func() {
|
||||
It("should provide monitor information", func() {
|
||||
cfg := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon, err := handler.Monitor(ctx, cfg, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
if mon != nil {
|
||||
info := mon.InfoGet()
|
||||
Expect(info).ToNot(BeNil())
|
||||
|
||||
// Cleanup
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
})
|
||||
|
||||
It("should include runtime information", func() {
|
||||
cfg := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon, err := handler.Monitor(ctx, cfg, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
if mon != nil {
|
||||
info := mon.InfoGet()
|
||||
Expect(info).ToNot(BeNil())
|
||||
|
||||
// Should have some information
|
||||
data := info.Info()
|
||||
Expect(data).ToNot(BeNil())
|
||||
|
||||
// Cleanup
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Concurrent Monitor Access", func() {
|
||||
Context("when accessing monitor concurrently", func() {
|
||||
It("should handle concurrent info requests", func() {
|
||||
cfg := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon, err := handler.Monitor(ctx, cfg, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon).ToNot(BeNil())
|
||||
|
||||
if mon != nil {
|
||||
done := make(chan bool, 5)
|
||||
|
||||
// Concurrent info requests
|
||||
for i := 0; i < 5; i++ {
|
||||
go func() {
|
||||
defer GinkgoRecover()
|
||||
info := mon.InfoGet()
|
||||
Expect(info).ToNot(BeNil())
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 5; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error Scenarios", func() {
|
||||
Context("when encountering errors", func() {
|
||||
It("should handle invalid base pth gracefully", func() {
|
||||
// This test verifies that even with potential issues,
|
||||
// the monitor creation doesn't panic
|
||||
cfg := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
Expect(func() {
|
||||
mon, _ := handler.Monitor(ctx, cfg, vrs)
|
||||
if mon != nil {
|
||||
_ = mon.Stop(ctx)
|
||||
}
|
||||
}).ToNot(Panic())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Multiple Handlers", func() {
|
||||
Context("when using multiple static handlers", func() {
|
||||
It("should create separate monitors", func() {
|
||||
h1 := newTestStatic()
|
||||
h2 := newTestStatic()
|
||||
|
||||
cfg1 := newTestMonitorConfig()
|
||||
cfg2 := newTestMonitorConfig()
|
||||
vrs := newTestVersion()
|
||||
|
||||
mon1, err := h1.Monitor(ctx, cfg1, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon1).ToNot(BeNil())
|
||||
|
||||
mon2, err := h2.Monitor(ctx, cfg2, vrs)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mon2).ToNot(BeNil())
|
||||
|
||||
// Monitors should be independent
|
||||
Expect(mon1).ToNot(Equal(mon2))
|
||||
|
||||
// Cleanup
|
||||
if mon1 != nil {
|
||||
_ = mon1.Stop(ctx)
|
||||
}
|
||||
if mon2 != nil {
|
||||
_ = mon2.Stop(ctx)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,6 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
@@ -35,46 +34,41 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
|
||||
liberr "github.com/nabbar/golib/errors"
|
||||
libfpg "github.com/nabbar/golib/file/progress"
|
||||
libbuf "github.com/nabbar/golib/ioutils/bufferReadCloser"
|
||||
)
|
||||
|
||||
func (s *staticHandler) _getSize() int64 {
|
||||
return s.z.Load()
|
||||
func (s *staticHandler) getSize() int64 {
|
||||
return s.siz.Load()
|
||||
}
|
||||
|
||||
func (s *staticHandler) _setSize(size int64) {
|
||||
s.z.Store(size)
|
||||
func (s *staticHandler) setSize(size int64) {
|
||||
s.siz.Store(size)
|
||||
}
|
||||
|
||||
func (s *staticHandler) _getBase() []string {
|
||||
i := s.b.Load()
|
||||
func (s *staticHandler) getBase() []string {
|
||||
i := s.bph.Load()
|
||||
if i == nil {
|
||||
return make([]string, 0)
|
||||
} else if b, k := i.([]string); !k {
|
||||
return make([]string, 0)
|
||||
} else {
|
||||
return b
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
func (s *staticHandler) _setBase(base ...string) {
|
||||
var b = make([]string, 0)
|
||||
|
||||
func (s *staticHandler) setBase(base ...string) {
|
||||
if len(base) > 0 {
|
||||
b = append(b, base...)
|
||||
s.bph.Store(base)
|
||||
} else {
|
||||
s.bph.Store(make([]string, 0))
|
||||
}
|
||||
|
||||
s.b.Store(b)
|
||||
}
|
||||
|
||||
func (s *staticHandler) _listEmbed(root string) ([]fs.DirEntry, liberr.Error) {
|
||||
func (s *staticHandler) dirEntries(root string) ([]fs.DirEntry, error) {
|
||||
if root == "" {
|
||||
return nil, ErrorParamEmpty.Error(fmt.Errorf("pathfile is empty"))
|
||||
}
|
||||
|
||||
val, err := s.c.ReadDir(root)
|
||||
val, err := s.efs.ReadDir(root)
|
||||
|
||||
if err != nil && errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, ErrorFileNotFound.Error(err)
|
||||
@@ -85,29 +79,31 @@ func (s *staticHandler) _listEmbed(root string) ([]fs.DirEntry, liberr.Error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *staticHandler) _fileGet(pathFile string) (fs.FileInfo, io.ReadCloser, liberr.Error) {
|
||||
func (s *staticHandler) fileGet(pathFile string) (fs.FileInfo, io.ReadCloser, error) {
|
||||
if len(pathFile) < 1 {
|
||||
return nil, nil, ErrorParamEmpty.Error(fmt.Errorf("pathfile is empty"))
|
||||
}
|
||||
|
||||
if inf, err := s._fileInfo(pathFile); err != nil {
|
||||
if inf, err := s.fileInfo(pathFile); err != nil {
|
||||
return nil, nil, err
|
||||
} else if inf.Size() >= s._getSize() {
|
||||
r, e := s._fileTemp(pathFile)
|
||||
} else if inf.IsDir() {
|
||||
return inf, nil, ErrorFileNotFound.Error(fmt.Errorf("path is a directory: %spc", pathFile))
|
||||
} else if inf.Size() >= s.getSize() {
|
||||
r, e := s.fileTemp(pathFile)
|
||||
return inf, r, e
|
||||
} else {
|
||||
r, e := s._fileBuff(pathFile)
|
||||
r, e := s.fileBuff(pathFile)
|
||||
return inf, r, e
|
||||
}
|
||||
}
|
||||
|
||||
func (s *staticHandler) _fileInfo(pathFile string) (fs.FileInfo, liberr.Error) {
|
||||
func (s *staticHandler) fileInfo(pathFile string) (fs.FileInfo, error) {
|
||||
if pathFile == "" {
|
||||
return nil, ErrorParamEmpty.Error(fmt.Errorf("pathfile is empty"))
|
||||
}
|
||||
|
||||
var inf fs.FileInfo
|
||||
obj, err := s.c.Open(pathFile)
|
||||
obj, err := s.efs.Open(pathFile)
|
||||
|
||||
if err != nil && errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, ErrorFileNotFound.Error(err)
|
||||
@@ -130,12 +126,12 @@ func (s *staticHandler) _fileInfo(pathFile string) (fs.FileInfo, liberr.Error) {
|
||||
return inf, nil
|
||||
}
|
||||
|
||||
func (s *staticHandler) _fileBuff(pathFile string) (io.ReadCloser, liberr.Error) {
|
||||
func (s *staticHandler) fileBuff(pathFile string) (io.ReadCloser, error) {
|
||||
if pathFile == "" {
|
||||
return nil, ErrorParamEmpty.Error(fmt.Errorf("pathfile is empty"))
|
||||
}
|
||||
|
||||
obj, err := s.c.ReadFile(pathFile)
|
||||
obj, err := s.efs.ReadFile(pathFile)
|
||||
|
||||
if err != nil && errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, ErrorFileNotFound.Error(err)
|
||||
@@ -146,13 +142,13 @@ func (s *staticHandler) _fileBuff(pathFile string) (io.ReadCloser, liberr.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *staticHandler) _fileTemp(pathFile string) (libfpg.Progress, liberr.Error) {
|
||||
func (s *staticHandler) fileTemp(pathFile string) (libfpg.Progress, error) {
|
||||
if pathFile == "" {
|
||||
return nil, ErrorParamEmpty.Error(fmt.Errorf("pathfile is empty"))
|
||||
}
|
||||
|
||||
var tmp libfpg.Progress
|
||||
obj, err := s.c.Open(pathFile)
|
||||
obj, err := s.efs.Open(pathFile)
|
||||
|
||||
if err != nil && errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, ErrorFileNotFound.Error(err)
|
||||
@@ -174,18 +170,26 @@ func (s *staticHandler) _fileTemp(pathFile string) (libfpg.Progress, liberr.Erro
|
||||
return nil, ErrorFiletemp.Error(e)
|
||||
}
|
||||
|
||||
// Reset cursor to beginning of file
|
||||
if _, e = tmp.Seek(0, io.SeekStart); e != nil {
|
||||
return nil, ErrorFiletemp.Error(e)
|
||||
}
|
||||
|
||||
return tmp, nil
|
||||
}
|
||||
|
||||
// Has checks if a file exists in the embedded filesystem.
|
||||
func (s *staticHandler) Has(pathFile string) bool {
|
||||
if _, e := s._fileInfo(pathFile); e != nil {
|
||||
if _, e := s.fileInfo(pathFile); e != nil {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *staticHandler) List(rootPath string) ([]string, liberr.Error) {
|
||||
// List returns all file paths under a root directory.
|
||||
// Recursively walks the directory tree and returns relative paths.
|
||||
func (s *staticHandler) List(rootPath string) ([]string, error) {
|
||||
var (
|
||||
err error
|
||||
res = make([]string, 0)
|
||||
@@ -195,10 +199,10 @@ func (s *staticHandler) List(rootPath string) ([]string, liberr.Error) {
|
||||
)
|
||||
|
||||
if rootPath == "" {
|
||||
for _, p := range s._getBase() {
|
||||
inf, err = s._fileInfo(p)
|
||||
for _, p := range s.getBase() {
|
||||
inf, err = s.fileInfo(p)
|
||||
if err != nil {
|
||||
return nil, err.(liberr.Error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !inf.IsDir() {
|
||||
@@ -209,13 +213,13 @@ func (s *staticHandler) List(rootPath string) ([]string, liberr.Error) {
|
||||
lst, err = s.List(p)
|
||||
|
||||
if err != nil {
|
||||
return nil, err.(liberr.Error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res = append(res, lst...)
|
||||
}
|
||||
} else if ent, err = s._listEmbed(rootPath); err != nil {
|
||||
return nil, err.(liberr.Error)
|
||||
} else if ent, err = s.dirEntries(rootPath); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, f := range ent {
|
||||
|
||||
@@ -227,7 +231,7 @@ func (s *staticHandler) List(rootPath string) ([]string, liberr.Error) {
|
||||
lst, err = s.List(path.Join(rootPath, f.Name()))
|
||||
|
||||
if err != nil {
|
||||
return nil, err.(liberr.Error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res = append(res, lst...)
|
||||
@@ -237,20 +241,29 @@ func (s *staticHandler) List(rootPath string) ([]string, liberr.Error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *staticHandler) Find(pathFile string) (io.ReadCloser, liberr.Error) {
|
||||
_, r, e := s._fileGet(pathFile)
|
||||
// Find opens a file and returns a ReadCloser.
|
||||
// The caller is responsible for closing the returned ReadCloser.
|
||||
func (s *staticHandler) Find(pathFile string) (io.ReadCloser, error) {
|
||||
_, r, e := s.fileGet(pathFile)
|
||||
return r, e
|
||||
}
|
||||
|
||||
func (s *staticHandler) Info(pathFile string) (os.FileInfo, liberr.Error) {
|
||||
return s._fileInfo(pathFile)
|
||||
// Info returns file information (size, modification time, etc.).
|
||||
func (s *staticHandler) Info(pathFile string) (os.FileInfo, error) {
|
||||
return s.fileInfo(pathFile)
|
||||
}
|
||||
|
||||
func (s *staticHandler) Temp(pathFile string) (libfpg.Progress, liberr.Error) {
|
||||
return s._fileTemp(pathFile)
|
||||
// Temp creates a temporary file copy with progress tracking.
|
||||
// Useful for large files or when progress reporting is needed.
|
||||
// See github.com/nabbar/golib/file/progress for details.
|
||||
func (s *staticHandler) Temp(pathFile string) (libfpg.Progress, error) {
|
||||
return s.fileTemp(pathFile)
|
||||
}
|
||||
|
||||
func (s *staticHandler) Map(fct func(pathFile string, inf os.FileInfo) error) liberr.Error {
|
||||
// Map iterates over all files in the embedded filesystem.
|
||||
// The provided function is called for each file with its path and FileInfo.
|
||||
// If the function returns an error, iteration stops and the error is returned.
|
||||
func (s *staticHandler) Map(fct func(pathFile string, inf os.FileInfo) error) error {
|
||||
var (
|
||||
err error
|
||||
lst []string
|
||||
@@ -258,13 +271,13 @@ func (s *staticHandler) Map(fct func(pathFile string, inf os.FileInfo) error) li
|
||||
)
|
||||
|
||||
if lst, err = s.List(""); err != nil {
|
||||
return err.(liberr.Error)
|
||||
return err
|
||||
} else {
|
||||
for _, f := range lst {
|
||||
if inf, err = s._fileInfo(f); err != nil {
|
||||
return err.(liberr.Error)
|
||||
if inf, err = s.fileInfo(f); err != nil {
|
||||
return err
|
||||
} else if err = fct(f, inf); err != nil {
|
||||
return err.(liberr.Error)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +285,8 @@ func (s *staticHandler) Map(fct func(pathFile string, inf os.FileInfo) error) li
|
||||
return nil
|
||||
}
|
||||
|
||||
// UseTempForFileSize sets the threshold for using temporary files.
|
||||
// Files larger than this size will be served via the Temp() method.
|
||||
func (s *staticHandler) UseTempForFileSize(size int64) {
|
||||
s._setSize(size)
|
||||
s.setSize(size)
|
||||
}
|
||||
|
||||
365
static/pathfile_test.go
Normal file
365
static/pathfile_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PathFile Operations", func() {
|
||||
var handler any
|
||||
|
||||
BeforeEach(func() {
|
||||
handler = newTestStatic()
|
||||
})
|
||||
|
||||
Describe("Has", func() {
|
||||
Context("when file exists", func() {
|
||||
It("should return true for existing file", func() {
|
||||
h := handler.(interface{ Has(string) bool })
|
||||
Expect(h.Has("testdata/test.txt")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should return true for existing nested file", func() {
|
||||
h := handler.(interface{ Has(string) bool })
|
||||
Expect(h.Has("testdata/subdir/nested.txt")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should return true for index.html", func() {
|
||||
h := handler.(interface{ Has(string) bool })
|
||||
Expect(h.Has("testdata/index.html")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when file does not exist", func() {
|
||||
It("should return false", func() {
|
||||
h := handler.(interface{ Has(string) bool })
|
||||
Expect(h.Has("testdata/nonexistent.txt")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should return false for empty path", func() {
|
||||
h := handler.(interface{ Has(string) bool })
|
||||
Expect(h.Has("")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should return false for directory that does not exist", func() {
|
||||
h := handler.(interface{ Has(string) bool })
|
||||
Expect(h.Has("testdata/fakedir/file.txt")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("List", func() {
|
||||
Context("when listing files", func() {
|
||||
It("should list all files in root", func() {
|
||||
h := handler.(staticList)
|
||||
files, err := h.List("testdata")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(files).ToNot(BeEmpty())
|
||||
Expect(files).To(ContainElement("testdata/test.txt"))
|
||||
Expect(files).To(ContainElement("testdata/index.html"))
|
||||
})
|
||||
|
||||
It("should list files in subdirectory", func() {
|
||||
h := handler.(staticList)
|
||||
files, err := h.List("testdata/subdir")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(files).ToNot(BeEmpty())
|
||||
Expect(files).To(ContainElement("testdata/subdir/nested.txt"))
|
||||
})
|
||||
|
||||
It("should list all files recursively from empty path", func() {
|
||||
h := handler.(staticList)
|
||||
files, err := h.List("")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(files).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("should not include directories in list", func() {
|
||||
h := handler.(staticList)
|
||||
files, err := h.List("testdata")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, f := range files {
|
||||
Expect(f).ToNot(HaveSuffix("/"))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Context("when directory does not exist", func() {
|
||||
It("should return an error", func() {
|
||||
h := handler.(staticList)
|
||||
_, err := h.List("testdata/nonexistent")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Find", func() {
|
||||
Context("when file exists", func() {
|
||||
It("should return file contents", func() {
|
||||
h := handler.(staticFind)
|
||||
r, err := h.Find("testdata/test.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
|
||||
defer r.Close()
|
||||
|
||||
content, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(content)).To(ContainSubstring("This is a test file"))
|
||||
})
|
||||
|
||||
It("should return JSON file contents", func() {
|
||||
h := handler.(staticFind)
|
||||
r, err := h.Find("testdata/test.json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
|
||||
defer r.Close()
|
||||
|
||||
content, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(content)).To(ContainSubstring("test json file"))
|
||||
})
|
||||
|
||||
It("should return nested file contents", func() {
|
||||
h := handler.(staticFind)
|
||||
r, err := h.Find("testdata/subdir/nested.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
|
||||
defer r.Close()
|
||||
|
||||
content, err := io.ReadAll(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(content)).To(ContainSubstring("nested test file"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when file does not exist", func() {
|
||||
It("should return an error", func() {
|
||||
h := handler.(staticFind)
|
||||
_, err := h.Find("testdata/nonexistent.txt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when path is empty", func() {
|
||||
It("should return an error", func() {
|
||||
h := handler.(staticFind)
|
||||
_, err := h.Find("")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Info", func() {
|
||||
Context("when file exists", func() {
|
||||
It("should return file info", func() {
|
||||
h := handler.(staticInfo)
|
||||
info, err := h.Info("testdata/test.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info).ToNot(BeNil())
|
||||
Expect(info.Name()).To(Equal("test.txt"))
|
||||
Expect(info.IsDir()).To(BeFalse())
|
||||
Expect(info.Size()).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("should return correct size", func() {
|
||||
h := handler.(staticInfo)
|
||||
info, err := h.Info("testdata/large.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info).ToNot(BeNil())
|
||||
Expect(info.Size()).To(BeNumerically(">", 100))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when file does not exist", func() {
|
||||
It("should return an error", func() {
|
||||
h := handler.(staticInfo)
|
||||
_, err := h.Info("testdata/nonexistent.txt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when path is empty", func() {
|
||||
It("should return an error", func() {
|
||||
h := handler.(staticInfo)
|
||||
_, err := h.Info("")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Temp", func() {
|
||||
Context("when creating temp file", func() {
|
||||
It("should create temp file from embedded file", func() {
|
||||
h := handler.(staticTemp)
|
||||
tmp, err := h.Temp("testdata/test.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(tmp).ToNot(BeNil())
|
||||
|
||||
defer func() {
|
||||
// Clean up temp file
|
||||
if tmp.IsTemp() {
|
||||
_ = tmp.CloseDelete()
|
||||
} else {
|
||||
_ = tmp.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
content, err := io.ReadAll(tmp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(content)).To(ContainSubstring("This is a test file"))
|
||||
})
|
||||
|
||||
It("should create temp file from large file", func() {
|
||||
h := handler.(staticTemp)
|
||||
tmp, err := h.Temp("testdata/large.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(tmp).ToNot(BeNil())
|
||||
|
||||
defer func() {
|
||||
if tmp.IsTemp() {
|
||||
_ = tmp.CloseDelete()
|
||||
} else {
|
||||
_ = tmp.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
content, err := io.ReadAll(tmp)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(content)).To(BeNumerically(">", 100))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when file does not exist", func() {
|
||||
It("should return an error", func() {
|
||||
h := handler.(staticTemp)
|
||||
_, err := h.Temp("testdata/nonexistent.txt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when path is empty", func() {
|
||||
It("should return an error", func() {
|
||||
h := handler.(staticTemp)
|
||||
_, err := h.Temp("")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Map", func() {
|
||||
Context("when mapping over files", func() {
|
||||
It("should call function for each file", func() {
|
||||
h := handler.(staticMap)
|
||||
count := 0
|
||||
files := []string{}
|
||||
err := h.Map(func(pathFile string, inf os.FileInfo) error {
|
||||
count++
|
||||
files = append(files, pathFile)
|
||||
Expect(inf).ToNot(BeNil())
|
||||
Expect(inf.IsDir()).To(BeFalse())
|
||||
return nil
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(BeNumerically(">", 0))
|
||||
Expect(slices.Contains(files, "testdata/test.txt")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should stop on error", func() {
|
||||
h := handler.(staticMap)
|
||||
count := 0
|
||||
err := h.Map(func(pathFile string, inf os.FileInfo) error {
|
||||
count++
|
||||
if count == 2 {
|
||||
return io.EOF
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
})
|
||||
|
||||
It("should provide correct file info", func() {
|
||||
h := handler.(staticMap)
|
||||
err := h.Map(func(pathFile string, inf os.FileInfo) error {
|
||||
Expect(inf.Name()).ToNot(BeEmpty())
|
||||
Expect(inf.Size()).To(BeNumerically(">=", 0))
|
||||
return nil
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("UseTempForFileSize", func() {
|
||||
Context("when setting size threshold", func() {
|
||||
It("should use temp files for large files", func() {
|
||||
h := handler.(staticFindTempSize)
|
||||
|
||||
// Set threshold to 10 bytes
|
||||
h.UseTempForFileSize(10)
|
||||
|
||||
// Small file should be buffered
|
||||
r, err := h.Find("testdata/test.json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
r.Close()
|
||||
|
||||
// Large file should use temp
|
||||
r, err = h.Find("testdata/large.txt")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
defer r.Close()
|
||||
|
||||
// Clean up temp file if created
|
||||
if namer, ok := r.(interface{ Name() string }); ok {
|
||||
defer os.Remove(namer.Name())
|
||||
}
|
||||
})
|
||||
|
||||
It("should accept zero size", func() {
|
||||
h := handler.(staticTempSize)
|
||||
h.UseTempForFileSize(0)
|
||||
})
|
||||
|
||||
It("should accept large size", func() {
|
||||
h := handler.(staticTempSize)
|
||||
h.UseTempForFileSize(1024 * 1024 * 100) // 100MB
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
159
static/pathsecurity.go
Normal file
159
static/pathsecurity.go
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
ginsdk "github.com/gin-gonic/gin"
|
||||
loglvl "github.com/nabbar/golib/logger/level"
|
||||
)
|
||||
|
||||
// SetPathSecurity configures path security validation rules.
|
||||
// This method is thread-safe and uses atomic operations.
|
||||
func (s *staticHandler) SetPathSecurity(cfg PathSecurityConfig) {
|
||||
s.psc.Store(&cfg)
|
||||
}
|
||||
|
||||
// GetPathSecurity returns the current path security configuration.
|
||||
// This method is thread-safe and uses atomic operations.
|
||||
func (s *staticHandler) GetPathSecurity() PathSecurityConfig {
|
||||
if cfg := s.psc.Load(); cfg != nil {
|
||||
return *cfg
|
||||
}
|
||||
return PathSecurityConfig{}
|
||||
}
|
||||
|
||||
// validatePath validates a path against path traversal and other attacks.
|
||||
//
|
||||
// This method performs multiple security checks in sequence:
|
||||
// 1. Null byte injection (before path cleaning)
|
||||
// 2. Path traversal detection (.. sequences)
|
||||
// 3. Path escape attempts (absolute paths)
|
||||
// 4. Dot file access (hidden files)
|
||||
// 5. Maximum path depth
|
||||
// 6. Blocked patterns
|
||||
// 7. Double slashes (logged but not blocked)
|
||||
//
|
||||
// The validation happens BEFORE path.Clean() for critical checks
|
||||
// to prevent normalization from hiding attacks.
|
||||
func (s *staticHandler) validatePath(requestPath string) error {
|
||||
cfg := s.GetPathSecurity()
|
||||
|
||||
if !cfg.Enabled {
|
||||
return nil // Validation disabled
|
||||
}
|
||||
|
||||
// 1. Check for null bytes (attack attempt) - BEFORE cleaning
|
||||
if strings.Contains(requestPath, "\x00") {
|
||||
s.logSecurityEvent("null byte in path", requestPath)
|
||||
return ErrorPathInvalid.Error(fmt.Errorf("null byte in path: %s", requestPath))
|
||||
}
|
||||
|
||||
// 2. Check for path traversal BEFORE Clean() - look for .. in original path
|
||||
if strings.Contains(requestPath, "..") {
|
||||
s.logSecurityEvent("path traversal attempt", requestPath)
|
||||
return ErrorPathTraversal.Error(fmt.Errorf("path traversal attempt: %s", requestPath))
|
||||
}
|
||||
|
||||
// 3. Clean the path for subsequent checks
|
||||
cleaned := path.Clean(requestPath)
|
||||
|
||||
// 4. Verify the path doesn't escape above root
|
||||
if strings.HasPrefix(cleaned, "..") || strings.HasPrefix(cleaned, "/..") {
|
||||
s.logSecurityEvent("path escape attempt", requestPath)
|
||||
return ErrorPathTraversal.Error(fmt.Errorf("path escape attempt: %s", requestPath))
|
||||
}
|
||||
|
||||
// 5. Check for hidden files (dot files)
|
||||
if !cfg.AllowDotFiles {
|
||||
parts := strings.Split(cleaned, "/")
|
||||
for _, part := range parts {
|
||||
if part != "" && part != "." && strings.HasPrefix(part, ".") {
|
||||
s.logSecurityEvent("dot file access attempt", requestPath)
|
||||
return ErrorPathDotFile.Error(fmt.Errorf("dot file not allowed: %s", requestPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check maximum path depth
|
||||
if cfg.MaxPathDepth > 0 {
|
||||
depth := strings.Count(cleaned, "/")
|
||||
if depth > cfg.MaxPathDepth {
|
||||
s.logSecurityEvent("max path depth exceeded", requestPath)
|
||||
return ErrorPathDepth.Error(fmt.Errorf("path depth %d exceeds maximum %d: %s", depth, cfg.MaxPathDepth, requestPath))
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Check for blocked patterns
|
||||
for _, pattern := range cfg.BlockedPatterns {
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cleaned, pattern) {
|
||||
s.logSecurityEvent("blocked pattern in path", requestPath)
|
||||
return ErrorPathBlocked.Error(fmt.Errorf("blocked pattern '%s' in path: %s", pattern, requestPath))
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Check for double slashes (even though Clean() handles them)
|
||||
if strings.Contains(requestPath, "//") {
|
||||
// Log but don't reject since Clean() fixes this
|
||||
ent := s.getLogger().Entry(loglvl.DebugLevel, "double slashes in path (cleaned)")
|
||||
ent.FieldAdd("originalPath", requestPath)
|
||||
ent.FieldAdd("cleanedPath", cleaned)
|
||||
ent.Log()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// logSecurityEvent logs a path security violation event.
|
||||
func (s *staticHandler) logSecurityEvent(event, path string) {
|
||||
ent := s.getLogger().Entry(loglvl.WarnLevel, "path security violation")
|
||||
ent.FieldAdd("event", event)
|
||||
ent.FieldAdd("path", path)
|
||||
ent.Log()
|
||||
}
|
||||
|
||||
// notifyPathSecurityEvent notifies external systems of a path security violation.
|
||||
// This creates a high-severity security event with the violation reason.
|
||||
func (s *staticHandler) notifyPathSecurityEvent(c *ginsdk.Context, eventType SecurityEventType, reason string) {
|
||||
details := map[string]string{
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
event := s.newSecuEvt(c, eventType, "high", true, details)
|
||||
s.notifySecuEvt(event)
|
||||
}
|
||||
|
||||
// IsPathSafe checks if a path is safe to serve (for external use).
|
||||
// Returns true if the path passes all security validations.
|
||||
func (s *staticHandler) IsPathSafe(requestPath string) bool {
|
||||
return s.validatePath(requestPath) == nil
|
||||
}
|
||||
341
static/pathsecurity_test.go
Normal file
341
static/pathsecurity_test.go
Normal file
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/nabbar/golib/static"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Path SecurityBackend", func() {
|
||||
Describe("Configuration", func() {
|
||||
Context("when setting path security config", func() {
|
||||
It("should store and retrieve configuration", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
cfg := static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: false,
|
||||
MaxPathDepth: 5,
|
||||
BlockedPatterns: []string{".git", "admin"},
|
||||
}
|
||||
|
||||
handler.SetPathSecurity(cfg)
|
||||
|
||||
retrieved := handler.GetPathSecurity()
|
||||
Expect(retrieved.Enabled).To(BeTrue())
|
||||
Expect(retrieved.AllowDotFiles).To(BeFalse())
|
||||
Expect(retrieved.MaxPathDepth).To(Equal(5))
|
||||
Expect(retrieved.BlockedPatterns).To(ContainElement(".git"))
|
||||
Expect(retrieved.BlockedPatterns).To(ContainElement("admin"))
|
||||
})
|
||||
|
||||
It("should use default config", func() {
|
||||
cfg := static.DefaultPathSecurityConfig()
|
||||
|
||||
Expect(cfg.Enabled).To(BeTrue())
|
||||
Expect(cfg.AllowDotFiles).To(BeFalse())
|
||||
Expect(cfg.MaxPathDepth).To(Equal(10))
|
||||
Expect(cfg.BlockedPatterns).To(ContainElement(".git"))
|
||||
Expect(cfg.BlockedPatterns).To(ContainElement(".env"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Path Traversal Protection", func() {
|
||||
Context("when path security is disabled", func() {
|
||||
It("should allow all paths", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: false,
|
||||
})
|
||||
|
||||
// All paths should be considered safe when disabled
|
||||
Expect(handler.IsPathSafe("/static/../../../etc/passwd")).To(BeTrue())
|
||||
Expect(handler.IsPathSafe("/static/.git/config")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when path security is enabled", func() {
|
||||
It("should block path traversal with ..", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Classic path traversal attempt
|
||||
w := performRequest(engine, "GET", "/static/../../../etc/passwd")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("should block encoded path traversal", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// URL encoded path traversal
|
||||
w := performRequest(engine, "GET", "/static/..%2F..%2Fetc/passwd")
|
||||
// Gin decodes this, so it should be blocked
|
||||
Expect(w.Code).To(Or(Equal(http.StatusForbidden), Equal(http.StatusNotFound)))
|
||||
})
|
||||
|
||||
It("should block paths with null bytes", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
// Null byte injection attempt
|
||||
Expect(handler.IsPathSafe("/static/test.txt\x00.exe")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should block dot files by default", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Dot file access
|
||||
w := performRequest(engine, "GET", "/static/.env")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
|
||||
w = performRequest(engine, "GET", "/static/.git/config")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
|
||||
w = performRequest(engine, "GET", "/static/subdir/.hidden")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("should allow dot files when configured", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: true,
|
||||
MaxPathDepth: 10,
|
||||
})
|
||||
|
||||
// Dot files should be allowed (but may 404 if they don't exist)
|
||||
Expect(handler.IsPathSafe("/static/.env")).To(BeTrue())
|
||||
Expect(handler.IsPathSafe("/static/.git/config")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should enforce max path depth", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
MaxPathDepth: 3,
|
||||
})
|
||||
|
||||
// Shallow paths should pass
|
||||
Expect(handler.IsPathSafe("/a/b/c")).To(BeTrue())
|
||||
|
||||
// Deep paths should be blocked
|
||||
Expect(handler.IsPathSafe("/a/b/c/d/e")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should block configured patterns", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
BlockedPatterns: []string{"admin", "wp-admin", ".git"},
|
||||
})
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Blocked patterns
|
||||
w := performRequest(engine, "GET", "/static/admin/config.php")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
|
||||
w = performRequest(engine, "GET", "/static/wp-admin/index.php")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
|
||||
w = performRequest(engine, "GET", "/static/.git/config")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("should allow normal paths", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
// Normal paths should pass validation
|
||||
Expect(handler.IsPathSafe("/static/test.txt")).To(BeTrue())
|
||||
Expect(handler.IsPathSafe("/static/subdir/file.css")).To(BeTrue())
|
||||
Expect(handler.IsPathSafe("/static/assets/img/logo.png")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Edge Cases", func() {
|
||||
Context("when handling special characters", func() {
|
||||
It("should handle double slashes", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
// Double slashes are cleaned by path.Clean()
|
||||
Expect(handler.IsPathSafe("/static//test.txt")).To(BeTrue())
|
||||
Expect(handler.IsPathSafe("/static///subdir//file.txt")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should handle trailing slashes", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
// Trailing slashes should be OK
|
||||
Expect(handler.IsPathSafe("/static/test.txt/")).To(BeTrue())
|
||||
Expect(handler.IsPathSafe("/static/subdir/")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should handle unicode characters", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
// Unicode should be allowed (if the file exists)
|
||||
Expect(handler.IsPathSafe("/static/regular-file.txt")).To(BeTrue())
|
||||
Expect(handler.IsPathSafe("/static/文件.txt")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Real-World Attack Vectors", func() {
|
||||
Context("when testing known attack patterns", func() {
|
||||
It("should block Windows path traversal", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
// Windows-style path traversal
|
||||
Expect(handler.IsPathSafe("/static/..\\..\\windows\\system32")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should block absolute path attempts", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Absolute path attempts (will be cleaned but may fail other checks)
|
||||
w := performRequest(engine, "GET", "/../../../etc/passwd")
|
||||
Expect(w.Code).NotTo(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("should block common sensitive files", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
BlockedPatterns: []string{
|
||||
".env", ".git", ".svn", ".htaccess",
|
||||
"config.php", "wp-config.php",
|
||||
},
|
||||
})
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
sensitiveFiles := []string{
|
||||
"/static/.env",
|
||||
"/static/.git/HEAD",
|
||||
"/static/.svn/entries",
|
||||
"/static/.htaccess",
|
||||
"/static/config.php",
|
||||
"/static/wp-config.php",
|
||||
}
|
||||
|
||||
for _, path := range sensitiveFiles {
|
||||
w := performRequest(engine, "GET", path)
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden),
|
||||
"Path %s should be blocked", path)
|
||||
}
|
||||
})
|
||||
|
||||
It("should handle mixed case pattern matching", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{
|
||||
Enabled: true,
|
||||
AllowDotFiles: true, // Allow dot files to test pattern matching only
|
||||
BlockedPatterns: []string{".git"},
|
||||
})
|
||||
|
||||
// Patterns are case-sensitive
|
||||
Expect(handler.IsPathSafe("/static/.GIT/config")).To(BeTrue())
|
||||
Expect(handler.IsPathSafe("/static/.git/config")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Integration with Route Handling", func() {
|
||||
Context("when combined with file serving", func() {
|
||||
It("should serve valid files and block invalid paths", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Valid file should work (200 or 404, but not 403)
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).NotTo(Equal(http.StatusForbidden))
|
||||
|
||||
// Path traversal should be blocked with 403
|
||||
w = performRequest(engine, "GET", "/static/../etc/passwd")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("should work with rate limiting", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
// Configure both rate limit and path security
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 10,
|
||||
Window: 60000000000, // 1 minute in nanoseconds
|
||||
})
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Normal request should pass both checks
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).NotTo(Equal(http.StatusForbidden))
|
||||
Expect(w.Code).NotTo(Equal(http.StatusTooManyRequests))
|
||||
|
||||
// Path traversal should be blocked before rate limit check
|
||||
w = performRequest(engine, "GET", "/static/../passwd")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
295
static/ratelimit.go
Normal file
295
static/ratelimit.go
Normal file
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
ginsdk "github.com/gin-gonic/gin"
|
||||
libatm "github.com/nabbar/golib/atomic"
|
||||
loglvl "github.com/nabbar/golib/logger/level"
|
||||
)
|
||||
|
||||
// ipTrack stores rate limiting information for a single IP address.
|
||||
// All fields use atomic operations for thread-safe access.
|
||||
type ipTrack struct {
|
||||
pth libatm.MapTyped[string, time.Time] // path -> timestamp mapping
|
||||
req *atomic.Int64 // total request counter
|
||||
fts libatm.Value[time.Time] // first request timestamp
|
||||
lts libatm.Value[time.Time] // last request timestamp
|
||||
}
|
||||
|
||||
// SetRateLimit configures IP-based rate limiting.
|
||||
// This method is thread-safe and uses atomic operations.
|
||||
//
|
||||
// If rate limiting is enabled, it starts a background goroutine
|
||||
// for periodic cache cleanup.
|
||||
func (s *staticHandler) SetRateLimit(cfg RateLimitConfig) {
|
||||
s.rlc.Store(&cfg)
|
||||
|
||||
if cfg.Enabled {
|
||||
x, n := context.WithCancel(context.Background())
|
||||
|
||||
o := s.rlx.Swap(n)
|
||||
if o != nil {
|
||||
o()
|
||||
}
|
||||
|
||||
go s.cleanupRateLimitCache(x)
|
||||
}
|
||||
}
|
||||
|
||||
// GetRateLimit returns the current rate limiting configuration.
|
||||
// This method is thread-safe and uses atomic operations.
|
||||
func (s *staticHandler) GetRateLimit() RateLimitConfig {
|
||||
if cfg := s.rlc.Load(); cfg != nil {
|
||||
return *cfg
|
||||
}
|
||||
return RateLimitConfig{}
|
||||
}
|
||||
|
||||
// IsRateLimited checks if an IP address is currently rate limited.
|
||||
// It counts unique file paths requested within the time window.
|
||||
// Returns true if the IP has exceeded the configured MaxRequests.
|
||||
func (s *staticHandler) IsRateLimited(ip string) bool {
|
||||
cfg := s.GetRateLimit()
|
||||
if !cfg.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
trk := s.getRateLimitTracker(ip)
|
||||
if trk == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-cfg.Window)
|
||||
|
||||
// Count unique paths within the time window
|
||||
up := 0 // unique paths
|
||||
trk.pth.Range(func(_ string, ts time.Time) bool {
|
||||
if ts.After(windowStart) {
|
||||
up++
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return up >= cfg.MaxRequests
|
||||
}
|
||||
|
||||
// ResetRateLimit clears all rate limiting data for a specific IP address.
|
||||
// This can be used to manually unblock an IP or reset counters.
|
||||
func (s *staticHandler) ResetRateLimit(ip string) {
|
||||
s.rli.Delete(ip)
|
||||
}
|
||||
|
||||
// getRateLimitTracker retrieves or creates a rate limit tracker for an IP.
|
||||
// This method is thread-safe and uses atomic map operations.
|
||||
func (s *staticHandler) getRateLimitTracker(ip string) *ipTrack {
|
||||
|
||||
if i, l := s.rli.Load(ip); l && i != nil {
|
||||
return i
|
||||
}
|
||||
|
||||
// Create new tracker
|
||||
t := &ipTrack{
|
||||
pth: libatm.NewMapTyped[string, time.Time](),
|
||||
req: new(atomic.Int64),
|
||||
fts: libatm.NewValue[time.Time](),
|
||||
lts: libatm.NewValue[time.Time](),
|
||||
}
|
||||
s.rli.Store(ip, t)
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// checkRateLimit checks and enforces rate limiting for a request.
|
||||
// Returns true if the request is allowed, false if rate limited.
|
||||
//
|
||||
// This method:
|
||||
// - Extracts client IP (respecting X-Forwarded-For)
|
||||
// - Checks IP whitelist
|
||||
// - Counts unique paths in time window
|
||||
// - Returns 429 Too Many Requests if limit exceeded
|
||||
// - Adds rate limit headers (X-RateLimit-*)
|
||||
// - Notifies security backend on limit exceeded
|
||||
func (s *staticHandler) checkRateLimit(c *ginsdk.Context) bool {
|
||||
cfg := s.GetRateLimit()
|
||||
|
||||
if !cfg.Enabled {
|
||||
return true // Rate limiting disabled
|
||||
}
|
||||
|
||||
// Extract real client IP
|
||||
ip := c.ClientIP()
|
||||
|
||||
// Check if IP is whitelisted
|
||||
if slices.Contains(cfg.WhitelistIPs, ip) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Get or create tracker for this IP
|
||||
t := s.getRateLimitTracker(ip)
|
||||
if t == nil {
|
||||
return true // No cache, allow request
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
win := now.Add(-cfg.Window)
|
||||
|
||||
// Clean old entries and count requests in window
|
||||
up := 0
|
||||
t.pth.Range(func(k string, ts time.Time) bool {
|
||||
if ts.After(win) {
|
||||
up++
|
||||
} else {
|
||||
t.pth.Delete(k)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Check if limit is exceeded
|
||||
if up >= cfg.MaxRequests {
|
||||
// Calculate time until reset
|
||||
ots := time.Now()
|
||||
t.pth.Range(func(k string, ts time.Time) bool {
|
||||
if ts.After(win) && ts.Before(ots) {
|
||||
ots = ts
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
rst := ots.Add(cfg.Window)
|
||||
rty := int(time.Until(rst).Seconds())
|
||||
if rty < 0 {
|
||||
rty = 0
|
||||
}
|
||||
|
||||
// Log the event
|
||||
ent := s.getLogger().Entry(loglvl.WarnLevel, "rate limit exceeded")
|
||||
ent.FieldAdd("ip", ip)
|
||||
ent.FieldAdd("up", up)
|
||||
ent.FieldAdd("limit", cfg.MaxRequests)
|
||||
ent.FieldAdd("window", cfg.Window.String())
|
||||
ent.FieldAdd("retryAfter", rty)
|
||||
ent.Log()
|
||||
|
||||
// Notify WAF/IDS/EDR
|
||||
details := map[string]string{
|
||||
"unique_paths": strconv.Itoa(up),
|
||||
"limit": strconv.Itoa(cfg.MaxRequests),
|
||||
"retry_after": strconv.Itoa(rty),
|
||||
}
|
||||
event := s.newSecuEvt(c, EventTypeRateLimit, "medium", true, details)
|
||||
s.notifySecuEvt(event)
|
||||
|
||||
// Add rate limiting headers
|
||||
c.Header("Retry-After", strconv.Itoa(rty))
|
||||
c.Header("X-RateLimit-Limit", strconv.Itoa(cfg.MaxRequests))
|
||||
c.Header("X-RateLimit-Remaining", "0")
|
||||
c.Header("X-RateLimit-Reset", strconv.FormatInt(rst.Unix(), 10))
|
||||
|
||||
c.AbortWithStatusJSON(429, ginsdk.H{
|
||||
"error": "rate limit exceeded",
|
||||
"message": "Too many different file requests. Please retry after " + strconv.Itoa(rty) + " seconds.",
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Record current request
|
||||
t.pth.Store(c.Request.URL.Path, now)
|
||||
t.lts.Store(now)
|
||||
t.req.Add(1)
|
||||
|
||||
if t.fts.Load().IsZero() {
|
||||
t.fts.Store(now)
|
||||
}
|
||||
|
||||
// Add informative headers
|
||||
rem := cfg.MaxRequests - up - 1
|
||||
if rem < 0 {
|
||||
rem = 0
|
||||
}
|
||||
|
||||
c.Header("X-RateLimit-Limit", strconv.Itoa(cfg.MaxRequests))
|
||||
c.Header("X-RateLimit-Remaining", strconv.Itoa(rem))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanupRateLimitCache periodically cleans up expired rate limit data.
|
||||
// This background goroutine removes old entries to prevent memory leaks.
|
||||
// It runs at the configured CleanupInterval and stops when context is canceled.
|
||||
func (s *staticHandler) cleanupRateLimitCache(ctx context.Context) {
|
||||
cfg := s.GetRateLimit()
|
||||
|
||||
if !cfg.Enabled || cfg.CleanupInterval <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(cfg.CleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
now := time.Now()
|
||||
win := now.Add(-cfg.Window * 2) // Keep data a bit longer
|
||||
|
||||
s.rli.Range(func(key string, value *ipTrack) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
allExp := true
|
||||
value.pth.Range(func(ph string, ts time.Time) bool {
|
||||
if ts.IsZero() {
|
||||
value.pth.Delete(ph)
|
||||
return true
|
||||
} else if ts.After(win) {
|
||||
allExp = false
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if allExp {
|
||||
s.rli.Delete(key)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
370
static/ratelimit_test.go
Normal file
370
static/ratelimit_test.go
Normal file
@@ -0,0 +1,370 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nabbar/golib/static"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Rate Limiting", func() {
|
||||
Describe("Configuration", func() {
|
||||
Context("when setting rate limit config", func() {
|
||||
It("should store and retrieve configuration", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
cfg := static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 50,
|
||||
Window: 30 * time.Second,
|
||||
CleanupInterval: 2 * time.Minute,
|
||||
WhitelistIPs: []string{"192.168.1.1"},
|
||||
TrustedProxies: []string{"10.0.0.1"},
|
||||
}
|
||||
|
||||
handler.SetRateLimit(cfg)
|
||||
|
||||
retrieved := handler.GetRateLimit()
|
||||
Expect(retrieved.Enabled).To(BeTrue())
|
||||
Expect(retrieved.MaxRequests).To(Equal(50))
|
||||
Expect(retrieved.Window).To(Equal(30 * time.Second))
|
||||
Expect(retrieved.CleanupInterval).To(Equal(2 * time.Minute))
|
||||
Expect(retrieved.WhitelistIPs).To(ContainElement("192.168.1.1"))
|
||||
Expect(retrieved.TrustedProxies).To(ContainElement("10.0.0.1"))
|
||||
})
|
||||
|
||||
It("should use default config", func() {
|
||||
cfg := static.DefaultRateLimitConfig()
|
||||
|
||||
Expect(cfg.Enabled).To(BeTrue())
|
||||
Expect(cfg.MaxRequests).To(Equal(100))
|
||||
Expect(cfg.Window).To(Equal(1 * time.Minute))
|
||||
Expect(cfg.CleanupInterval).To(Equal(5 * time.Minute))
|
||||
Expect(cfg.WhitelistIPs).To(ContainElement("127.0.0.1"))
|
||||
Expect(cfg.WhitelistIPs).To(ContainElement("::1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Basic Rate Limiting", func() {
|
||||
Context("when rate limiting is disabled", func() {
|
||||
It("should allow unlimited requests", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Make many requests without rate limiting
|
||||
for i := 0; i < 150; i++ {
|
||||
w := performRequest(engine, "GET", fmt.Sprintf("/static/test%d.txt", i%10))
|
||||
// Should either succeed or 404, but never 429
|
||||
Expect(w.Code).NotTo(Equal(http.StatusTooManyRequests))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Context("when rate limiting is enabled", func() {
|
||||
It("should enforce request limits", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
// Configure strict rate limit for testing
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 5,
|
||||
Window: 1 * time.Minute,
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
WhitelistIPs: []string{},
|
||||
TrustedProxies: []string{},
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// First 5 unique files should succeed
|
||||
for i := 0; i < 5; i++ {
|
||||
w := performRequest(engine, "GET", fmt.Sprintf("/static/file%d.txt", i))
|
||||
Expect(w.Code).To(Or(Equal(http.StatusOK), Equal(http.StatusNotFound)))
|
||||
Expect(w.Code).NotTo(Equal(http.StatusTooManyRequests))
|
||||
}
|
||||
|
||||
// 6th unique file should be rate limited
|
||||
w := performRequest(engine, "GET", "/static/file6.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusTooManyRequests))
|
||||
Expect(w.Header().Get("X-RateLimit-Limit")).To(Equal("5"))
|
||||
Expect(w.Header().Get("X-RateLimit-Remaining")).To(Equal("0"))
|
||||
Expect(w.Header().Get("Retry-After")).NotTo(BeEmpty())
|
||||
})
|
||||
|
||||
It("should not count duplicate requests", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 5,
|
||||
Window: 1 * time.Minute,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Request same file multiple times
|
||||
for i := 0; i < 10; i++ {
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
// Should never be rate limited since it's the same file
|
||||
Expect(w.Code).NotTo(Equal(http.StatusTooManyRequests))
|
||||
}
|
||||
})
|
||||
|
||||
It("should include rate limit headers", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 10,
|
||||
Window: 1 * time.Minute,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
|
||||
limit := w.Header().Get("X-RateLimit-Limit")
|
||||
Expect(limit).To(Equal("10"))
|
||||
|
||||
remaining := w.Header().Get("X-RateLimit-Remaining")
|
||||
Expect(remaining).NotTo(BeEmpty())
|
||||
|
||||
remainingInt, err := strconv.Atoi(remaining)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(remainingInt).To(BeNumerically("<=", 10))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Whitelist", func() {
|
||||
Context("when IP is whitelisted", func() {
|
||||
It("should bypass rate limiting", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
// Note: In tests, ClientIP() will be empty or a test value
|
||||
// This test verifies the whitelist logic works
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 2,
|
||||
Window: 1 * time.Minute,
|
||||
WhitelistIPs: []string{"127.0.0.1", ""},
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Should allow unlimited requests from whitelisted IP
|
||||
for i := 0; i < 10; i++ {
|
||||
w := performRequest(engine, "GET", fmt.Sprintf("/static/file%d.txt", i))
|
||||
Expect(w.Code).NotTo(Equal(http.StatusTooManyRequests))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Window Management", func() {
|
||||
Context("when window expires", func() {
|
||||
It("should reset counter after window", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 3,
|
||||
Window: 500 * time.Millisecond, // Short window for testing
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Use up the limit
|
||||
for i := 0; i < 3; i++ {
|
||||
w := performRequest(engine, "GET", fmt.Sprintf("/static/file%d.txt", i))
|
||||
Expect(w.Code).NotTo(Equal(http.StatusTooManyRequests))
|
||||
}
|
||||
|
||||
// Should be limited
|
||||
w := performRequest(engine, "GET", "/static/file4.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusTooManyRequests))
|
||||
|
||||
// Wait for window to expire
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
|
||||
// Should work again
|
||||
w = performRequest(engine, "GET", "/static/file5.txt")
|
||||
Expect(w.Code).NotTo(Equal(http.StatusTooManyRequests))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IsRateLimited", func() {
|
||||
Context("when checking rate limit status", func() {
|
||||
It("should correctly report limited status", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 2,
|
||||
Window: 1 * time.Minute,
|
||||
})
|
||||
|
||||
testIP := "192.168.1.100"
|
||||
|
||||
// Initially not limited
|
||||
Expect(handler.IsRateLimited(testIP)).To(BeFalse())
|
||||
|
||||
// The actual test uses ClientIP() which returns empty or test value
|
||||
// This just tests the method works
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ResetRateLimit", func() {
|
||||
Context("when resetting rate limit", func() {
|
||||
It("should clear counter for IP", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 2,
|
||||
Window: 1 * time.Minute,
|
||||
})
|
||||
|
||||
testIP := "192.168.1.100"
|
||||
|
||||
// Reset should not panic
|
||||
Expect(func() {
|
||||
handler.ResetRateLimit(testIP)
|
||||
}).NotTo(Panic())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Concurrent Access", func() {
|
||||
Context("when multiple goroutines access simultaneously", func() {
|
||||
It("should handle concurrent requests safely", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 50,
|
||||
Window: 1 * time.Minute,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
successCount := 0
|
||||
rateLimitCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
// Launch 20 goroutines
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
defer GinkgoRecover()
|
||||
|
||||
// Each goroutine makes 5 unique requests
|
||||
for j := 0; j < 5; j++ {
|
||||
w := performRequest(engine, "GET", fmt.Sprintf("/static/concurrent_%d_%d.txt", id, j))
|
||||
|
||||
mu.Lock()
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
rateLimitCount++
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Should have mix of success and rate limits (total 100 requests, limit 50)
|
||||
GinkgoWriter.Printf("Success: %d, Rate Limited: %d\n", successCount, rateLimitCount)
|
||||
Expect(successCount + rateLimitCount).To(Equal(100))
|
||||
Expect(rateLimitCount).To(BeNumerically(">", 0)) // Some should be rate limited
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Cleanup", func() {
|
||||
Context("when cleanup runs", func() {
|
||||
It("should not panic during cleanup", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 10,
|
||||
Window: 100 * time.Millisecond,
|
||||
CleanupInterval: 200 * time.Millisecond,
|
||||
})
|
||||
|
||||
// Wait for cleanup to run
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Should not panic
|
||||
Expect(handler.GetRateLimit().Enabled).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should cancel cleanup on reconfiguration", func() {
|
||||
handler := newTestStatic()
|
||||
|
||||
// Set initial config
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 10,
|
||||
Window: 1 * time.Second,
|
||||
CleanupInterval: 100 * time.Millisecond,
|
||||
})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Reconfigure
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 20,
|
||||
Window: 2 * time.Second,
|
||||
CleanupInterval: 200 * time.Millisecond,
|
||||
})
|
||||
|
||||
// Wait and verify no panic
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
cfg := handler.GetRateLimit()
|
||||
Expect(cfg.MaxRequests).To(Equal(20))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
209
static/route.go
209
static/route.go
@@ -21,7 +21,6 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
@@ -30,29 +29,29 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/nabbar/golib/router/header"
|
||||
|
||||
ginsdk "github.com/gin-gonic/gin"
|
||||
ginrdr "github.com/gin-gonic/gin/render"
|
||||
liberr "github.com/nabbar/golib/errors"
|
||||
loglvl "github.com/nabbar/golib/logger/level"
|
||||
librtr "github.com/nabbar/golib/router"
|
||||
rtrhdr "github.com/nabbar/golib/router/header"
|
||||
|
||||
_ "github.com/ugorji/go/codec"
|
||||
)
|
||||
|
||||
func (s *staticHandler) _makeRoute(group, route string) string {
|
||||
func (s *staticHandler) makeRoute(group, route string) string {
|
||||
if group == "" {
|
||||
group = urlPathSeparator
|
||||
}
|
||||
return path.Join(group, route)
|
||||
}
|
||||
|
||||
func (s *staticHandler) genRegisterRouter(route, group string, register any, router ...ginsdk.HandlerFunc) {
|
||||
func (s *staticHandler) genRegRouter(route, group string, register any, router ...ginsdk.HandlerFunc) {
|
||||
var (
|
||||
ok bool
|
||||
rte string
|
||||
@@ -62,12 +61,12 @@ func (s *staticHandler) genRegisterRouter(route, group string, register any, rou
|
||||
|
||||
if register == nil {
|
||||
return
|
||||
} else if reg, ok = register.(librtr.RegisterRouter); ok {
|
||||
rte = s._makeRoute(urlPathSeparator, route)
|
||||
grp = nil
|
||||
} else if grp, ok = register.(librtr.RegisterRouterInGroup); ok {
|
||||
rte = s._makeRoute(group, route)
|
||||
rte = s.makeRoute(group, route)
|
||||
reg = nil
|
||||
} else if reg, ok = register.(librtr.RegisterRouter); ok {
|
||||
rte = s.makeRoute(urlPathSeparator, route)
|
||||
grp = nil
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@@ -78,61 +77,118 @@ func (s *staticHandler) genRegisterRouter(route, group string, register any, rou
|
||||
router = append(make([]ginsdk.HandlerFunc, 0), s.Get)
|
||||
}
|
||||
|
||||
if rtr := s._getRouter(); len(rtr) > 0 {
|
||||
s._setRouter(append(rtr, rte))
|
||||
if rtr := s.getRouter(); len(rtr) > 0 {
|
||||
s.setRouter(append(rtr, rte))
|
||||
} else {
|
||||
s._setRouter(append(make([]string, 0), rte))
|
||||
s.setRouter(append(make([]string, 0), rte))
|
||||
}
|
||||
|
||||
if reg != nil {
|
||||
reg(http.MethodGet, path.Clean(route), router...)
|
||||
reg(http.MethodGet, path.Join(route, urlPathSeparator+"*file"), router...)
|
||||
}
|
||||
|
||||
if grp != nil {
|
||||
grp(group, http.MethodGet, path.Clean(route), router...)
|
||||
grp(group, http.MethodGet, path.Join(route, urlPathSeparator+"*file"), router...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *staticHandler) RegisterRouter(route string, register librtr.RegisterRouter, router ...ginsdk.HandlerFunc) {
|
||||
s.genRegisterRouter(route, "", register, router...)
|
||||
s.genRegRouter(route, "/", register, router...)
|
||||
}
|
||||
|
||||
func (s *staticHandler) RegisterRouterInGroup(route, group string, register librtr.RegisterRouterInGroup, router ...ginsdk.HandlerFunc) {
|
||||
s.genRegisterRouter(route, group, register, router...)
|
||||
s.genRegRouter(route, group, register, router...)
|
||||
}
|
||||
|
||||
// Get is the main HTTP handler for serving static files.
|
||||
// It implements the complete request flow including:
|
||||
// - Rate limiting
|
||||
// - Path security validation
|
||||
// - Redirects
|
||||
// - Custom handlers
|
||||
// - Index files
|
||||
// - ETag caching
|
||||
// - Suspicious access detection
|
||||
// - MIME type validation
|
||||
func (s *staticHandler) Get(c *ginsdk.Context) {
|
||||
// Check rate limiting first
|
||||
if !s.checkRateLimit(c) {
|
||||
return // Rate limit exceeded, 429 response already sent
|
||||
}
|
||||
|
||||
calledFile := c.Request.URL.Path
|
||||
|
||||
// Validate path security
|
||||
if err := s.validatePath(calledFile); err != nil {
|
||||
ent := s.getLogger().Entry(loglvl.WarnLevel, "Get Static file, path validation failed")
|
||||
ent.FieldAdd("requestPath", calledFile)
|
||||
ent.ErrorAdd(false, err)
|
||||
ent.Log()
|
||||
|
||||
// Notify WAF/IDS/EDR
|
||||
s.notifyPathSecurityEvent(c, EventTypePathTraversal, err.Error())
|
||||
|
||||
// Log suspicious access before returning
|
||||
s.checkAndLogSuspicious(c, http.StatusForbidden)
|
||||
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if dest := s.GetRedirect("", calledFile); dest != "" {
|
||||
url := c.Request.URL
|
||||
url.Path = dest
|
||||
|
||||
ent := s.getLogger().Entry(loglvl.DebugLevel, "Get redirect to url")
|
||||
ent.FieldAdd("requestPath", calledFile)
|
||||
ent.FieldAdd("redirectPath", dest)
|
||||
ent.Log()
|
||||
|
||||
c.Redirect(http.StatusPermanentRedirect, url.String())
|
||||
return
|
||||
}
|
||||
|
||||
if router := s.GetSpecific("", calledFile); router != nil {
|
||||
ent := s.getLogger().Entry(loglvl.DebugLevel, "Get call specific")
|
||||
ent.FieldAdd("requestPath", calledFile)
|
||||
ent.FieldAdd("called", reflect.ValueOf(router).String())
|
||||
ent.Log()
|
||||
|
||||
router(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if an index file is configured for this route
|
||||
if idx := s.GetIndex("", calledFile); idx != "" {
|
||||
ent := s.getLogger().Entry(loglvl.DebugLevel, "Get call index")
|
||||
ent.FieldAdd("requestPath", calledFile)
|
||||
ent.FieldAdd("index", idx)
|
||||
ent.Log()
|
||||
|
||||
// Use the index file directly, no further path processing needed
|
||||
calledFile = idx
|
||||
} else {
|
||||
for _, p := range s._getRouter() {
|
||||
// Normal file path processing
|
||||
for _, p := range s.getRouter() {
|
||||
if p == urlPathSeparator {
|
||||
continue
|
||||
}
|
||||
calledFile = strings.TrimLeft(calledFile, p)
|
||||
calledFile = strings.TrimPrefix(calledFile, p)
|
||||
}
|
||||
calledFile = strings.Trim(calledFile, urlPathSeparator)
|
||||
|
||||
ent := s.getLogger().Entry(loglvl.DebugLevel, "Get call index")
|
||||
ent.FieldAdd("requestPath", c.Request.URL.Path)
|
||||
ent.FieldAdd("cleanedPath", calledFile)
|
||||
ent.Log()
|
||||
}
|
||||
|
||||
calledFile = strings.Trim(calledFile, urlPathSeparator)
|
||||
|
||||
if !s.Has(calledFile) {
|
||||
for _, p := range s._getBase() {
|
||||
old := calledFile
|
||||
|
||||
for _, p := range s.getBase() {
|
||||
f := path.Join(p, calledFile)
|
||||
|
||||
if s.Has(f) {
|
||||
@@ -140,51 +196,128 @@ func (s *staticHandler) Get(c *ginsdk.Context) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if old != calledFile {
|
||||
ent := s.getLogger().Entry(loglvl.DebugLevel, "Get search file")
|
||||
ent.FieldAdd("requestPath", c.Request.URL.Path)
|
||||
ent.FieldAdd("oldCalledPath", old)
|
||||
ent.FieldAdd("newCalledPath", calledFile)
|
||||
ent.Log()
|
||||
}
|
||||
}
|
||||
|
||||
if !s.Has(calledFile) {
|
||||
ent := s.getLogger().Entry(loglvl.WarnLevel, "Get cannot find file")
|
||||
ent.FieldAdd("requestPath", c.Request.URL.Path)
|
||||
ent.FieldAdd("cleanedPath", calledFile)
|
||||
ent.Log()
|
||||
|
||||
// Log suspicious access
|
||||
s.checkAndLogSuspicious(c, http.StatusNotFound)
|
||||
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
err liberr.Error
|
||||
err error
|
||||
buf io.ReadCloser
|
||||
inf fs.FileInfo
|
||||
)
|
||||
|
||||
if inf, buf, err = s._fileGet(calledFile); err != nil {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
ent := s._getLogger().Entry(loglvl.ErrorLevel, "get file info")
|
||||
ent.FieldAdd("filePath", calledFile)
|
||||
ent.FieldAdd("requestPath", c.Request.URL.Path)
|
||||
ent.ErrorAdd(true, err)
|
||||
ent.Log()
|
||||
if buf != nil {
|
||||
_ = buf.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
inf, buf, err = s.fileGet(calledFile)
|
||||
defer func() {
|
||||
if buf != nil {
|
||||
_ = buf.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
s.SendFile(c, calledFile, inf.Size(), s.IsDownload(calledFile), buf)
|
||||
if err != nil {
|
||||
// Check if it'spc a "not found" error (including directories without index)
|
||||
if liberr.Has(err, ErrorFileNotFound) {
|
||||
ent := s.getLogger().Entry(loglvl.WarnLevel, "file not found or directory without index")
|
||||
ent.FieldAdd("requestPath", c.Request.URL.Path)
|
||||
ent.FieldAdd("cleanedPath", calledFile)
|
||||
ent.ErrorAdd(false, err)
|
||||
ent.Log()
|
||||
|
||||
// Log suspicious access
|
||||
s.checkAndLogSuspicious(c, http.StatusNotFound)
|
||||
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
} else {
|
||||
ent := s.getLogger().Entry(loglvl.ErrorLevel, "get file error")
|
||||
ent.FieldAdd("requestPath", c.Request.URL.Path)
|
||||
ent.FieldAdd("cleanedPath", calledFile)
|
||||
ent.ErrorAdd(true, err)
|
||||
ent.Log()
|
||||
|
||||
// Log suspicious access
|
||||
s.checkAndLogSuspicious(c, http.StatusInternalServerError)
|
||||
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
} else if inf != nil && inf.IsDir() {
|
||||
// This should not happen anymore, but keep as safety net
|
||||
ent := s.getLogger().Entry(loglvl.WarnLevel, "directory access without index")
|
||||
ent.FieldAdd("requestPath", c.Request.URL.Path)
|
||||
ent.FieldAdd("cleanedPath", calledFile)
|
||||
ent.Log()
|
||||
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
} else {
|
||||
ent := s.getLogger().Entry(loglvl.DebugLevel, "get file info")
|
||||
ent.FieldAdd("requestPath", c.Request.URL.Path)
|
||||
ent.FieldAdd("cleanedPath", calledFile)
|
||||
ent.FieldAdd("pathSize", inf.Size())
|
||||
ent.Log()
|
||||
|
||||
// Check ETag and potentially return 304 Not Modified
|
||||
if s.setETagHeader(c, calledFile, inf.Size(), inf.ModTime()) {
|
||||
// Cache hit - client already has correct version
|
||||
ent = s.getLogger().Entry(loglvl.DebugLevel, "cache hit - returning 304")
|
||||
ent.FieldAdd("requestPath", c.Request.URL.Path)
|
||||
ent.FieldAdd("cleanedPath", calledFile)
|
||||
ent.Log()
|
||||
|
||||
c.AbortWithStatus(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
// Log suspicious access (even for successful requests)
|
||||
s.checkAndLogSuspicious(c, http.StatusOK)
|
||||
|
||||
s.SendFile(c, calledFile, inf.Size(), s.IsDownload(calledFile), buf)
|
||||
}
|
||||
}
|
||||
|
||||
// SendFile sends a file to the client with appropriate headers.
|
||||
// This method:
|
||||
// - Validates and sets Content-Type
|
||||
// - Sets cache headers
|
||||
// - Handles downloads (Content-Disposition)
|
||||
// - Streams the file content
|
||||
func (s *staticHandler) SendFile(c *ginsdk.Context, filename string, size int64, isDownload bool, buf io.ReadCloser) {
|
||||
head := header.NewHeaders()
|
||||
head := rtrhdr.NewHeaders()
|
||||
|
||||
// Check and set Content-Type
|
||||
mimeType, err := s.setContentTypeHeader(c, filename)
|
||||
if err != nil {
|
||||
// MIME type denied
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Set cache headers
|
||||
s.setCacheHeaders(c)
|
||||
|
||||
if isDownload {
|
||||
head.Add("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", path.Base(filename)))
|
||||
head.Add("Content-Disposition", fmt.Sprintf("attachment; filename=\"%spc\"", path.Base(filename)))
|
||||
}
|
||||
|
||||
c.Render(http.StatusOK, ginrdr.Reader{
|
||||
ContentLength: size,
|
||||
ContentType: mime.TypeByExtension(path.Ext(filename)),
|
||||
ContentType: mimeType,
|
||||
Headers: head.Header(),
|
||||
Reader: buf,
|
||||
})
|
||||
|
||||
@@ -21,27 +21,29 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
|
||||
func (s *staticHandler) _getRouter() []string {
|
||||
if i := s.r.Load(); i == nil {
|
||||
return make([]string, 0)
|
||||
} else if o, ok := i.([]string); !ok {
|
||||
// getRouter returns the list of registered routes.
|
||||
// Returns an empty slice if no routes are registered.
|
||||
func (s *staticHandler) getRouter() []string {
|
||||
if i := s.rtr.Load(); i == nil {
|
||||
return make([]string, 0)
|
||||
} else {
|
||||
return o
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
func (s *staticHandler) _setRouter(val []string) {
|
||||
var rtr = make([]string, 0)
|
||||
|
||||
if len(val) > 0 {
|
||||
copy(rtr, val)
|
||||
// setRouter stores the list of registered routes.
|
||||
// Makes a copy of the slice to prevent external modifications.
|
||||
func (s *staticHandler) setRouter(val []string) {
|
||||
if len(val) == 0 {
|
||||
s.rtr.Store(make([]string, 0))
|
||||
return
|
||||
}
|
||||
|
||||
s.r.Store(rtr)
|
||||
rtr := make([]string, len(val))
|
||||
copy(rtr, val)
|
||||
s.rtr.Store(rtr)
|
||||
}
|
||||
|
||||
444
static/router_test.go
Normal file
444
static/router_test.go
Normal file
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/nabbar/golib/static"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Router", func() {
|
||||
Describe("RegisterRouter", func() {
|
||||
Context("when registering without group", func() {
|
||||
It("should serve files from root", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static", testMiddleware)
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("This is a test file"))
|
||||
Expect(w.Header().Get("X-Test-Middleware")).To(Equal("true"))
|
||||
})
|
||||
|
||||
It("should serve JSON files", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.json")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("test json file"))
|
||||
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json"))
|
||||
})
|
||||
|
||||
It("should serve nested files", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/subdir/nested.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("nested test file"))
|
||||
})
|
||||
|
||||
It("should return 404 for non-existent files", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/nonexistent.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("should serve CSS files with correct content type", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/assets/style.css")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/css"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when using multiple middlewares", func() {
|
||||
It("should apply all middlewares", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static", newMiddleware(1), newMiddleware(2))
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("X-Test-Middleware-1")).To(Equal("true"))
|
||||
Expect(w.Header().Get("X-Test-Middleware-2")).To(Equal("true"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RegisterRouterInGroup", func() {
|
||||
Context("when registering with group", func() {
|
||||
It("should serve files from group", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouterInGroup(handler, "/static", "/api")
|
||||
|
||||
w := performRequest(engine, "GET", "/api/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("This is a test file"))
|
||||
})
|
||||
|
||||
It("should not serve files without group prefix", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouterInGroup(handler, "/static", "/api")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("should apply middlewares in group", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouterInGroup(handler, "/static", "/api", testMiddleware)
|
||||
|
||||
w := performRequest(engine, "GET", "/api/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("X-Test-Middleware")).To(Equal("true"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SendFile", func() {
|
||||
Context("when sending regular file", func() {
|
||||
It("should send file with correct headers", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("This is a test file"))
|
||||
Expect(w.Header().Get("Content-Type")).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("should send HTML files", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/index.html")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("Test Index Page"))
|
||||
Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/html"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when file is marked for download", func() {
|
||||
It("should add Content-Disposition header", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetDownload("testdata/test.txt", true)
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Disposition")).To(ContainSubstring("attachment"))
|
||||
Expect(w.Header().Get("Content-Disposition")).To(ContainSubstring("test.txt"))
|
||||
})
|
||||
|
||||
It("should not add Content-Disposition for normal files", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Disposition")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Index Handling", func() {
|
||||
Context("when index is set", func() {
|
||||
It("should serve index file for route", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetIndex("", "/static", "testdata/index.html")
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("Test Index Page"))
|
||||
})
|
||||
|
||||
It("should serve index for exact route match", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetIndex("", "/static/", "testdata/index.html")
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("Test Index Page"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when index is not set", func() {
|
||||
It("should return 404 for directory pth", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/subdir")
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Redirect Handling", func() {
|
||||
Context("when redirect is set", func() {
|
||||
It("should redirect to destination", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetRedirect("", "/static/old", "", "/static/test.txt")
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/old")
|
||||
Expect(w.Code).To(Equal(http.StatusPermanentRedirect))
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
Expect(location).To(ContainSubstring("/static/test.txt"))
|
||||
})
|
||||
|
||||
It("should preserve query parameters on redirect", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetRedirect("", "/static/old", "", "/static/new")
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/old?param=value")
|
||||
Expect(w.Code).To(Equal(http.StatusPermanentRedirect))
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
Expect(location).To(ContainSubstring("param=value"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when redirect is not set", func() {
|
||||
It("should serve file normally", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Specific Handler", func() {
|
||||
Context("when specific handler is set", func() {
|
||||
It("should use custom handler", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSpecific("", "/static/custom", customMiddlewareOK("Custom Response", nil))
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/custom")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("Custom Response"))
|
||||
})
|
||||
|
||||
It("should override file serving", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSpecific("", "/static/test.txt", customMiddlewareOK("Overridden", nil))
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("Overridden"))
|
||||
})
|
||||
|
||||
It("should allow custom status codes", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSpecific("", "/static/custom", customMiddlewareCreated("Created"))
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/custom")
|
||||
Expect(w.Code).To(Equal(http.StatusCreated))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Logger Integration", func() {
|
||||
Context("when logger is registered", func() {
|
||||
It("should accept logger without error", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
// Use nil logger (should create default)
|
||||
Expect(func() {
|
||||
handler.RegisterLogger(nil)
|
||||
}).ToNot(Panic())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Complex Routing Scenarios", func() {
|
||||
Context("when combining features", func() {
|
||||
It("should handle redirect before specific handler", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetRedirect("", "/static/path", "", "/static/redirect")
|
||||
handler.SetSpecific("", "/static/path", customMiddlewareOK("Custom", nil))
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/path")
|
||||
// Redirect should take precedence
|
||||
Expect(w.Code).To(Equal(http.StatusPermanentRedirect))
|
||||
})
|
||||
|
||||
It("should handle specific handler before index", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetIndex("", "/static/path", "testdata/index.html")
|
||||
handler.SetSpecific("", "/static/path", customMiddlewareOK("Custom", nil))
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/path")
|
||||
// Specific handler should take precedence
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(Equal("Custom"))
|
||||
})
|
||||
|
||||
It("should serve index when no redirect or specific handler", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetIndex("", "/static", "testdata/index.html")
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("Test Index Page"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when handling different file types", func() {
|
||||
It("should serve all file types correctly", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Test TXT
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// Test JSON
|
||||
w = performRequest(engine, "GET", "/static/test.json")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// Test HTML
|
||||
w = performRequest(engine, "GET", "/static/index.html")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// Test CSS
|
||||
w = performRequest(engine, "GET", "/static/assets/style.css")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error Handling", func() {
|
||||
Context("when encountering errors", func() {
|
||||
It("should handle path traversal attempts", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/../../../etc/passwd")
|
||||
// Should not be able to access files outside embed.FS
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("should handle double slashes", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static//test.txt")
|
||||
// Should still work due to path cleaning
|
||||
Expect(w.Code).To(Or(Equal(http.StatusOK), Equal(http.StatusNotFound)))
|
||||
})
|
||||
|
||||
It("should handle trailing slashes", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt/")
|
||||
// Trailing slashes are stripped, so the file should be served normally
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Body.String()).To(ContainSubstring("This is a test file"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Base Path Handling", func() {
|
||||
Context("when using custom base pth", func() {
|
||||
It("should serve files from custom base", func() {
|
||||
handler := newTestStaticWithRoot("testdata")
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("should handle multiple base pth", func() {
|
||||
handler := newTestStaticWithRoot("testdata", "testdata/subdir")
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
w = performRequest(engine, "GET", "/static/nested.txt")
|
||||
Expect(w.Code).To(Or(Equal(http.StatusOK), Equal(http.StatusNotFound)))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Case Sensitivity", func() {
|
||||
Context("when checking file pth", func() {
|
||||
It("should be case sensitive", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/Test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
|
||||
w = performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Content Length", func() {
|
||||
Context("when serving files", func() {
|
||||
It("should include content length header", func() {
|
||||
handler := newTestStatic()
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
contentLength := w.Header().Get("Content-Length")
|
||||
// Should have content length (may vary based on implementation)
|
||||
if contentLength != "" {
|
||||
Expect(strings.TrimSpace(contentLength)).ToNot(BeEmpty())
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
460
static/security.go
Normal file
460
static/security.go
Normal file
@@ -0,0 +1,460 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
ginsdk "github.com/gin-gonic/gin"
|
||||
libatm "github.com/nabbar/golib/atomic"
|
||||
libhtc "github.com/nabbar/golib/httpcli"
|
||||
loglvl "github.com/nabbar/golib/logger/level"
|
||||
)
|
||||
|
||||
// secEvt represents a security event to be reported to external systems.
|
||||
// This is a private type used internally for security event notifications.
|
||||
// Events are sent to webhooks or callbacks configured in SecurityConfig.
|
||||
type secEvt struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
EventType SecurityEventType `json:"event_type"`
|
||||
Severity string `json:"severity"` // low, medium, high, critical
|
||||
IP string `json:"ip"`
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
StatusCode int `json:"status_code"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Referer string `json:"referer"`
|
||||
Details map[string]string `json:"details,omitempty"`
|
||||
Blocked bool `json:"blocked"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
XForwardedFor string `json:"x_forwarded_for,omitempty"`
|
||||
}
|
||||
|
||||
// SetSecurityBackend configures the security backend integration.
|
||||
// This method is thread-safe and uses atomic operations.
|
||||
//
|
||||
// If batch processing is enabled (BatchSize > 0), it initializes
|
||||
// the batch processing system.
|
||||
func (s *staticHandler) SetSecurityBackend(cfg SecurityConfig) {
|
||||
s.sec.Store(&cfg)
|
||||
|
||||
// Initialize batch system if necessary
|
||||
if cfg.Enabled && cfg.BatchSize > 0 {
|
||||
s.initSecuBatch()
|
||||
}
|
||||
}
|
||||
|
||||
// GetSecurityBackend returns the current security backend configuration.
|
||||
// This method is thread-safe and uses atomic operations.
|
||||
func (s *staticHandler) GetSecurityBackend() SecurityConfig {
|
||||
if cfg := s.sec.Load(); cfg != nil {
|
||||
return *cfg
|
||||
}
|
||||
return SecurityConfig{}
|
||||
}
|
||||
|
||||
// AddSecurityCallback registers a Go callback function for security events.
|
||||
// The callback will be invoked asynchronously when security events occur.
|
||||
// This method is thread-safe.
|
||||
func (s *staticHandler) AddSecurityCallback(callback SecuEvtCallback) {
|
||||
cfg := s.GetSecurityBackend()
|
||||
cfg.Callbacks = append(cfg.Callbacks, callback)
|
||||
s.SetSecurityBackend(cfg)
|
||||
}
|
||||
|
||||
// notifySecuEvt notifies external systems of a security event.
|
||||
// This method handles:
|
||||
// - Severity filtering
|
||||
// - Go callbacks (async)
|
||||
// - Webhooks (sync/async)
|
||||
// - Batch processing
|
||||
//
|
||||
// The method is non-blocking if WebhookAsync is true or callbacks are used.
|
||||
func (s *staticHandler) notifySecuEvt(event secEvt) {
|
||||
cfg := s.GetSecurityBackend()
|
||||
|
||||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Check minimum severity
|
||||
if !s.shouldNotifySeverity(event.Severity, cfg.MinSeverity) {
|
||||
return
|
||||
}
|
||||
|
||||
// Go callbacks
|
||||
for _, callback := range cfg.Callbacks {
|
||||
if callback != nil {
|
||||
go callback(event) // Async to avoid blocking
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook
|
||||
if cfg.WebhookURL != "" {
|
||||
if cfg.BatchSize > 0 {
|
||||
s.qEvtForBatch(event)
|
||||
} else {
|
||||
if cfg.WebhookAsync {
|
||||
go s.sendWebhook(event, cfg)
|
||||
} else {
|
||||
s.sendWebhook(event, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendWebhook sends a single security event to the configured webhook URL.
|
||||
// The event is sent as JSON or CEF format depending on configuration.
|
||||
// Errors are logged but do not interrupt the request handling.
|
||||
func (s *staticHandler) sendWebhook(event secEvt, cfg SecurityConfig) {
|
||||
var (
|
||||
err error
|
||||
buf *bytes.Buffer
|
||||
cnt []byte
|
||||
cli = libhtc.GetClient()
|
||||
req *http.Request
|
||||
rsp *http.Response
|
||||
)
|
||||
|
||||
defer func() {
|
||||
if rsp != nil && rsp.Body != nil {
|
||||
_ = rsp.Body.Close()
|
||||
}
|
||||
if buf != nil {
|
||||
buf.Reset()
|
||||
}
|
||||
if len(cnt) > 0 {
|
||||
cnt = cnt[:0]
|
||||
}
|
||||
}()
|
||||
|
||||
cli.Timeout = cfg.WebhookTimeout
|
||||
|
||||
if cfg.EnableCEFFormat {
|
||||
buf = bytes.NewBuffer([]byte(s.formatCEF(event)))
|
||||
} else if cnt, err = json.Marshal(event); err != nil {
|
||||
ent := s.getLogger().Entry(loglvl.ErrorLevel, "failed to marshal security event")
|
||||
ent.FieldAdd("url", cfg.WebhookURL)
|
||||
ent.ErrorAdd(true, err)
|
||||
ent.Log()
|
||||
return
|
||||
} else {
|
||||
buf = bytes.NewBuffer(cnt)
|
||||
}
|
||||
|
||||
if req, err = http.NewRequest("POST", cfg.WebhookURL, buf); err != nil {
|
||||
ent := s.getLogger().Entry(loglvl.ErrorLevel, "failed to create webhook request")
|
||||
ent.FieldAdd("url", cfg.WebhookURL)
|
||||
ent.ErrorAdd(true, err)
|
||||
ent.Log()
|
||||
return
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
if cfg.EnableCEFFormat {
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
} else {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
for key, value := range cfg.WebhookHeaders {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
if rsp, err = cli.Do(req); err != nil {
|
||||
ent := s.getLogger().Entry(loglvl.ErrorLevel, "webhook request failed")
|
||||
ent.FieldAdd("url", cfg.WebhookURL)
|
||||
ent.ErrorAdd(true, err)
|
||||
ent.Log()
|
||||
return
|
||||
}
|
||||
|
||||
if rsp.StatusCode >= 400 {
|
||||
ent := s.getLogger().Entry(loglvl.WarnLevel, "webhook returned error status")
|
||||
ent.FieldAdd("url", cfg.WebhookURL)
|
||||
ent.FieldAdd("status", rsp.StatusCode)
|
||||
ent.Log()
|
||||
}
|
||||
}
|
||||
|
||||
// formatCEF formats an event in CEF (Common Event Format).
|
||||
// CEF is a standard format supported by many SIEM systems including
|
||||
// Splunk, ArcSight, and QRadar.
|
||||
//
|
||||
// Format: CEF:Version|Device Vendor|Device Product|Device Version|Signature ID|Name|Severity|Extension
|
||||
func (s *staticHandler) formatCEF(event secEvt) string {
|
||||
return fmt.Sprintf(
|
||||
"CEF:0|golib|static|1.0|%s|%s|%s|src=%s spt=%s request=%s cs1Label=UserAgent cs1=%s cs2Label=Referer cs2=%s outcome=%s",
|
||||
event.EventType,
|
||||
event.EventType,
|
||||
s.severityToCEF(event.Severity),
|
||||
event.IP,
|
||||
event.Method,
|
||||
event.Path,
|
||||
event.UserAgent,
|
||||
event.Referer,
|
||||
s.blockedToOutcome(event.Blocked),
|
||||
)
|
||||
}
|
||||
|
||||
// severityToCEF converts severity level to CEF numeric value (0-10).
|
||||
// Mapping:
|
||||
// - low: 3
|
||||
// - medium: 5
|
||||
// - high: 8
|
||||
// - critical: 10
|
||||
func (s *staticHandler) severityToCEF(severity string) string {
|
||||
switch severity {
|
||||
case "low":
|
||||
return "3"
|
||||
case "medium":
|
||||
return "5"
|
||||
case "high":
|
||||
return "8"
|
||||
case "critical":
|
||||
return "10"
|
||||
default:
|
||||
return "5"
|
||||
}
|
||||
}
|
||||
|
||||
// blockedToOutcome converts blocked status to CEF outcome field.
|
||||
func (s *staticHandler) blockedToOutcome(blocked bool) string {
|
||||
if blocked {
|
||||
return "blocked"
|
||||
}
|
||||
return "allowed"
|
||||
}
|
||||
|
||||
// shouldNotifySeverity checks if an event's severity meets the minimum threshold.
|
||||
// Returns true if the event should be notified.
|
||||
func (s *staticHandler) shouldNotifySeverity(eventSeverity, minSeverity string) bool {
|
||||
severityLevels := map[string]int{
|
||||
"low": 1,
|
||||
"medium": 2,
|
||||
"high": 3,
|
||||
"critical": 4,
|
||||
}
|
||||
|
||||
eventLevel := severityLevels[eventSeverity]
|
||||
minLevel := severityLevels[minSeverity]
|
||||
|
||||
return eventLevel >= minLevel
|
||||
}
|
||||
|
||||
// newSecuEvt creates a security event from Gin context.
|
||||
// It extracts relevant information from the HTTP request including
|
||||
// IP address, headers, and request details.
|
||||
func (s *staticHandler) newSecuEvt(c *ginsdk.Context, eventType SecurityEventType, severity string, blocked bool, details map[string]string) secEvt {
|
||||
evt := secEvt{
|
||||
Timestamp: time.Now(),
|
||||
EventType: eventType,
|
||||
Severity: severity,
|
||||
IP: c.ClientIP(),
|
||||
Path: c.Request.URL.Path,
|
||||
Method: c.Request.Method,
|
||||
StatusCode: c.Writer.Status(),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
Referer: c.GetHeader("Referer"),
|
||||
Details: details,
|
||||
Blocked: blocked,
|
||||
RemoteAddr: c.Request.RemoteAddr,
|
||||
}
|
||||
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
evt.XForwardedFor = xff
|
||||
}
|
||||
|
||||
return evt
|
||||
}
|
||||
|
||||
// evtBatch manages batched security events using atomic operations.
|
||||
// This structure is thread-safe without mutexes:
|
||||
// - seq: Atomic counter for event sequencing
|
||||
// - evt: Atomic map storing events
|
||||
// - tms: Atomic map storing flush timer
|
||||
type evtBatch struct {
|
||||
seq *atomic.Uint64
|
||||
evt libatm.MapTyped[uint64, secEvt]
|
||||
tms libatm.MapTyped[uint8, *time.Timer]
|
||||
}
|
||||
|
||||
// initSecuBatch initializes the batch processing system.
|
||||
// This is called automatically when batch processing is enabled.
|
||||
func (s *staticHandler) initSecuBatch() {
|
||||
s.seb.Store(&evtBatch{
|
||||
seq: new(atomic.Uint64),
|
||||
evt: libatm.NewMapTyped[uint64, secEvt](),
|
||||
tms: libatm.NewMapTyped[uint8, *time.Timer](),
|
||||
})
|
||||
}
|
||||
|
||||
// qEvtForBatch adds an event to the batch queue.
|
||||
// When the batch is full (reaches BatchSize), it's sent immediately.
|
||||
// Otherwise, a timer is started to flush after BatchTimeout.
|
||||
func (s *staticHandler) qEvtForBatch(event secEvt) {
|
||||
b := s.seb.Load()
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
|
||||
b.seq.Add(1)
|
||||
b.evt.Store(b.seq.Load(), event)
|
||||
|
||||
cfg := s.GetSecurityBackend()
|
||||
|
||||
// Send immediately if batch is full
|
||||
if s.batchLen(b) >= cfg.BatchSize {
|
||||
s.flushBatch(b, cfg)
|
||||
return
|
||||
}
|
||||
|
||||
// Start or reset timer
|
||||
if _, l := b.tms.Load(0); !l {
|
||||
b.tms.Store(0, time.AfterFunc(cfg.BatchTimeout, func() {
|
||||
s.flushBatchTimeout()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// flushBatchTimeout flushes the batch when timeout occurs.
|
||||
// This is called by the timer created in qEvtForBatch.
|
||||
func (s *staticHandler) flushBatchTimeout() {
|
||||
b := s.seb.Load()
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cfg := s.GetSecurityBackend()
|
||||
s.flushBatch(b, cfg)
|
||||
}
|
||||
|
||||
func (s *staticHandler) batchLen(b *evtBatch) int {
|
||||
nbe := 0
|
||||
b.evt.Range(func(_ uint64, _ secEvt) bool {
|
||||
nbe++
|
||||
return true
|
||||
})
|
||||
return nbe
|
||||
}
|
||||
|
||||
// flushBatch sends all batched events to the webhook.
|
||||
// This method is thread-safe and clears the batch after sending.
|
||||
func (s *staticHandler) flushBatch(b *evtBatch, cfg SecurityConfig) {
|
||||
if s.batchLen(b) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Copy events
|
||||
evt := make([]secEvt, 0, b.seq.Load())
|
||||
b.seq.Store(0)
|
||||
b.evt.Range(func(k uint64, v secEvt) bool {
|
||||
evt = append(evt, v)
|
||||
b.evt.Delete(k)
|
||||
return true
|
||||
})
|
||||
|
||||
// Clear the batch
|
||||
if t, l := b.tms.Load(0); l && t != nil {
|
||||
t.Stop()
|
||||
b.tms.Delete(0)
|
||||
} else if l {
|
||||
b.tms.Delete(0)
|
||||
}
|
||||
|
||||
// Send as batch
|
||||
go s.sendBatchWebhook(evt, cfg)
|
||||
}
|
||||
|
||||
// sendBatchWebhook sends multiple events in a single webhook call.
|
||||
// The events are sent as JSON array with event count.
|
||||
// This reduces network overhead compared to individual requests.
|
||||
func (s *staticHandler) sendBatchWebhook(events []secEvt, cfg SecurityConfig) {
|
||||
var (
|
||||
err error
|
||||
cli = libhtc.GetClient()
|
||||
cnt []byte
|
||||
buf *bytes.Buffer
|
||||
req *http.Request
|
||||
rsp *http.Response
|
||||
)
|
||||
|
||||
defer func() {
|
||||
if rsp != nil && rsp.Body != nil {
|
||||
_ = rsp.Body.Close()
|
||||
}
|
||||
if buf != nil {
|
||||
buf.Reset()
|
||||
}
|
||||
if len(cnt) > 0 {
|
||||
cnt = cnt[:0]
|
||||
}
|
||||
}()
|
||||
|
||||
cli.Timeout = cfg.WebhookTimeout
|
||||
cnt, err = json.Marshal(map[string]interface{}{
|
||||
"evt": events,
|
||||
"count": len(events),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
ent := s.getLogger().Entry(loglvl.ErrorLevel, "failed to marshal batch event to webhook")
|
||||
ent.ErrorAdd(true, err)
|
||||
ent.Log()
|
||||
return
|
||||
} else {
|
||||
buf = bytes.NewBuffer(cnt)
|
||||
}
|
||||
|
||||
if req, err = http.NewRequest("POST", cfg.WebhookURL, buf); err != nil {
|
||||
ent := s.getLogger().Entry(loglvl.ErrorLevel, "failed to create request to send events to webhook")
|
||||
ent.FieldAdd("url", cfg.WebhookURL)
|
||||
ent.FieldAdd("eventCount", len(events))
|
||||
ent.ErrorAdd(true, err)
|
||||
ent.Log()
|
||||
return
|
||||
} else {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
for key, value := range cfg.WebhookHeaders {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
if rsp, err = cli.Do(req); err != nil {
|
||||
ent := s.getLogger().Entry(loglvl.ErrorLevel, "batch webhook request failed")
|
||||
ent.FieldAdd("url", cfg.WebhookURL)
|
||||
ent.FieldAdd("eventCount", len(events))
|
||||
ent.ErrorAdd(true, err)
|
||||
ent.Log()
|
||||
return
|
||||
}
|
||||
}
|
||||
491
static/security_test.go
Normal file
491
static/security_test.go
Normal file
@@ -0,0 +1,491 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/nabbar/golib/static"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Security Backend Integration", func() {
|
||||
Describe("Configuration", func() {
|
||||
Context("when setting security backend config", func() {
|
||||
It("should store and retrieve configuration", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
cfg := static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: "http://localhost:9999/webhook",
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
WebhookAsync: true,
|
||||
MinSeverity: "high",
|
||||
BatchSize: 10,
|
||||
BatchTimeout: 30 * time.Second,
|
||||
EnableCEFFormat: false,
|
||||
}
|
||||
|
||||
handler.SetSecurityBackend(cfg)
|
||||
|
||||
retrieved := handler.GetSecurityBackend()
|
||||
Expect(retrieved.Enabled).To(BeTrue())
|
||||
Expect(retrieved.WebhookURL).To(Equal("http://localhost:9999/webhook"))
|
||||
Expect(retrieved.MinSeverity).To(Equal("high"))
|
||||
Expect(retrieved.BatchSize).To(Equal(10))
|
||||
})
|
||||
|
||||
It("should use default config", func() {
|
||||
cfg := static.DefaultSecurityConfig()
|
||||
|
||||
Expect(cfg.Enabled).To(BeFalse())
|
||||
Expect(cfg.WebhookAsync).To(BeTrue())
|
||||
Expect(cfg.MinSeverity).To(Equal("medium"))
|
||||
Expect(cfg.BatchSize).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Webhook Integration", func() {
|
||||
Context("when sending events to webhook", func() {
|
||||
It("should send path traversal events", func() {
|
||||
// Setup webhook server
|
||||
receivedEvents := make([]map[string]interface{}, 0)
|
||||
var mu sync.Mutex
|
||||
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var event map[string]interface{}
|
||||
json.Unmarshal(body, &event)
|
||||
|
||||
mu.Lock()
|
||||
receivedEvents = append(receivedEvents, event)
|
||||
mu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
|
||||
// Configure handler
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
WebhookAsync: false, // Synchrone pour les tests
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
MinSeverity: "medium",
|
||||
})
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Trigger path traversal
|
||||
_ = performRequest(engine, "GET", "/static/../../../etc/passwd")
|
||||
|
||||
// Wait a bit for webhook
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
Expect(receivedEvents).To(HaveLen(1))
|
||||
Expect(receivedEvents[0]["event_type"]).To(Equal("path_traversal"))
|
||||
Expect(receivedEvents[0]["severity"]).To(Equal("high"))
|
||||
Expect(receivedEvents[0]["blocked"]).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should send rate limit events", func() {
|
||||
receivedEvents := &atomic.Int32{}
|
||||
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedEvents.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
WebhookAsync: false,
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
MinSeverity: "medium",
|
||||
})
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 2,
|
||||
Window: time.Minute,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Trigger rate limit
|
||||
_ = performRequest(engine, "GET", "/static/file1.txt")
|
||||
_ = performRequest(engine, "GET", "/static/file2.txt")
|
||||
_ = performRequest(engine, "GET", "/static/file3.txt") // Should trigger rate limit
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
Expect(receivedEvents.Load()).To(BeNumerically(">=", 1))
|
||||
})
|
||||
|
||||
It("should send MIME type denied events", func() {
|
||||
receivedEvents := &atomic.Int32{}
|
||||
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedEvents.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
WebhookAsync: false,
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
MinSeverity: "medium",
|
||||
})
|
||||
handler.SetHeaders(static.HeadersConfig{
|
||||
EnableContentType: true,
|
||||
DenyMimeTypes: []string{"text/plain"},
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Trigger MIME type denied
|
||||
_ = performRequest(engine, "GET", "/static/test.txt")
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
Expect(receivedEvents.Load()).To(Equal(int32(1)))
|
||||
})
|
||||
|
||||
It("should respect minimum severity level", func() {
|
||||
receivedEvents := &atomic.Int32{}
|
||||
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedEvents.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
WebhookAsync: false,
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
MinSeverity: "critical", // Only critical events
|
||||
})
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 1,
|
||||
Window: time.Minute,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Trigger medium severity event (rate limit)
|
||||
_ = performRequest(engine, "GET", "/static/file1.txt")
|
||||
_ = performRequest(engine, "GET", "/static/file2.txt")
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Should not receive event because severity is only "medium"
|
||||
Expect(receivedEvents.Load()).To(Equal(int32(0)))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when webhook fails", func() {
|
||||
It("should handle connection errors gracefully", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: "http://localhost:99999/webhook", // Invalid port
|
||||
WebhookAsync: false,
|
||||
WebhookTimeout: 1 * time.Second,
|
||||
MinSeverity: "medium",
|
||||
})
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Should not panic even if webhook fails
|
||||
_ = performRequest(engine, "GET", "/static/../passwd")
|
||||
})
|
||||
|
||||
It("should handle webhook error responses", func() {
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
WebhookAsync: false,
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
MinSeverity: "medium",
|
||||
})
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Should not panic even if webhook returns error
|
||||
_ = performRequest(engine, "GET", "/static/../passwd")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Callback Integration", func() {
|
||||
Context("when using Go callbacks", func() {
|
||||
It("should not panic with callback configuration", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
MinSeverity: "medium",
|
||||
})
|
||||
|
||||
// AddSecurityCallback is not accessible because SecuEvtCallback uses a private type
|
||||
// We test via webhook instead
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Trigger event
|
||||
_ = performRequest(engine, "GET", "/static/../passwd")
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Test passed if no panic
|
||||
})
|
||||
|
||||
It("should handle configuration with callbacks", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
MinSeverity: "medium",
|
||||
Callbacks: []static.SecuEvtCallback{}, // Empty callbacks list
|
||||
})
|
||||
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Should not panic even with empty callbacks
|
||||
_ = performRequest(engine, "GET", "/static/../passwd")
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Test passed if no panic
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CEF Format", func() {
|
||||
Context("when CEF format is enabled", func() {
|
||||
It("should send events in CEF format", func() {
|
||||
var receivedBody string
|
||||
var mu sync.Mutex
|
||||
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
receivedBody = string(body)
|
||||
mu.Unlock()
|
||||
|
||||
// Check Content-Type
|
||||
Expect(r.Header.Get("Content-Type")).To(Equal("text/plain"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
WebhookAsync: false,
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
MinSeverity: "medium",
|
||||
EnableCEFFormat: true,
|
||||
})
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
_ = performRequest(engine, "GET", "/static/../passwd")
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
Expect(receivedBody).To(ContainSubstring("CEF:0"))
|
||||
Expect(receivedBody).To(ContainSubstring("golib"))
|
||||
Expect(receivedBody).To(ContainSubstring("static"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Batch Processing", func() {
|
||||
Context("when batch mode is enabled", func() {
|
||||
It("should accumulate events and send in batch", func() {
|
||||
batchReceived := &atomic.Int32{}
|
||||
var mu sync.Mutex
|
||||
var lastBatch map[string]interface{}
|
||||
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var batch map[string]interface{}
|
||||
json.Unmarshal(body, &batch)
|
||||
|
||||
mu.Lock()
|
||||
lastBatch = batch
|
||||
mu.Unlock()
|
||||
|
||||
batchReceived.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
WebhookAsync: false,
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
MinSeverity: "medium",
|
||||
BatchSize: 3,
|
||||
BatchTimeout: 5 * time.Second,
|
||||
})
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Generate 3 events to trigger batch send
|
||||
_ = performRequest(engine, "GET", "/static/../passwd1")
|
||||
_ = performRequest(engine, "GET", "/static/../passwd2")
|
||||
_ = performRequest(engine, "GET", "/static/../passwd3")
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
Expect(batchReceived.Load()).To(Equal(int32(1)))
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if lastBatch != nil {
|
||||
Expect(lastBatch["count"]).To(BeNumerically(">=", 1))
|
||||
}
|
||||
})
|
||||
|
||||
It("should send batch on timeout even if not full", func() {
|
||||
batchReceived := &atomic.Int32{}
|
||||
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
batchReceived.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
WebhookAsync: false,
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
MinSeverity: "medium",
|
||||
BatchSize: 10,
|
||||
BatchTimeout: 500 * time.Millisecond,
|
||||
})
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Generate only 2 events (less than batch size)
|
||||
_ = performRequest(engine, "GET", "/static/../passwd1")
|
||||
_ = performRequest(engine, "GET", "/static/../passwd2")
|
||||
|
||||
// Wait for timeout
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
Expect(batchReceived.Load()).To(BeNumerically(">=", 1))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Custom Headers", func() {
|
||||
Context("when custom webhook headers are provided", func() {
|
||||
It("should include custom headers in webhook request", func() {
|
||||
var receivedHeaders http.Header
|
||||
var mu sync.Mutex
|
||||
|
||||
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
receivedHeaders = r.Header.Clone()
|
||||
mu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer webhookServer.Close()
|
||||
|
||||
handler := newTestStatic().(static.Static)
|
||||
handler.SetSecurityBackend(static.SecurityConfig{
|
||||
Enabled: true,
|
||||
WebhookURL: webhookServer.URL,
|
||||
WebhookAsync: false,
|
||||
WebhookTimeout: 5 * time.Second,
|
||||
MinSeverity: "medium",
|
||||
WebhookHeaders: map[string]string{
|
||||
"Authorization": "Bearer secret-token",
|
||||
"X-Custom": "test-value",
|
||||
},
|
||||
})
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
_ = performRequest(engine, "GET", "/static/../passwd")
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
Expect(receivedHeaders.Get("Authorization")).To(Equal("Bearer secret-token"))
|
||||
Expect(receivedHeaders.Get("X-Custom")).To(Equal("test-value"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -21,7 +21,6 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
@@ -30,15 +29,19 @@ import (
|
||||
ginsdk "github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetSpecific registers a custom Gin handler for a specific route.
|
||||
// This overrides the default static file serving for this route.
|
||||
func (s *staticHandler) SetSpecific(group, route string, router ginsdk.HandlerFunc) {
|
||||
route = s._makeRoute(group, route)
|
||||
s.s.Store(route, router)
|
||||
route = s.makeRoute(group, route)
|
||||
s.spc.Store(route, router)
|
||||
}
|
||||
|
||||
// GetSpecific returns the custom handler for a specific route.
|
||||
// Returns nil if no custom handler is registered for this route.
|
||||
func (s *staticHandler) GetSpecific(group, route string) ginsdk.HandlerFunc {
|
||||
route = s._makeRoute(group, route)
|
||||
route = s.makeRoute(group, route)
|
||||
|
||||
if i, l := s.s.Load(route); !l {
|
||||
if i, l := s.spc.Load(route); !l {
|
||||
return nil
|
||||
} else if v, k := i.(ginsdk.HandlerFunc); !k {
|
||||
return nil
|
||||
|
||||
324
static/static_suite_test.go
Normal file
324
static/static_suite_test.go
Normal file
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
ginsdk "github.com/gin-gonic/gin"
|
||||
libdur "github.com/nabbar/golib/duration"
|
||||
libfpg "github.com/nabbar/golib/file/progress"
|
||||
liblog "github.com/nabbar/golib/logger"
|
||||
logcfg "github.com/nabbar/golib/logger/config"
|
||||
montps "github.com/nabbar/golib/monitor/types"
|
||||
librtr "github.com/nabbar/golib/router"
|
||||
"github.com/nabbar/golib/static"
|
||||
libver "github.com/nabbar/golib/version"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
//go:embed testdata/*
|
||||
var testFS embed.FS
|
||||
|
||||
var (
|
||||
// Global context for all tests
|
||||
testCtx context.Context
|
||||
testCancel context.CancelFunc
|
||||
|
||||
// Test gin engine
|
||||
testGinEngine *ginsdk.Engine
|
||||
|
||||
// Global logger for tests
|
||||
testLogger liblog.Logger
|
||||
testAccLog liblog.Logger
|
||||
)
|
||||
|
||||
type staticDownload interface {
|
||||
SetDownload(string, bool)
|
||||
IsDownload(string) bool
|
||||
}
|
||||
|
||||
type staticIndex interface {
|
||||
SetIndex(string, string, string)
|
||||
GetIndex(string, string) string
|
||||
IsIndex(string) bool
|
||||
IsIndexForRoute(string, string, string) bool
|
||||
}
|
||||
|
||||
type staticRedirect interface {
|
||||
SetRedirect(string, string, string, string)
|
||||
GetRedirect(string, string) string
|
||||
IsRedirect(string, string) bool
|
||||
}
|
||||
|
||||
type staticSpecific interface {
|
||||
SetSpecific(string, string, ginsdk.HandlerFunc)
|
||||
GetSpecific(string, string) ginsdk.HandlerFunc
|
||||
}
|
||||
|
||||
type staticConfig interface {
|
||||
SetDownload(string, bool)
|
||||
SetIndex(string, string, string)
|
||||
IsDownload(string) bool
|
||||
IsIndex(string) bool
|
||||
GetIndex(string, string) string
|
||||
}
|
||||
|
||||
type staticMap interface {
|
||||
Map(func(string, os.FileInfo) error) error
|
||||
}
|
||||
|
||||
type staticFind interface {
|
||||
Find(string) (io.ReadCloser, error)
|
||||
}
|
||||
|
||||
type staticList interface {
|
||||
List(rootPath string) ([]string, error)
|
||||
}
|
||||
|
||||
type staticInfo interface {
|
||||
Info(string) (os.FileInfo, error)
|
||||
}
|
||||
|
||||
type staticTemp interface {
|
||||
Temp(string) (libfpg.Progress, error)
|
||||
}
|
||||
|
||||
type staticTempSize interface {
|
||||
UseTempForFileSize(int64)
|
||||
}
|
||||
|
||||
type staticFindHas interface {
|
||||
Find(string) (io.ReadCloser, error)
|
||||
Has(string) bool
|
||||
}
|
||||
|
||||
type staticFindTempSize interface {
|
||||
Find(string) (io.ReadCloser, error)
|
||||
UseTempForFileSize(int64)
|
||||
}
|
||||
|
||||
// TestStatic is the entry point for the Ginkgo test suite
|
||||
func TestStatic(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Static Package Suite")
|
||||
}
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
testCtx, testCancel = context.WithCancel(context.Background())
|
||||
ginsdk.SetMode(ginsdk.TestMode)
|
||||
|
||||
// Initialize logger
|
||||
testLogger = liblog.New(testCtx)
|
||||
Expect(testLogger.SetOptions(&logcfg.Options{
|
||||
Stdout: &logcfg.OptionsStd{
|
||||
DisableStandard: true, // Enable log output for security evt
|
||||
},
|
||||
})).ToNot(HaveOccurred())
|
||||
|
||||
// Initialize logger
|
||||
testAccLog = liblog.New(testCtx)
|
||||
Expect(testAccLog.SetOptions(&logcfg.Options{
|
||||
Stdout: &logcfg.OptionsStd{
|
||||
EnableAccessLog: true,
|
||||
DisableStandard: true, // Enable log output for access logs
|
||||
},
|
||||
})).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
// Close access logger
|
||||
if testAccLog != nil {
|
||||
_ = testAccLog.Close()
|
||||
}
|
||||
|
||||
// Close logger
|
||||
if testLogger != nil {
|
||||
_ = testLogger.Close()
|
||||
}
|
||||
|
||||
if testCancel != nil {
|
||||
testCancel()
|
||||
}
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
|
||||
// newTestVersion creates a test version instance for use in tests
|
||||
func newTestVersion() libver.Version {
|
||||
return libver.NewVersion(
|
||||
libver.License_MIT,
|
||||
"static-test",
|
||||
"Static Package Test",
|
||||
"2024",
|
||||
"test-build",
|
||||
"1.0.0",
|
||||
"test-author",
|
||||
"",
|
||||
struct{}{},
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
// newTestMonitorConfig creates a test monitor config instance
|
||||
func newTestMonitorConfig() montps.Config {
|
||||
return montps.Config{
|
||||
Name: "test",
|
||||
CheckTimeout: libdur.Seconds(1),
|
||||
IntervalCheck: libdur.Seconds(1),
|
||||
IntervalFall: libdur.Seconds(1),
|
||||
IntervalRise: libdur.Seconds(1),
|
||||
FallCountKO: 1,
|
||||
FallCountWarn: 1,
|
||||
RiseCountKO: 1,
|
||||
RiseCountWarn: 1,
|
||||
Logger: logcfg.Options{
|
||||
Stdout: &logcfg.OptionsStd{
|
||||
DisableStandard: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// getTestLogger returns the global test logger
|
||||
func getTestLogger() liblog.Logger {
|
||||
return testLogger
|
||||
}
|
||||
|
||||
// getTestLogger returns the global test logger
|
||||
func getTestAccessLogger() liblog.Logger {
|
||||
return testAccLog
|
||||
}
|
||||
|
||||
// newTestStatic creates a new static handler with test data
|
||||
func newTestStatic() static.Static {
|
||||
s := static.New(testCtx, testFS, "testdata")
|
||||
s.RegisterLogger(testLogger)
|
||||
return s
|
||||
}
|
||||
|
||||
// newTestStaticWithRoot creates a new static handler with custom root
|
||||
func newTestStaticWithRoot(roots ...string) static.Static {
|
||||
s := static.New(testCtx, testFS, roots...)
|
||||
s.RegisterLogger(testLogger)
|
||||
return s
|
||||
}
|
||||
|
||||
// setupTestRouter creates a gin router with static handler registered
|
||||
func setupTestRouter(handler static.Static, route string, middlewares ...ginsdk.HandlerFunc) *ginsdk.Engine {
|
||||
n, e := librtr.GinEngine("")
|
||||
Expect(e).NotTo(HaveOccurred())
|
||||
|
||||
// Disable automatic trailing slash redirects for tests
|
||||
n.RedirectTrailingSlash = false
|
||||
|
||||
n = librtr.GinAddGlobalMiddleware(n, librtr.GinAccessLog(func() liblog.Logger {
|
||||
return getTestAccessLogger()
|
||||
}))
|
||||
|
||||
n = librtr.GinAddGlobalMiddleware(n, librtr.GinErrorLog(func() liblog.Logger {
|
||||
return getTestLogger()
|
||||
}))
|
||||
|
||||
routerList := librtr.NewRouterList(func() *ginsdk.Engine {
|
||||
return n
|
||||
})
|
||||
|
||||
handler.RegisterRouter(route, routerList.Register, middlewares...)
|
||||
|
||||
routerList.Handler(n)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// setupTestRouterInGroup creates a gin router with static handler registered in a group
|
||||
func setupTestRouterInGroup(handler static.Static, route, group string, middlewares ...ginsdk.HandlerFunc) *ginsdk.Engine {
|
||||
routerList := librtr.NewRouterList(librtr.DefaultGinInit)
|
||||
|
||||
handler.RegisterRouterInGroup(route, group, routerList.RegisterInGroup, middlewares...)
|
||||
|
||||
engine := routerList.Engine()
|
||||
// Disable automatic trailing slash redirects for tests
|
||||
engine.RedirectTrailingSlash = false
|
||||
routerList.Handler(engine)
|
||||
|
||||
return engine
|
||||
}
|
||||
|
||||
// performRequest performs an HTTP request and returns the response
|
||||
func performRequest(engine *ginsdk.Engine, method, path string) *httptest.ResponseRecorder {
|
||||
req, err := http.NewRequest(method, path, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
// testMiddleware is a simple middleware for testing
|
||||
func testMiddleware(ctx *ginsdk.Context) {
|
||||
ctx.Header("X-Test-Middleware", "true")
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func newMiddleware(id int) ginsdk.HandlerFunc {
|
||||
var h = "X-Test-Middleware"
|
||||
|
||||
if id > 0 {
|
||||
h = h + "-" + strconv.Itoa(id)
|
||||
}
|
||||
|
||||
return func(ctx *ginsdk.Context) {
|
||||
ctx.Header(h, "true")
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func customMiddlewareOK(response string, fct func()) ginsdk.HandlerFunc {
|
||||
if fct == nil {
|
||||
fct = func() {}
|
||||
}
|
||||
return func(c *ginsdk.Context) {
|
||||
fct()
|
||||
c.String(http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
func customMiddlewareCreated(response string) ginsdk.HandlerFunc {
|
||||
return func(c *ginsdk.Context) {
|
||||
c.String(http.StatusCreated, response)
|
||||
}
|
||||
}
|
||||
185
static/suspicious.go
Normal file
185
static/suspicious.go
Normal file
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
ginsdk "github.com/gin-gonic/gin"
|
||||
loglvl "github.com/nabbar/golib/logger/level"
|
||||
)
|
||||
|
||||
// SetSuspicious configures suspicious access detection.
|
||||
// This method is thread-safe and uses atomic operations.
|
||||
func (s *staticHandler) SetSuspicious(cfg SuspiciousConfig) {
|
||||
s.sus.Store(&cfg)
|
||||
}
|
||||
|
||||
// GetSuspicious returns the current suspicious access detection configuration.
|
||||
// This method is thread-safe and uses atomic operations.
|
||||
func (s *staticHandler) GetSuspicious() SuspiciousConfig {
|
||||
if cfg := s.sus.Load(); cfg != nil {
|
||||
return *cfg
|
||||
}
|
||||
return SuspiciousConfig{}
|
||||
}
|
||||
|
||||
// isSuspiciousPath checks if a path matches suspicious patterns.
|
||||
// Returns (true, reason) if suspicious, (false, "") otherwise.
|
||||
//
|
||||
// This method checks:
|
||||
// - Suspicious patterns in the path
|
||||
// - Suspicious file extensions
|
||||
func (s *staticHandler) isSuspiciousPath(path string) (bool, string) {
|
||||
cfg := s.GetSuspicious()
|
||||
|
||||
if !cfg.Enabled {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
pathLower := strings.ToLower(path)
|
||||
|
||||
// Check suspicious patterns
|
||||
for _, pattern := range cfg.SuspiciousPatterns {
|
||||
if strings.Contains(pathLower, strings.ToLower(pattern)) {
|
||||
return true, "suspicious_pattern:" + pattern
|
||||
}
|
||||
}
|
||||
|
||||
// Check suspicious extensions
|
||||
for _, ext := range cfg.SuspiciousExtensions {
|
||||
if strings.HasSuffix(pathLower, strings.ToLower(ext)) {
|
||||
return true, "suspicious_extension:" + ext
|
||||
}
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// logSuspiciousAccess logs a suspicious access with full details.
|
||||
// The log level is determined by the HTTP status code:
|
||||
// - 2xx: INFO level (successful suspicious access)
|
||||
// - 4xx/5xx: WARN level (failed suspicious access)
|
||||
func (s *staticHandler) logSuspiciousAccess(c *ginsdk.Context, reason string, statusCode int) {
|
||||
cfg := s.GetSuspicious()
|
||||
|
||||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// If successful and not logging successes, skip
|
||||
if statusCode >= 200 && statusCode < 300 && !cfg.LogSuccessfulAccess {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine log level based on status
|
||||
level := loglvl.WarnLevel
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
level = loglvl.InfoLevel // Successful suspicious access = INFO
|
||||
} else if statusCode >= 400 {
|
||||
level = loglvl.WarnLevel // Failed suspicious access = WARN
|
||||
}
|
||||
|
||||
ent := s.getLogger().Entry(level, "suspicious access detected")
|
||||
ent.FieldAdd("ip", c.ClientIP())
|
||||
ent.FieldAdd("method", c.Request.Method)
|
||||
ent.FieldAdd("path", c.Request.URL.Path)
|
||||
ent.FieldAdd("reason", reason)
|
||||
ent.FieldAdd("status", statusCode)
|
||||
ent.FieldAdd("userAgent", c.GetHeader("User-Agent"))
|
||||
ent.FieldAdd("referer", c.GetHeader("Referer"))
|
||||
ent.FieldAdd("remoteAddr", c.Request.RemoteAddr)
|
||||
|
||||
// Add X-Forwarded-For if present
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
ent.FieldAdd("xForwardedFor", xff)
|
||||
}
|
||||
|
||||
ent.Log()
|
||||
}
|
||||
|
||||
// logAccessPattern logs suspicious access patterns like scanning and enumeration.
|
||||
// This helps identify automated attacks or reconnaissance attempts.
|
||||
func (s *staticHandler) logAccessPattern(c *ginsdk.Context, pattern string) {
|
||||
cfg := s.GetSuspicious()
|
||||
|
||||
if !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
ent := s.getLogger().Entry(loglvl.WarnLevel, "suspicious access pattern")
|
||||
ent.FieldAdd("ip", c.ClientIP())
|
||||
ent.FieldAdd("pattern", pattern)
|
||||
ent.FieldAdd("path", c.Request.URL.Path)
|
||||
ent.FieldAdd("userAgent", c.GetHeader("User-Agent"))
|
||||
ent.Log()
|
||||
}
|
||||
|
||||
// checkAndLogSuspicious checks for and logs suspicious access patterns.
|
||||
// This method is called for every request and detects:
|
||||
// - Suspicious paths (via isSuspiciousPath)
|
||||
// - Backup file scanning
|
||||
// - Config file scanning
|
||||
// - Directory traversal attempts
|
||||
// - Path manipulation attempts
|
||||
// - Admin panel scanning
|
||||
func (s *staticHandler) checkAndLogSuspicious(c *ginsdk.Context, statusCode int) {
|
||||
if suspicious, reason := s.isSuspiciousPath(c.Request.URL.Path); suspicious {
|
||||
s.logSuspiciousAccess(c, reason, statusCode)
|
||||
}
|
||||
|
||||
// Detect scanning patterns
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// Scanner looking for backup files
|
||||
if strings.Contains(path, ".bak") || strings.Contains(path, ".backup") ||
|
||||
strings.Contains(path, ".old") || strings.Contains(path, "~") {
|
||||
s.logAccessPattern(c, "backup_file_scanning")
|
||||
}
|
||||
|
||||
// Scanner looking for config files
|
||||
if strings.Contains(strings.ToLower(path), "config") &&
|
||||
(strings.HasSuffix(path, ".php") || strings.HasSuffix(path, ".inc")) {
|
||||
s.logAccessPattern(c, "config_file_scanning")
|
||||
}
|
||||
|
||||
// Directory traversal attempts (already blocked but log the attempt)
|
||||
if strings.Contains(path, "..") {
|
||||
s.logAccessPattern(c, "directory_traversal_attempt")
|
||||
}
|
||||
|
||||
// Multiple slashes (potential path manipulation)
|
||||
if strings.Contains(path, "//") || strings.Contains(path, "\\\\") {
|
||||
s.logAccessPattern(c, "path_manipulation_attempt")
|
||||
}
|
||||
|
||||
// Looking for admin panels
|
||||
pathLower := strings.ToLower(path)
|
||||
if strings.Contains(pathLower, "admin") || strings.Contains(pathLower, "login") ||
|
||||
strings.Contains(pathLower, "console") {
|
||||
s.logAccessPattern(c, "admin_panel_scanning")
|
||||
}
|
||||
}
|
||||
280
static/suspicious_test.go
Normal file
280
static/suspicious_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2022 Nicolas JUHEL
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
*/
|
||||
|
||||
package static_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/nabbar/golib/static"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Suspicious Access Detection", func() {
|
||||
Describe("Configuration", func() {
|
||||
Context("when setting suspicious detection config", func() {
|
||||
It("should store and retrieve configuration", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
cfg := static.SuspiciousConfig{
|
||||
Enabled: true,
|
||||
LogSuccessfulAccess: true,
|
||||
SuspiciousPatterns: []string{".env", "admin"},
|
||||
SuspiciousExtensions: []string{".php", ".exe"},
|
||||
}
|
||||
|
||||
handler.SetSuspicious(cfg)
|
||||
|
||||
retrieved := handler.GetSuspicious()
|
||||
Expect(retrieved.Enabled).To(BeTrue())
|
||||
Expect(retrieved.LogSuccessfulAccess).To(BeTrue())
|
||||
Expect(retrieved.SuspiciousPatterns).To(ContainElement(".env"))
|
||||
Expect(retrieved.SuspiciousExtensions).To(ContainElement(".php"))
|
||||
})
|
||||
|
||||
It("should use default config", func() {
|
||||
cfg := static.DefaultSuspiciousConfig()
|
||||
|
||||
Expect(cfg.Enabled).To(BeTrue())
|
||||
Expect(cfg.LogSuccessfulAccess).To(BeTrue())
|
||||
Expect(cfg.SuspiciousPatterns).To(ContainElement(".env"))
|
||||
Expect(cfg.SuspiciousPatterns).To(ContainElement(".git"))
|
||||
Expect(cfg.SuspiciousExtensions).To(ContainElement(".php"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Suspicious Pattern Detection", func() {
|
||||
Context("when accessing suspicious files", func() {
|
||||
It("should log access to .env files (blocked)", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
// Enable all security features
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// This should be logged as suspicious AND blocked
|
||||
w := performRequest(engine, "GET", "/static/.env")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("should log access to config.php files (404 but logged)", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{Enabled: false})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// This should be logged as suspicious (404)
|
||||
w := performRequest(engine, "GET", "/static/config.php")
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("should log access to admin panels", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{Enabled: false})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Admin panel access attempts
|
||||
_ = performRequest(engine, "GET", "/static/admin/login.php")
|
||||
_ = performRequest(engine, "GET", "/static/wp-admin/")
|
||||
_ = performRequest(engine, "GET", "/static/phpmyadmin/")
|
||||
})
|
||||
|
||||
It("should log backup file access attempts", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{Enabled: false})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Backup files
|
||||
_ = performRequest(engine, "GET", "/static/config.bak")
|
||||
_ = performRequest(engine, "GET", "/static/backup.old")
|
||||
_ = performRequest(engine, "GET", "/static/index.php.save")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Attack Pattern Detection", func() {
|
||||
Context("when detecting scanning patterns", func() {
|
||||
It("should detect directory traversal scanning", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Directory traversal - should log pattern
|
||||
w := performRequest(engine, "GET", "/static/../../../etc/passwd")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("should detect path manipulation attempts", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{Enabled: false})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Double slashes
|
||||
_ = performRequest(engine, "GET", "/static//test.txt")
|
||||
_ = performRequest(engine, "GET", "/static\\\\test.txt")
|
||||
})
|
||||
|
||||
It("should detect config file scanning", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{Enabled: false})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Config scanning
|
||||
_ = performRequest(engine, "GET", "/static/config.php")
|
||||
_ = performRequest(engine, "GET", "/static/configuration.inc")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Successful Suspicious Access", func() {
|
||||
Context("when suspicious file exists and is served", func() {
|
||||
It("should log even successful requests when enabled", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.SuspiciousConfig{
|
||||
Enabled: true,
|
||||
LogSuccessfulAccess: true, // Log even successful access
|
||||
SuspiciousExtensions: []string{".txt"}, // Make .txt suspicious for this test
|
||||
})
|
||||
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{Enabled: false})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// This file exists and will return 200, but should still be logged
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("should not log successful requests when disabled", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.SuspiciousConfig{
|
||||
Enabled: true,
|
||||
LogSuccessfulAccess: false, // Don't log successful access
|
||||
SuspiciousExtensions: []string{".txt"},
|
||||
})
|
||||
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{Enabled: false})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// This won't be logged since LogSuccessfulAccess is false
|
||||
w := performRequest(engine, "GET", "/static/test.txt")
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Integration with SecurityBackend Features", func() {
|
||||
Context("when combining with path security", func() {
|
||||
It("should log and block dangerous combinations", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.DefaultPathSecurityConfig())
|
||||
handler.SetRateLimit(static.RateLimitConfig{
|
||||
Enabled: true,
|
||||
MaxRequests: 100,
|
||||
Window: 60000000000,
|
||||
})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Combination attack: path traversal + sensitive file
|
||||
w := performRequest(engine, "GET", "/static/../.env")
|
||||
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Real-World Attack Scenarios", func() {
|
||||
Context("when simulating real attacks", func() {
|
||||
It("should detect WordPress scanning", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{Enabled: false})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Typical WordPress scanner
|
||||
_ = performRequest(engine, "GET", "/static/wp-admin/")
|
||||
_ = performRequest(engine, "GET", "/static/wp-login.php")
|
||||
_ = performRequest(engine, "GET", "/static/wp-config.php")
|
||||
_ = performRequest(engine, "GET", "/static/xmlrpc.php")
|
||||
})
|
||||
|
||||
It("should detect database file access attempts", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{Enabled: false})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Database files
|
||||
_ = performRequest(engine, "GET", "/static/database.sql")
|
||||
_ = performRequest(engine, "GET", "/static/backup.db")
|
||||
_ = performRequest(engine, "GET", "/static/data.sqlite")
|
||||
})
|
||||
|
||||
It("should detect source code exposure attempts", func() {
|
||||
handler := newTestStatic().(static.Static)
|
||||
|
||||
handler.SetSuspicious(static.DefaultSuspiciousConfig())
|
||||
handler.SetPathSecurity(static.PathSecurityConfig{Enabled: false})
|
||||
|
||||
engine := setupTestRouter(handler, "/static")
|
||||
|
||||
// Source code
|
||||
_ = performRequest(engine, "GET", "/static/source.tar.gz")
|
||||
_ = performRequest(engine, "GET", "/static/backup.zip")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
4
static/testdata/assets/style.css
vendored
Normal file
4
static/testdata/assets/style.css
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
9
static/testdata/index.html
vendored
Normal file
9
static/testdata/index.html
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Index</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Index Page</h1>
|
||||
</body>
|
||||
</html>
|
||||
5
static/testdata/large.txt
vendored
Normal file
5
static/testdata/large.txt
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
This is a large test file with lots of content to test temp file handling.
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
|
||||
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
1
static/testdata/subdir/nested.txt
vendored
Normal file
1
static/testdata/subdir/nested.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This is a nested test file.
|
||||
1
static/testdata/test.json
vendored
Normal file
1
static/testdata/test.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"message": "test json file"}
|
||||
1
static/testdata/test.txt
vendored
Normal file
1
static/testdata/test.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This is a test file.
|
||||
Reference in New Issue
Block a user