Human Verification
++ Please complete the verification below to continue. +
+ +diff --git a/internal/auth/oauth_refresh.go b/internal/auth/oauth_refresh.go index 8457f24..d6a8d4e 100644 --- a/internal/auth/oauth_refresh.go +++ b/internal/auth/oauth_refresh.go @@ -131,7 +131,7 @@ func (auth *OIDCProvider) setSessionTokenCookie(w http.ResponseWriter, r *http.R logging.Err(err).Msg("failed to sign session token") return } - setTokenCookie(w, r, CookieOauthSessionToken, signed, common.APIJWTTokenTTL) + SetTokenCookie(w, r, CookieOauthSessionToken, signed, common.APIJWTTokenTTL) } func (auth *OIDCProvider) parseSessionJWT(sessionJWT string) (claims *sessionClaims, valid bool, err error) { diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 0c750eb..18e0648 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -176,7 +176,7 @@ func (auth *OIDCProvider) LoginHandler(w http.ResponseWriter, r *http.Request) { } state := generateState() - setTokenCookie(w, r, CookieOauthState, state, 300*time.Second) + SetTokenCookie(w, r, CookieOauthState, state, 300*time.Second) // redirect user to Idp http.Redirect(w, r, auth.oauthConfig.AuthCodeURL(state, optRedirectPostAuth(r)), http.StatusFound) } @@ -301,12 +301,12 @@ func (auth *OIDCProvider) LogoutHandler(w http.ResponseWriter, r *http.Request) } func (auth *OIDCProvider) setIDTokenCookie(w http.ResponseWriter, r *http.Request, jwt string, ttl time.Duration) { - setTokenCookie(w, r, CookieOauthToken, jwt, ttl) + SetTokenCookie(w, r, CookieOauthToken, jwt, ttl) } func (auth *OIDCProvider) clearCookie(w http.ResponseWriter, r *http.Request) { - clearTokenCookie(w, r, CookieOauthToken) - clearTokenCookie(w, r, CookieOauthSessionToken) + ClearTokenCookie(w, r, CookieOauthToken) + ClearTokenCookie(w, r, CookieOauthSessionToken) } // handleTestCallback handles OIDC callback in test environment. @@ -323,7 +323,7 @@ func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Requ } // Create test JWT token - setTokenCookie(w, r, CookieOauthToken, "test", time.Hour) + SetTokenCookie(w, r, CookieOauthToken, "test", time.Hour) http.Redirect(w, r, "/", http.StatusFound) } diff --git a/internal/auth/userpass.go b/internal/auth/userpass.go index 8b8beb8..bbcdebb 100644 --- a/internal/auth/userpass.go +++ b/internal/auth/userpass.go @@ -119,7 +119,7 @@ func (auth *UserPassAuth) PostAuthCallbackHandler(w http.ResponseWriter, r *http gphttp.ServerError(w, r, err) return } - setTokenCookie(w, r, auth.TokenCookieName(), token, auth.tokenTTL) + SetTokenCookie(w, r, auth.TokenCookieName(), token, auth.tokenTTL) w.WriteHeader(http.StatusOK) } @@ -128,7 +128,7 @@ func (auth *UserPassAuth) LoginHandler(w http.ResponseWriter, r *http.Request) { } func (auth *UserPassAuth) LogoutHandler(w http.ResponseWriter, r *http.Request) { - clearTokenCookie(w, r, auth.TokenCookieName()) + ClearTokenCookie(w, r, auth.TokenCookieName()) http.Redirect(w, r, "/", http.StatusFound) } diff --git a/internal/auth/utils.go b/internal/auth/utils.go index f1c20d6..5fb043b 100644 --- a/internal/auth/utils.go +++ b/internal/auth/utils.go @@ -44,7 +44,7 @@ func cookieDomain(r *http.Request) string { return strutils.JoinRune(parts, '.') } -func setTokenCookie(w http.ResponseWriter, r *http.Request, name, value string, ttl time.Duration) { +func SetTokenCookie(w http.ResponseWriter, r *http.Request, name, value string, ttl time.Duration) { http.SetCookie(w, &http.Cookie{ Name: name, Value: value, @@ -57,7 +57,7 @@ func setTokenCookie(w http.ResponseWriter, r *http.Request, name, value string, }) } -func clearTokenCookie(w http.ResponseWriter, r *http.Request, name string) { +func ClearTokenCookie(w http.ResponseWriter, r *http.Request, name string) { http.SetCookie(w, &http.Cookie{ Name: name, Value: "", diff --git a/internal/gperr/utils.go b/internal/gperr/utils.go index 46a8740..1fb3e65 100644 --- a/internal/gperr/utils.go +++ b/internal/gperr/utils.go @@ -85,6 +85,14 @@ func Join(errors ...error) Error { return &nestedError{Extras: errs} } +func JoinLines(main error, errors ...string) Error { + errs := make([]error, len(errors)) + for i, err := range errors { + errs[i] = newError(err) + } + return &nestedError{Err: main, Extras: errs} +} + func Collect[T any, Err error, Arg any, Func func(Arg) (T, Err)](eb *Builder, fn Func, arg Arg) T { result, err := fn(arg) eb.Add(err) diff --git a/internal/net/gphttp/httpheaders/csp.go b/internal/net/gphttp/httpheaders/csp.go new file mode 100644 index 0000000..d77b595 --- /dev/null +++ b/internal/net/gphttp/httpheaders/csp.go @@ -0,0 +1,69 @@ +package httpheaders + +import ( + "net/http" + "strings" +) + +// AppendCSP appends a CSP header to specific directives in the response writer. +// +// Directives other than the ones in cspDirectives will be kept as is. +// +// It will replace 'none' with the sources. +// +// It will append 'self' to the sources if it's not already present. +func AppendCSP(w http.ResponseWriter, r *http.Request, cspDirectives []string, sources []string) { + csp := make(map[string]string) + cspValues := r.Header.Values("Content-Security-Policy") + if len(cspValues) == 1 { + cspValues = strings.Split(cspValues[0], ";") + for i, cspString := range cspValues { + cspValues[i] = strings.TrimSpace(cspString) + } + } + + for _, cspString := range cspValues { + parts := strings.SplitN(cspString, " ", 2) + if len(parts) == 2 { + csp[parts[0]] = parts[1] + } + } + + for _, directive := range cspDirectives { + value, ok := csp[directive] + if !ok { + value = "'self'" + } + switch value { + case "'self'": + csp[directive] = value + " " + strings.Join(sources, " ") + case "'none'": + csp[directive] = strings.Join(sources, " ") + default: + for _, source := range sources { + if !strings.Contains(value, source) { + value += " " + source + } + } + if !strings.Contains(value, "'self'") { + value = "'self' " + value + } + csp[directive] = value + } + } + + values := make([]string, 0, len(csp)) + for directive, value := range csp { + values = append(values, directive+" "+value) + } + + // Remove existing CSP header, case insensitive + for k := range w.Header() { + if strings.EqualFold(k, "Content-Security-Policy") { + delete(w.Header(), k) + } + } + + // Set new CSP header + w.Header()["Content-Security-Policy"] = values +} diff --git a/internal/net/gphttp/httpheaders/csp_test.go b/internal/net/gphttp/httpheaders/csp_test.go new file mode 100644 index 0000000..95b33c2 --- /dev/null +++ b/internal/net/gphttp/httpheaders/csp_test.go @@ -0,0 +1,168 @@ +package httpheaders + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAppendCSP(t *testing.T) { + tests := []struct { + name string + initialHeaders map[string][]string + sources []string + directives []string + expectedCSP map[string]string + }{ + { + name: "No CSP header", + initialHeaders: map[string][]string{}, + sources: []string{}, + directives: []string{"default-src", "script-src", "frame-src", "style-src", "connect-src"}, + expectedCSP: map[string]string{"default-src": "'self'", "script-src": "'self'", "frame-src": "'self'", "style-src": "'self'", "connect-src": "'self'"}, + }, + { + name: "No CSP header with sources", + initialHeaders: map[string][]string{}, + sources: []string{"https://example.com"}, + directives: []string{"default-src", "script-src", "frame-src", "style-src", "connect-src"}, + expectedCSP: map[string]string{"default-src": "'self' https://example.com", "script-src": "'self' https://example.com", "frame-src": "'self' https://example.com", "style-src": "'self' https://example.com", "connect-src": "'self' https://example.com"}, + }, + { + name: "replace 'none' with sources", + initialHeaders: map[string][]string{ + "Content-Security-Policy": {"default-src 'none'"}, + }, + sources: []string{"https://example.com"}, + directives: []string{"default-src"}, + expectedCSP: map[string]string{"default-src": "https://example.com"}, + }, + { + name: "CSP header with some directives", + initialHeaders: map[string][]string{ + "Content-Security-Policy": {"default-src 'none'", "script-src 'unsafe-inline'"}, + }, + sources: []string{"https://example.com"}, + directives: []string{"script-src"}, + expectedCSP: map[string]string{ + "default-src": "'none", + "script-src": "'unsafe-inline' https://example.com", + }, + }, + { + name: "CSP header with some directives with self", + initialHeaders: map[string][]string{ + "Content-Security-Policy": {"default-src 'self'", "connect-src 'self'"}, + }, + sources: []string{"https://api.example.com"}, + directives: []string{"default-src", "connect-src"}, + expectedCSP: map[string]string{ + "default-src": "'self' https://api.example.com", + "connect-src": "'self' https://api.example.com", + }, + }, + { + name: "AppendCSP sources conflict with existing CSP header", + initialHeaders: map[string][]string{ + "Content-Security-Policy": {"default-src 'self' https://cdn.example.com", "script-src 'unsafe-inline'"}, + }, + sources: []string{"https://cdn.example.com", "https://api.example.com"}, + directives: []string{"default-src", "script-src"}, + expectedCSP: map[string]string{ + "default-src": "'self' https://cdn.example.com https://api.example.com", + "script-src": "'unsafe-inline' https://cdn.example.com https://api.example.com", + }, + }, + { + name: "Non-standard CSP directive", + initialHeaders: map[string][]string{ + "Content-Security-Policy": { + "default-src 'self'", + "script-src 'unsafe-inline'", + "img-src 'self'", // img-src is not in cspDirectives list + }, + }, + sources: []string{"https://example.com"}, + directives: []string{"default-src", "script-src"}, + expectedCSP: map[string]string{ + "default-src": "'self' https://example.com", + "script-src": "'unsafe-inline' https://example.com", + // img-src should not be present in response as it's not in cspDirectives + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create a test request with initial headers + req := httptest.NewRequest(http.MethodGet, "/", nil) + for header, values := range tc.initialHeaders { + req.Header[header] = values + } + + // Create a test response recorder + w := httptest.NewRecorder() + + // Call the function under test + AppendCSP(w, req, tc.directives, tc.sources) + + // Check the resulting CSP headers + respHeaders := w.Header() + cspValues, exists := respHeaders["Content-Security-Policy"] + + // If we expect no CSP headers, verify none exist + if len(tc.expectedCSP) == 0 { + if exists && len(cspValues) > 0 { + t.Errorf("Expected no CSP header, but got %v", cspValues) + } + return + } + + // Verify CSP headers exist when expected + if !exists || len(cspValues) == 0 { + t.Errorf("Expected CSP header to be set, but it was not") + return + } + + // Parse the CSP response and verify each directive + foundDirectives := make(map[string]string) + for _, cspValue := range cspValues { + parts := strings.Split(cspValue, ";") + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + directiveParts := strings.SplitN(part, " ", 2) + if len(directiveParts) != 2 { + t.Errorf("Invalid CSP directive format: %s", part) + continue + } + + directive := directiveParts[0] + value := directiveParts[1] + foundDirectives[directive] = value + } + } + + // Verify expected directives + for directive, expectedValue := range tc.expectedCSP { + actualValue, ok := foundDirectives[directive] + if !ok { + t.Errorf("Expected directive %s not found in response", directive) + continue + } + + // Check if all expected sources are in the actual value + expectedSources := strings.SplitSeq(expectedValue, " ") + for source := range expectedSources { + if !strings.Contains(actualValue, source) { + t.Errorf("Directive %s missing expected source %s. Got: %s", directive, source, actualValue) + } + } + } + }) + } +} diff --git a/internal/net/gphttp/middleware/captcha.go b/internal/net/gphttp/middleware/captcha.go new file mode 100644 index 0000000..6248ad3 --- /dev/null +++ b/internal/net/gphttp/middleware/captcha.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "net/http" + + "github.com/yusing/go-proxy/internal/net/gphttp/middleware/captcha" +) + +type hCaptcha struct { + captcha.HcaptchaProvider +} + +func (h *hCaptcha) before(w http.ResponseWriter, r *http.Request) (proceed bool) { + return captcha.PreRequest(h, w, r) +} + +var HCaptcha = NewMiddleware[hCaptcha]() diff --git a/internal/net/gphttp/middleware/captcha/captcha.html b/internal/net/gphttp/middleware/captcha/captcha.html new file mode 100644 index 0000000..aaf3900 --- /dev/null +++ b/internal/net/gphttp/middleware/captcha/captcha.html @@ -0,0 +1,293 @@ + + +
+ + ++ Please complete the verification below to continue. +
+ +