fix: comprehensive bug fixes, hardening, and test coverage
Bug fixes: - Fix nil-pointer panic in sendMessage SOAP fault handler (used err.Error() on nil) - Fix missing return after SOAP fault response (caused fall-through to 200 OK) - Fix timeout=0 shadowing: removed package-level constant, use configured value - Fix receiveMessage not checking SOAP faults from CIIMS Codec hardening: - Replace fragile regex parsing with encoding/xml.Decoder for namespace-agnostic XML - XML-escape all user inputs (user, pass, event) — not just message body - Remove magic-number-based string slicing (split, GetErrMsg) Transport improvements: - Extract Client type with connection pooling (reuse http.Client across requests) - Add CIIMSResponse domain type with IsFault(), ErrorMessage(), Messages() - Add HTTPStatusError and ResponseTooLargeError typed errors - Replace deprecated ioutil.ReadAll with io.ReadAll - Add response body size limit (64 MiB) Security hardening: - Add MaxBytesReader (1MB request body limit) on both endpoints - Add URL allowlist via CIIMS_ALLOWED_SERVERS env var - Add normalizeBaseURL with scheme/host/query validation - Add count bounds validation (1-1000) on /receive - Add server-level timeouts (ReadHeaderTimeout, ReadTimeout, IdleTimeout) Configuration: - Introduce Config struct to replace package-level globals - Add loadConfig() with full validation and error propagation - Add getIntEnv() with positive-value enforcement Test coverage (75 tests, 35 new): - Phase 1: 8 internal tests (nil receiver, error types, SOAP+HTTP500, malformed XML) - Phase 2: 19 pure function tests (normalizeBaseURL, parseAllowedServers, getIntEnv) - Phase 3: 16 handler tests (send/receive success, SOAP fault, network error, HTTP 500, body too large, invalid JSON, missing fields, URL allowlist) - Phase 4: 8 config/router tests (loadConfig, newServer, newRouter) Toolchain: - Upgrade Go 1.13 → 1.22, gin 1.6.3 → 1.10.0, testify 1.5.1 → 1.10.0 Cleanup: - Remove dead code (unused post() function, commented-out defaults) - Replace println with structured logging - Add .gitignore - Rewrite README with API docs, env vars, security considerations
This commit is contained in:
+145
-44
@@ -5,31 +5,57 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gzzn.com/mini/ciimsproxy/internal"
|
||||
)
|
||||
|
||||
const (
|
||||
servicePrefix string = "/services/ExchangeService"
|
||||
defaultTimeout = 240
|
||||
maxMsgLen = 1048576 // 1MB
|
||||
maxCount = 1000
|
||||
servicePrefix = "/services/ExchangeService"
|
||||
defaultTimeout = 240
|
||||
maxMsgLen = 1048576 // 1MB
|
||||
maxCount = 1000
|
||||
readHeaderTimeout = 5 * time.Second
|
||||
readTimeout = 10 * time.Second
|
||||
writeTimeoutGrace = 10 * time.Second
|
||||
idleTimeout = 60 * time.Second
|
||||
allowedServersEnvVarName = "CIIMS_ALLOWED_SERVERS"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerURL string
|
||||
Listen string
|
||||
Timeout int
|
||||
Client *internal.Client
|
||||
ServerURL string
|
||||
AllowedServers map[string]string
|
||||
Listen string
|
||||
Timeout int
|
||||
Client *internal.Client
|
||||
}
|
||||
|
||||
type App struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
type sendRequest struct {
|
||||
URL string `json:"url"`
|
||||
User string `json:"user" binding:"required"`
|
||||
Pass string `json:"pass" binding:"required"`
|
||||
Event string `json:"event" binding:"required"`
|
||||
Priority int `json:"priority"`
|
||||
Val bool `json:"val"`
|
||||
Msg string `json:"msg" binding:"required"`
|
||||
}
|
||||
|
||||
type receiveRequest struct {
|
||||
URL string `json:"url"`
|
||||
User string `json:"user" binding:"required"`
|
||||
Pass string `json:"pass" binding:"required"`
|
||||
Count int `json:"count" binding:"required"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
config, err := loadConfig()
|
||||
if err != nil {
|
||||
@@ -38,10 +64,10 @@ func main() {
|
||||
}
|
||||
|
||||
log.Printf("use ciims: %s", config.ServerURL)
|
||||
r := newRouter(config)
|
||||
srv := newServer(config)
|
||||
log.Printf("Starting ciims proxy for %s", config.ServerURL)
|
||||
log.Printf("timeout: %d", config.Timeout)
|
||||
if err := r.Run(config.Listen); err != nil {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Printf("[ERROR] server failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -49,10 +75,25 @@ func main() {
|
||||
|
||||
func loadConfig() (Config, error) {
|
||||
config := Config{
|
||||
ServerURL: os.Getenv("CIIMS_SERVER"),
|
||||
Listen: os.Getenv("PROXY_LISTEN"),
|
||||
Timeout: defaultTimeout,
|
||||
Listen: os.Getenv("PROXY_LISTEN"),
|
||||
Timeout: defaultTimeout,
|
||||
}
|
||||
|
||||
serverURL, err := normalizeBaseURL(os.Getenv("CIIMS_SERVER"))
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("invalid CIIMS_SERVER: %w", err)
|
||||
}
|
||||
config.ServerURL = serverURL
|
||||
config.AllowedServers = map[string]string{serverURL: serverURL}
|
||||
|
||||
allowedServers, err := parseAllowedServers(os.Getenv(allowedServersEnvVarName))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
for _, allowed := range allowedServers {
|
||||
config.AllowedServers[allowed] = allowed
|
||||
}
|
||||
|
||||
t, err := getIntEnv("CIIMS_TIMEOUT")
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@@ -70,7 +111,21 @@ func loadConfig() (Config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func newServer(config Config) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: config.Listen,
|
||||
Handler: newRouter(config),
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: time.Duration(config.Timeout)*time.Second + writeTimeoutGrace,
|
||||
IdleTimeout: idleTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func newRouter(config Config) *gin.Engine {
|
||||
if config.AllowedServers == nil && config.ServerURL != "" {
|
||||
config.AllowedServers = map[string]string{config.ServerURL: config.ServerURL}
|
||||
}
|
||||
app := &App{config: config}
|
||||
r := gin.Default()
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
@@ -84,32 +139,25 @@ func newRouter(config Config) *gin.Engine {
|
||||
}
|
||||
|
||||
func (a *App) sendMessage(c *gin.Context) {
|
||||
// Limit request body size
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMsgLen)
|
||||
|
||||
var message struct {
|
||||
URL string `json:"url"`
|
||||
User string `json:"user" binding:"required"`
|
||||
Pass string `json:"pass" binding:"required"`
|
||||
Event string `json:"event" binding:"required"`
|
||||
Priority int `json:"priority"`
|
||||
Val bool `json:"val"`
|
||||
Msg string `json:"msg" binding:"required"`
|
||||
}
|
||||
var message sendRequest
|
||||
if err := c.ShouldBind(&message); err != nil {
|
||||
a.handleBindError(c, err)
|
||||
return
|
||||
}
|
||||
ciimsURL, ok := a.resolveTargetURL(c, message.URL)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
msg := internal.CreateSend(message.User, message.Pass,
|
||||
message.Priority, message.Event, message.Val, message.Msg)
|
||||
url := a.getURL(message.URL)
|
||||
resp, err := a.config.Client.Send(url, msg)
|
||||
resp, err := a.config.Client.Send(c.Request.Context(), ciimsURL, msg)
|
||||
if err != nil {
|
||||
a.handleSendError(c, "send", resp, err)
|
||||
return
|
||||
}
|
||||
errMsg := resp.ErrorMessage()
|
||||
if len(errMsg) > 0 {
|
||||
if errMsg := resp.ErrorMessage(); errMsg != "" {
|
||||
log.Printf("[ERROR] SOAP fault: %s", errMsg)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||
return
|
||||
@@ -118,15 +166,9 @@ func (a *App) sendMessage(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *App) receiveMessage(c *gin.Context) {
|
||||
// Limit request body size
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMsgLen)
|
||||
|
||||
var message struct {
|
||||
URL string `json:"url"`
|
||||
User string `json:"user" binding:"required"`
|
||||
Pass string `json:"pass" binding:"required"`
|
||||
Count int `json:"count" binding:"required"`
|
||||
}
|
||||
var message receiveRequest
|
||||
if err := c.ShouldBind(&message); err != nil {
|
||||
a.handleBindError(c, err)
|
||||
return
|
||||
@@ -137,9 +179,12 @@ func (a *App) receiveMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ciimsURL, ok := a.resolveTargetURL(c, message.URL)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
msg := internal.CreateReceive(message.User, message.Pass, message.Count)
|
||||
url := a.getURL(message.URL)
|
||||
resp, err := a.config.Client.Send(url, msg)
|
||||
resp, err := a.config.Client.Send(c.Request.Context(), ciimsURL, msg)
|
||||
if err != nil {
|
||||
a.handleSendError(c, "receive", resp, err)
|
||||
return
|
||||
@@ -150,17 +195,70 @@ func (a *App) receiveMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"msgs": resp.Messages()})
|
||||
|
||||
}
|
||||
|
||||
func (a *App) getURL(url string) string {
|
||||
var result string
|
||||
if len(url) > 0 {
|
||||
result = url + servicePrefix
|
||||
} else {
|
||||
result = a.config.ServerURL + servicePrefix
|
||||
func (a *App) resolveTargetURL(c *gin.Context, requestedURL string) (string, bool) {
|
||||
targetBase, err := a.getBaseURL(requestedURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return "", false
|
||||
}
|
||||
return result
|
||||
return targetBase + servicePrefix, true
|
||||
}
|
||||
|
||||
func (a *App) getBaseURL(requestedURL string) (string, error) {
|
||||
if strings.TrimSpace(requestedURL) == "" {
|
||||
return a.config.ServerURL, nil
|
||||
}
|
||||
normalized, err := normalizeBaseURL(requestedURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid url: %w", err)
|
||||
}
|
||||
if _, ok := a.config.AllowedServers[normalized]; !ok {
|
||||
return "", fmt.Errorf("url is not allowed")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeBaseURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("must not be empty")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("scheme must be http or https")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return "", fmt.Errorf("host is required")
|
||||
}
|
||||
if u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", fmt.Errorf("query and fragment are not allowed")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/")
|
||||
u.RawPath = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func parseAllowedServers(raw string) ([]string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
allowed := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
normalized, err := normalizeBaseURL(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid %s entry %q: %w", allowedServersEnvVarName, part, err)
|
||||
}
|
||||
allowed = append(allowed, normalized)
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func (a *App) handleBindError(c *gin.Context, err error) {
|
||||
@@ -202,5 +300,8 @@ func getIntEnv(key string) (int, error) {
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s value %q: must be an integer", key, val)
|
||||
}
|
||||
if ret <= 0 {
|
||||
return 0, fmt.Errorf("invalid %s value %q: must be positive", key, val)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user