diff --git a/.github/templates/README.template.md b/.github/templates/README.template.md index c53c812..3bdd98d 100644 --- a/.github/templates/README.template.md +++ b/.github/templates/README.template.md @@ -177,11 +177,15 @@ Because Secured Signal API is just a Proxy you can use all of the [Signal REST A | **/v1/accounts** | | **/v1/contacts** | -These Endpoints are blocked by default due to Security Risks, but can be modified by setting `BLOCKED_ENDPOINTS` to a valid json array string +These Endpoints are blocked by default due to Security Risks, but can be modified by setting `BLOCKED_ENDPOINTS` to a Comma seperated List: ```yaml environment: - BLOCKED_ENDPOINTS: '[ "/v1/register","/v1/unregister","/v1/qrcodelink","/v1/contacts" ]' + BLOCKED_ENDPOINTS: | + /v1/register, + /v1/unregister, + /v1/qrcodelink, + /v1/contacts, ``` #### Variables @@ -206,7 +210,8 @@ Set this Environment Variable to automatically provide default Recipients: ```yaml environment: - RECIPIENTS: ' [ "user.id", "000", "001", "group.id" ] ' + RECIPIENTS: | + user.id, 000, 001, group.id, ``` example: diff --git a/go.mod b/go.mod index 62c830e..a647fb6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/codeshelldev/secured-signal-api -go 1.24.5 +go 1.25.1 require go.uber.org/zap v1.27.0 diff --git a/internals/proxy/middlewares/body.go b/internals/proxy/middlewares/body.go index 73d98aa..a4f245f 100644 --- a/internals/proxy/middlewares/body.go +++ b/internals/proxy/middlewares/body.go @@ -2,12 +2,12 @@ package middlewares import ( "bytes" - "encoding/json" "io" "net/http" "strconv" log "github.com/codeshelldev/secured-signal-api/utils/logger" + request "github.com/codeshelldev/secured-signal-api/utils/request" ) type MessageAlias struct { @@ -25,65 +25,66 @@ func (data BodyMiddleware) Use() http.Handler { messageAliases := data.MessageAliases return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - bodyBytes, err := io.ReadAll(req.Body) + body, err := request.GetReqBody(w, req) + if err != nil { - log.Error("Could not read Body: ", err.Error()) - http.Error(w, "Bad Request", http.StatusBadRequest) - return + log.Error("Could not get Request Body: ", err.Error()) } - defer req.Body.Close() - if len(bodyBytes) > 0 { + var modifiedBody bool + var bodyData map[string]interface{} + + if !body.Empty { + bodyData = body.Data - req.Body.Close() + content, ok := bodyData["message"] - var modifiedBodyData map[string]interface{} + if !ok || content == "" { - err = json.Unmarshal(bodyBytes, &modifiedBodyData) + bodyData["message"], bodyData = getMessage(messageAliases, bodyData) + + modifiedBody = true + } + } + + if modifiedBody { + modifiedBody, err := request.CreateBody(bodyData) if err != nil { - log.Error("Could not decode Body: ", err.Error()) http.Error(w, "Internal Error", http.StatusInternalServerError) return } - content, ok := modifiedBodyData["message"] - - if !ok || content == "" { - best := 0 - - for _, alias := range messageAliases { - aliasKey := alias.Alias - priority := alias.Priority + body = modifiedBody - value, ok := modifiedBodyData[aliasKey] + strData := body.ToString() - if ok && value != "" && priority > best { - content = modifiedBodyData[aliasKey] - } + req.ContentLength = int64(len(strData)) + req.Header.Set("Content-Length", strconv.Itoa(len(strData))) + } - modifiedBodyData[aliasKey] = nil - } + req.Body = io.NopCloser(bytes.NewReader(body.Raw)) - modifiedBodyData["message"] = content + next.ServeHTTP(w, req) + }) +} - bodyBytes, err = json.Marshal(modifiedBodyData) +func getMessage(aliases []MessageAlias, data map[string]interface{}) (string, map[string]interface{}) { + var content string + var best int - if err != nil { - log.Error("Could not encode Body: ", err.Error()) - http.Error(w, "Internal Error", http.StatusInternalServerError) - return - } + for _, alias := range aliases { + aliasKey := alias.Alias + priority := alias.Priority - modifiedBody := string(bodyBytes) + value, ok := data[aliasKey] - req.ContentLength = int64(len(modifiedBody)) - req.Header.Set("Content-Length", strconv.Itoa(len(modifiedBody))) - } + if ok && value != "" && priority > best { + content = data[aliasKey].(string) } - req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + data[aliasKey] = nil + } - next.ServeHTTP(w, req) - }) -} + return content, data +} \ No newline at end of file diff --git a/internals/proxy/middlewares/template.go b/internals/proxy/middlewares/template.go index a311452..0bd5276 100644 --- a/internals/proxy/middlewares/template.go +++ b/internals/proxy/middlewares/template.go @@ -13,6 +13,7 @@ import ( log "github.com/codeshelldev/secured-signal-api/utils/logger" query "github.com/codeshelldev/secured-signal-api/utils/query" + request "github.com/codeshelldev/secured-signal-api/utils/request" ) type TemplateMiddleware struct { @@ -20,6 +21,67 @@ type TemplateMiddleware struct { Variables map[string]interface{} } +func (data TemplateMiddleware) Use() http.Handler { + next := data.Next + VARIABLES := data.Variables + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, err := request.GetReqBody(w, req) + + if err != nil { + log.Error("Could not get Request Body: ", err.Error()) + } + + bodyData := map[string]interface{}{} + + var modifiedBody bool + + if !body.Empty { + var modified bool + + bodyData, modified = templateJSON(body.Data, VARIABLES) + + if modified { + modifiedBody = true + } + } + + if req.URL.RawQuery != "" { + var modified bool + + req.URL.RawQuery, bodyData, modified = templateQuery(bodyData, req.URL, VARIABLES) + + if modified { + modifiedBody = true + } + } + + if modifiedBody { + modifiedBody, err := request.CreateBody(bodyData) + + if err != nil { + http.Error(w, "Internal Error", http.StatusInternalServerError) + return + } + + body = modifiedBody + + strData := body.ToString() + + log.Debug("Applied Body Templating: ", strData) + + req.ContentLength = int64(len(strData)) + req.Header.Set("Content-Length", strconv.Itoa(len(strData))) + } + + req.Body = io.NopCloser(bytes.NewReader(body.Raw)) + + req.URL.Path, _ = templatePath(req.URL, VARIABLES) + + next.ServeHTTP(w, req) + }) +} + func renderTemplate(name string, tmplStr string, data any) (string, error) { tmpl, err := template.New(name).Parse(tmplStr) @@ -36,7 +98,9 @@ func renderTemplate(name string, tmplStr string, data any) (string, error) { return buf.String(), nil } -func templateJSON(data map[string]interface{}, variables map[string]interface{}) map[string]interface{} { +func templateJSON(data map[string]interface{}, variables map[string]interface{}) (map[string]interface{}, bool) { + var modified bool + for k, v := range data { str, ok := v.(string) @@ -44,7 +108,7 @@ func templateJSON(data map[string]interface{}, variables map[string]interface{}) re, err := regexp.Compile(`{{\s*\.([A-Za-z_][A-Za-z0-9_]*)\s*}}`) if err != nil { - log.Error("Encountered Error while Compiling Regex: ", err.Error()) + log.Error("Error while Compiling Regex: ", err.Error()) } matches := re.FindAllStringSubmatch(str, -1) @@ -63,95 +127,77 @@ func templateJSON(data map[string]interface{}, variables map[string]interface{}) data[k] = strings.ReplaceAll(str, string(variable), tmplStr[0]) } + + modified = true } else if len(matches) == 1 { tmplKey := matches[0][1] data[k] = variables[tmplKey] + + modified = true } } } - return data + return data, modified } -func (data TemplateMiddleware) Use() http.Handler { - next := data.Next - VARIABLES := data.Variables - - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - bodyBytes, err := io.ReadAll(req.Body) - if err != nil { - log.Error("Could not read Body: ", err.Error()) - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - defer req.Body.Close() +func templatePath(reqUrl *url.URL, VARIABLES interface{}) (string, bool) { + var modified bool - if len(bodyBytes) > 0 { + reqPath, err := url.PathUnescape(reqUrl.Path) - var modifiedBodyData map[string]interface{} - - err = json.Unmarshal(bodyBytes, &modifiedBodyData) - - if err != nil { - log.Error("Could not decode Body: ", err.Error()) - http.Error(w, "Internal Error", http.StatusInternalServerError) - return - } - - modifiedBodyData = templateJSON(modifiedBodyData, VARIABLES) - - if req.URL.RawQuery != "" { - decodedQuery, _ := url.QueryUnescape(req.URL.RawQuery) + if err != nil { + log.Error("Error while Escaping Path: ", err.Error()) + return reqUrl.Path, modified + } - log.Debug("Decoded Query: ", decodedQuery) + reqPath, err = renderTemplate("path", reqPath, VARIABLES) - templatedQuery, _ := renderTemplate("query", decodedQuery, VARIABLES) + if err != nil { + log.Error("Could not Template Path: ", err.Error()) + return reqUrl.Path, modified + } - modifiedQuery := req.URL.Query() + if reqUrl.Path != reqPath { + log.Debug("Applied Path Templating: ", reqPath) - queryData := query.ParseRawQuery(templatedQuery) + modified = true + } - for key, value := range queryData { - keyWithoutPrefix, found := strings.CutPrefix(key, "@") + return reqPath, modified +} - if found { - modifiedBodyData[keyWithoutPrefix] = query.ParseTypedQuery(value) +func templateQuery(data map[string]interface{}, reqUrl *url.URL, VARIABLES interface{}) (string, map[string]interface{}, bool) { + var modified bool - modifiedQuery.Del(key) - } - } + decodedQuery, _ := url.QueryUnescape(reqUrl.RawQuery) - req.URL.RawQuery = modifiedQuery.Encode() + log.Debug("Decoded Query: ", decodedQuery) - log.Debug("Applied Query Templating: ", templatedQuery) - } + templatedQuery, _ := renderTemplate("query", decodedQuery, VARIABLES) - bodyBytes, err = json.Marshal(modifiedBodyData) + modifiedQuery := reqUrl.Query() - if err != nil { - log.Error("Could not encode Body: ", err.Error()) - http.Error(w, "Internal Error", http.StatusInternalServerError) - return - } + queryData := query.ParseRawQuery(templatedQuery) - modifiedBody := string(bodyBytes) + for key, value := range queryData { + keyWithoutPrefix, found := strings.CutPrefix(key, "@") - log.Debug("Applied Body Templating: ", modifiedBody) + if found { + data[keyWithoutPrefix] = query.ParseTypedQuery(value) - req.ContentLength = int64(len(modifiedBody)) - req.Header.Set("Content-Length", strconv.Itoa(len(modifiedBody))) + modifiedQuery.Del(key) } + } - req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) - - reqPath := req.URL.Path - reqPath, _ = url.PathUnescape(reqPath) + reqRawQuery := modifiedQuery.Encode() - modifiedReqPath, _ := renderTemplate("path", reqPath, VARIABLES) + if reqUrl.Query().Encode() != reqRawQuery { + log.Debug("Applied Query Templating: ", templatedQuery) - req.URL.Path = modifiedReqPath + modified = true + } - next.ServeHTTP(w, req) - }) -} + return reqRawQuery, data, modified +} \ No newline at end of file diff --git a/main.go b/main.go index c430634..b4b434c 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,8 @@ import ( "os" proxy "github.com/codeshelldev/secured-signal-api/internals/proxy" - . "github.com/codeshelldev/secured-signal-api/internals/proxy/middlewares" + middlewares "github.com/codeshelldev/secured-signal-api/internals/proxy/middlewares" + docker "github.com/codeshelldev/secured-signal-api/utils/docker" env "github.com/codeshelldev/secured-signal-api/utils/env" log "github.com/codeshelldev/secured-signal-api/utils/logger" ) @@ -26,27 +27,27 @@ func main() { initHandler = proxy.Create(ENV.API_URL) - body_m4 := BodyMiddleware{ + body_m4 := middlewares.BodyMiddleware{ Next: initHandler, MessageAliases: ENV.MESSAGE_ALIASES, } - temp_m3 := TemplateMiddleware{ + temp_m3 := middlewares.TemplateMiddleware{ Next: body_m4.Use(), Variables: ENV.VARIABLES, } - endp_m2 := EndpointsMiddleware{ + endp_m2 := middlewares.EndpointsMiddleware{ Next: temp_m3.Use(), BlockedEndpoints: ENV.BLOCKED_ENDPOINTS, } - auth_m1 := AuthMiddleware{ + auth_m1 := middlewares.AuthMiddleware{ Next: endp_m2.Use(), Tokens: ENV.API_TOKENS, } - log_m0 := LogMiddleware{ + log_m0 := middlewares.LogMiddleware{ Next: auth_m1.Use(), } @@ -56,5 +57,20 @@ func main() { log.Info("Server Listening on ", addr) - http.ListenAndServe(addr, log_m0.Use()) -} + server := &http.Server{ + Addr: addr, + Handler: log_m0.Use(), + } + + stop := docker.Run(func(){ + err := server.ListenAndServe() + + if err != nil && err != http.ErrServerClosed { + log.Fatal("Server error: ", err.Error()) + } + }) + + <-stop + + docker.Shutdown(server) +} \ No newline at end of file diff --git a/utils/docker/docker.go b/utils/docker/docker.go new file mode 100644 index 0000000..8635c12 --- /dev/null +++ b/utils/docker/docker.go @@ -0,0 +1,50 @@ +package docker + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + log "github.com/codeshelldev/secured-signal-api/utils/logger" +) + +var stop chan os.Signal + +func Run(main func()) chan os.Signal { + stop = make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + + go main() + + return stop +} + +func Exit(code int) { + log.Info("Exiting...") + + os.Exit(code) + + stop <- syscall.SIGTERM +} + +func Shutdown(server *http.Server) { + log.Info("Shutdown signal received") + + log.Sync() + + ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second) + defer cancel() + + err := server.Shutdown(ctx); + + if err != nil { + log.Fatal("Server shutdown failed: ", err.Error()) + + log.Info("Server exited forcefully") + } else { + log.Info("Server exited gracefully") + } +} \ No newline at end of file diff --git a/utils/env/env.go b/utils/env/env.go index f116d71..4bfce98 100644 --- a/utils/env/env.go +++ b/utils/env/env.go @@ -3,9 +3,11 @@ package env import ( "os" "strconv" + "strings" middlewares "github.com/codeshelldev/secured-signal-api/internals/proxy/middlewares" "github.com/codeshelldev/secured-signal-api/utils" + "github.com/codeshelldev/secured-signal-api/utils/docker" log "github.com/codeshelldev/secured-signal-api/utils/logger" ) @@ -83,16 +85,17 @@ func Load() { apiToken = os.Getenv("API_TOKEN") } - blockedEndpointJSON := os.Getenv("BLOCKED_ENDPOINTS") - recipientsJSON := os.Getenv("RECIPIENTS") + blockedEndpointStrArray := os.Getenv("BLOCKED_ENDPOINTS") + recipientsStrArray := os.Getenv("RECIPIENTS") + messageAliasesJSON := os.Getenv("MESSAGE_ALIASES") variablesJSON := os.Getenv("VARIABLES") log.Info("Loaded Environment Variables") - apiTokens, err := utils.StringToArray(apiToken) + apiTokens := utils.StringToArray(apiToken) - if err != nil { + if apiTokens == nil { log.Warn("No API TOKEN provided this is NOT recommended") log.Info("Disabling Security Features due to incomplete Congfiguration") @@ -104,8 +107,17 @@ func Load() { ENV.API_TOKENS = apiTokens } - if blockedEndpointJSON != "" { - ENV.BLOCKED_ENDPOINTS = utils.GetJson[[]string](blockedEndpointJSON) + if blockedEndpointStrArray != "" { + if strings.Contains(blockedEndpointStrArray, "[") || strings.Contains(blockedEndpointStrArray, "]") { + //! Deprecated: JSON + //TODO: Remove this in new Version + + log.Error("Invalid Blocked Endpoints: ", "JSON instead of Comma seperated String") + + docker.Exit(1) + } + + ENV.BLOCKED_ENDPOINTS = utils.StringToArray(blockedEndpointStrArray) } if messageAliasesJSON != "" { @@ -116,7 +128,16 @@ func Load() { ENV.VARIABLES = utils.GetJson[map[string]any](variablesJSON) } - if recipientsJSON != "" { - ENV.VARIABLES["RECIPIENTS"] = utils.GetJson[[]string](recipientsJSON) + if recipientsStrArray != "" { + if strings.Contains(blockedEndpointStrArray, "[") || strings.Contains(blockedEndpointStrArray, "]") { + //! Deprecated: JSON + //TODO: Remove this in new Version + + log.Error("Invalid Blocked Endpoints: ", "JSON instead of Comma seperated String") + + docker.Exit(1) + } + + ENV.VARIABLES["RECIPIENTS"] = utils.StringToArray(recipientsStrArray) } } \ No newline at end of file diff --git a/utils/logger/logger.go b/utils/logger/logger.go index 7847ec2..78887b3 100644 --- a/utils/logger/logger.go +++ b/utils/logger/logger.go @@ -56,6 +56,8 @@ func getLogLevel(level string) zapcore.Level { return zapcore.WarnLevel case "error": return zapcore.ErrorLevel + case "fatal": + return zapcore.FatalLevel default: return zapcore.InfoLevel } @@ -73,11 +75,14 @@ func Error(msg ...string) { _log.Error(strings.Join(msg, "")) } +func Fatal(msg ...string) { + _log.Fatal(strings.Join(msg, "")) +} + func Warn(msg ...string) { _log.Warn(strings.Join(msg, "")) } - func Sync() { _ = _log.Sync() -} +} \ No newline at end of file diff --git a/utils/query/query.go b/utils/query/query.go index 789f33f..04b00da 100644 --- a/utils/query/query.go +++ b/utils/query/query.go @@ -30,7 +30,7 @@ func ParseRawQuery(raw string) map[string][]string { return result } -func TryParseInt(str string) (int, bool) { +func tryParseInt(str string) (int, bool) { isInt, err := regexp.MatchString(`^\d+$`, str) if isInt && err == nil { @@ -49,7 +49,7 @@ func ParseTypedQuery(values []string) interface{} { raw := values[0] - intValue, isInt := TryParseInt(raw) + intValue, isInt := tryParseInt(raw) if strings.Contains(raw, ",") || (strings.Contains(raw, "[") && strings.Contains(raw, "]")) { if strings.Contains(raw, "[") && strings.Contains(raw, "]") { @@ -63,7 +63,7 @@ func ParseTypedQuery(values []string) interface{} { var list []interface{} for _, part := range parts { - _intValue, _isInt := TryParseInt(part) + _intValue, _isInt := tryParseInt(part) if _isInt { list = append(list, _intValue) diff --git a/utils/request/request.go b/utils/request/request.go new file mode 100644 index 0000000..9e5c12c --- /dev/null +++ b/utils/request/request.go @@ -0,0 +1,158 @@ +package req + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/codeshelldev/secured-signal-api/utils/query" +) + +const ( + Json BodyType = "Json" + Form BodyType = "Form" + Unknown BodyType = "Unknown" +) + +type BodyType string + +type Body struct { + Data map[string]interface{} + Raw []byte + Empty bool +} + +func (body Body) ToString() string { + return string(body.Raw) +} + +func CreateBody(data map[string]interface{}) (Body, error) { + if len(data) <= 0 { + err := errors.New("empty data map") + + return Body{Empty: true}, err + } + + bytes, err := json.Marshal(data) + + if err != nil { + + return Body{Empty: true}, err + } + + isEmpty := len(data) <= 0 + + return Body{ + Data: data, + Raw: bytes, + Empty: isEmpty, + }, nil +} + +func GetJsonData(body []byte) (map[string]interface{}, error) { + var data map[string]interface{} + + err := json.Unmarshal(body, &data) + + if err != nil { + + return nil, err + } + + return data, nil +} + +func GetFormData(body []byte) (map[string]interface{}, error) { + data := map[string]interface{}{} + + queryData := query.ParseRawQuery(string(body)) + + if len(queryData) <= 0 { + err := errors.New("invalid form data") + + return nil, err + } + + for key, value := range queryData { + data[key] = query.ParseTypedQuery(value) + } + + return data, nil +} + +func GetBody(req *http.Request) ([]byte, error) { + bodyBytes, err := io.ReadAll(req.Body) + + if err != nil { + req.Body.Close() + + return nil, err + } + defer req.Body.Close() + + return bodyBytes, nil +} + +func GetReqBody(w http.ResponseWriter, req *http.Request) (Body, error) { + bytes, err := GetBody(req) + + var isEmpty bool + + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + + return Body{Empty: true}, err + } + + if len(bytes) <= 0 { + return Body{Empty: true}, nil + } + + var data map[string]interface{} + + switch GetBodyType(req) { + case Json: + data, err = GetJsonData(bytes) + + if err != nil { + http.Error(w, "Bad Request: invalid JSON", http.StatusBadRequest) + + return Body{Empty: true}, err + } + case Form: + data, err = GetFormData(bytes) + + if err != nil { + http.Error(w, "Bad Request: invalid Form", http.StatusBadRequest) + + return Body{Empty: true}, err + } + } + + isEmpty = len(data) <= 0 + + return Body{ + Raw: bytes, + Data: data, + Empty: isEmpty, + }, nil +} + +func GetBodyType(req *http.Request) BodyType { + contentType := req.Header.Get("Content-Type") + + switch { + case strings.HasPrefix(contentType, "application/json"): + return Json + + case strings.HasPrefix(contentType, "multipart/form-data"): + return Form + + case strings.HasPrefix(contentType, "application/x-www-form-urlencoded"): + return Form + default: + return Unknown + } +} \ No newline at end of file diff --git a/utils/utils.go b/utils/utils.go index 67368ff..181ff95 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -7,27 +7,25 @@ package utils import ( "encoding/json" - "errors" - "regexp" "strings" ) -func StringToArray(sliceStr string) ([]string, error) { - if sliceStr == "" { - return []string{}, errors.New("sliceStr is empty") - } - - re, err := regexp.Compile(`\s+`) - - if err != nil { - return []string{}, err - } +func StringToArray(sliceStr string) []string { + if sliceStr == "" { + return nil + } - normalized := re.ReplaceAllString(sliceStr, "") + rawItems := strings.Split(sliceStr, ",") + items := make([]string, 0, len(rawItems)) - tokens := strings.Split(normalized, ",") + for _, item := range rawItems { + trimmed := strings.TrimSpace(item) + if trimmed != "" { + items = append(items, trimmed) + } + } - return tokens, nil + return items } func GetJsonSafe[T any](jsonStr string) (T, error) {