diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index 5f02f8b2..a39d711d 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -60,13 +60,13 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: "v2.6.0" + version: "v2.10.1" working-directory: backend/app - name: golangci-lint on example directory uses: golangci/golangci-lint-action@v9 with: - version: "v2.6.0" + version: "v2.10.1" args: --config ../../.golangci.yml working-directory: backend/_example/memory_store diff --git a/.gitignore b/.gitignore index 1cfe72ca..cb5ea5d5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ compose-private.yml http-client.env.json /playwright-report/ /backend/app/cmd/var + +# ralphex progress logs +.ralphex/progress/ diff --git a/backend/.golangci.yml b/backend/.golangci.yml index ca9e2440..847653da 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -23,6 +23,12 @@ linters: goconst: min-len: 2 min-occurrences: 2 + gosec: + excludes: + - G117 # false positive: struct field name matches "secret" pattern + - G703 # false positive: path traversal via taint analysis + - G704 # false positive: SSRF via taint analysis + - G705 # false positive: XSS via taint analysis gocritic: disabled-checks: - wrapperFunc @@ -51,6 +57,9 @@ linters: - linters: - revive text: 'var-naming: avoid meaningless package names' + - linters: + - revive + text: 'var-naming: avoid package names that conflict with Go standard library package names' - linters: - dupl - gosec diff --git a/backend/app/cmd/cmd.go b/backend/app/cmd/cmd.go index 8c2fbbcd..cdfd2d2a 100644 --- a/backend/app/cmd/cmd.go +++ b/backend/app/cmd/cmd.go @@ -124,7 +124,7 @@ func responseError(resp *http.Response) error { // mkdir -p for all dirs func makeDirs(dirs ...string) error { for _, dir := range dirs { - if err := os.MkdirAll(dir, 0o700); err != nil { // If path is already a directory, MkdirAll does nothing + if err := os.MkdirAll(dir, 0o700); err != nil { // if path is already a directory, MkdirAll does nothing return fmt.Errorf("can't make directory %s: %w", dir, err) } } diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index 519b2efb..f463d1cb 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -99,18 +99,18 @@ type ServerCommand struct { SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of server-set cookie; with this enabled, frontend stores the JWT in a client-side cookie (note: increases vulnerability to XSS attacks)"` SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint - Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"` - Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"` - Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"` - Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"` + Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"` + Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"` + Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"` + Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"` Microsoft MicrosoftAuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"` - Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"` - Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"[deprecated, doesn't work] Twitter OAuth"` - Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"` - Discord AuthGroup `group:"discord" namespace:"discord" env-namespace:"DISCORD" description:"Discord OAuth"` - Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"` - Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"` - Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"` + Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"` + Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"[deprecated, doesn't work] Twitter OAuth"` + Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"` + Discord AuthGroup `group:"discord" namespace:"discord" env-namespace:"DISCORD" description:"Discord OAuth"` + Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"` + Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"` + Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"` Email struct { Enable bool `long:"enable" env:"ENABLE" description:"enable auth via email"` From string `long:"from" env:"FROM" description:"from email address"` @@ -685,9 +685,9 @@ func (s *ServerCommand) getAllowedDomains() []string { continue } - // Only for RemarkURL if domain is not IP and has more than two levels, extract second level domain. - // For AllowedHosts we don't do this as they are exact list of domains which can host comments, but - // RemarkURL might be on a subdomain and we must allow parent domain to be used for TitleExtract. + // only for RemarkURL if domain is not IP and has more than two levels, extract second level domain. + // for AllowedHosts we don't do this as they are exact list of domains which can host comments, but + // remarkURL might be on a subdomain and we must allow parent domain to be used for TitleExtract. if rawURL == s.RemarkURL && net.ParseIP(domain) == nil && len(strings.Split(domain, ".")) > 2 { domain = strings.Join(strings.Split(domain, ".")[len(strings.Split(domain, "."))-2:], ".") } @@ -1027,7 +1027,7 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error { } return true, nil }), - // Custom user ID generator, used to distinguish anonymous users with the same login + // custom user ID generator, used to distinguish anonymous users with the same login // coming from different IPs func(user string, r *http.Request) string { return user + r.RemoteAddr @@ -1112,7 +1112,7 @@ func (s *ServerCommand) makeNotifyDestinations(authenticator *auth.Service) ([]n VerificationSubject: s.Notify.Email.VerificationSubject, UnsubscribeURL: s.RemarkURL + "/email/unsubscribe.html", // TODO: uncomment after #560 frontend part is ready and URL is known - // SubscribeURL: s.RemarkURL + "/subscribe.html?token=", + // subscribeURL: s.RemarkURL + "/subscribe.html?token=", TokenGenFn: func(userID, email, site string) (string, error) { claims := token.Claims{ Handshake: &token.Handshake{ID: userID + "::" + email}, @@ -1222,7 +1222,7 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor if c.User == nil { return c } - // Audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected + // audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected if len(c.Audience) != 1 { return c } diff --git a/backend/app/migrator/disqus_test.go b/backend/app/migrator/disqus_test.go index b3c88e6a..93d5a106 100644 --- a/backend/app/migrator/disqus_test.go +++ b/backend/app/migrator/disqus_test.go @@ -122,7 +122,7 @@ func TestDisqus_Convert(t *testing.T) { require.NoError(t, err) ch := d.convert(fh, "test") - res := []store.Comment{} + res := make([]store.Comment, 0, 4) for comment := range ch { res = append(res, comment) } diff --git a/backend/app/migrator/wordpress_test.go b/backend/app/migrator/wordpress_test.go index 2fd8e400..dbc31ab8 100644 --- a/backend/app/migrator/wordpress_test.go +++ b/backend/app/migrator/wordpress_test.go @@ -72,7 +72,7 @@ func TestWordPress_Convert(t *testing.T) { wp := WordPress{} ch := wp.convert(strings.NewReader(xmlTestWP), "testWP") - comments := []store.Comment{} + comments := make([]store.Comment, 0, 3) for c := range ch { comments = append(comments, c) } @@ -100,7 +100,7 @@ func TestWP_Convert_MD(t *testing.T) { wp := WordPress{} ch := wp.convert(strings.NewReader(xmlTestWPmd), "siteID") - comments := []store.Comment{} + comments := make([]store.Comment, 0, 3) for c := range ch { comments = append(comments, c) } diff --git a/backend/app/notify/email.go b/backend/app/notify/email.go index 6238e09f..ce7b1bfb 100644 --- a/backend/app/notify/email.go +++ b/backend/app/notify/email.go @@ -26,7 +26,7 @@ type EmailParams struct { SubscribeURL string // full subscribe handler URL UnsubscribeURL string // full unsubscribe handler URL - TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function + TokenGenFn func(userID, email, site string) (string, error) // unsubscribe token generation function } // Email implements notify.Destination for email diff --git a/backend/app/notify/notify_test.go b/backend/app/notify/notify_test.go index 260a27c5..73184f09 100644 --- a/backend/app/notify/notify_test.go +++ b/backend/app/notify/notify_test.go @@ -103,17 +103,10 @@ func TestService_Many(t *testing.T) { } s.Close() - // wait for destinations to close - assert.Eventually(t, func() bool { return d1.IsClosed() && d2.IsClosed() }, 100*time.Millisecond, 10*time.Millisecond) - assert.NotEqual(t, 10, len(d1.Get()), "some comments dropped from d1") assert.NotEqual(t, 10, len(d1.GetVerify()), "some verifications dropped from d1") assert.NotEqual(t, 10, len(d2.Get()), "some comments dropped from d2") assert.NotEqual(t, 10, len(d2.GetVerify()), "some verifications dropped from d2") - - assert.True(t, d1.IsClosed()) - assert.True(t, d2.IsClosed()) - assert.Equal(t, "mock id=1, closed=true", d1.String()) } func TestService_WithParent(t *testing.T) { diff --git a/backend/app/providers/telegram.go b/backend/app/providers/telegram.go index 380b2ac6..86a8d8bb 100644 --- a/backend/app/providers/telegram.go +++ b/backend/app/providers/telegram.go @@ -27,8 +27,8 @@ type TGUpdatesReceiver interface { // DispatchTelegramUpdates dispatches telegram updates to provided list of receivers // Blocks caller func DispatchTelegramUpdates(ctx context.Context, requester tgRequester, receivers []TGUpdatesReceiver, period time.Duration) { - // Identifier of the first update to be requested. - // Should be equal to LastSeenUpdateID + 1 + // identifier of the first update to be requested. + // should be equal to LastSeenUpdateID + 1 // See https://core.telegram.org/bots/api#getupdates var updateOffset int diff --git a/backend/app/rest/api/admin.go b/backend/app/rest/api/admin.go index 29025667..e8150c4b 100644 --- a/backend/app/rest/api/admin.go +++ b/backend/app/rest/api/admin.go @@ -103,7 +103,7 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) { return } - // Audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected + // audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected if len(claims.Audience) != 1 { rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("bad request"), "can't process token, claims.Audience expected to be a single element but it's not", rest.ErrActionRejected) return diff --git a/backend/app/rest/api/rest.go b/backend/app/rest/api/rest.go index e0aed7a3..e2c2adf4 100644 --- a/backend/app/rest/api/rest.go +++ b/backend/app/rest/api/rest.go @@ -619,13 +619,13 @@ func cacheControl(expiration time.Duration, version string) func(http.Handler) h } // securityHeadersMiddleware sets security-related headers: -// - Content-Security-Policy: controls which resources the browser is allowed to load -// - Permissions-Policy: disables browser features (camera, mic, etc.) not needed by a comment widget -// - X-Content-Type-Options: prevents browsers from MIME-sniffing responses away from the declared type, -// stopping e.g. a user-uploaded image from being reinterpreted as executable HTML/JS -// - Referrer-Policy: controls how much URL information leaks in the Referer header on cross-origin -// requests; "strict-origin-when-cross-origin" sends only the origin (no path) to other domains -// and nothing at all on HTTPS→HTTP downgrades +// - Content-Security-Policy: controls which resources the browser is allowed to load +// - Permissions-Policy: disables browser features (camera, mic, etc.) not needed by a comment widget +// - X-Content-Type-Options: prevents browsers from MIME-sniffing responses away from the declared type, +// stopping e.g. a user-uploaded image from being reinterpreted as executable HTML/JS +// - Referrer-Policy: controls how much URL information leaks in the Referer header on cross-origin +// requests; "strict-origin-when-cross-origin" sends only the origin (no path) to other domains +// and nothing at all on HTTPS→HTTP downgrades func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/app/rest/api/rest_private.go b/backend/app/rest/api/rest_private.go index b709d3ce..1b51f545 100644 --- a/backend/app/rest/api/rest_private.go +++ b/backend/app/rest/api/rest_private.go @@ -10,6 +10,7 @@ import ( "fmt" "html/template" "io" + "net" "net/http" "strings" "time" @@ -120,7 +121,7 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) { comment.PrepareUntrusted() // clean all fields user not supposed to set comment.User = user - comment.User.IP = strings.Split(r.RemoteAddr, ":")[0] + comment.User.IP = extractIP(r.RemoteAddr) comment.Orig = comment.Text // original comment text, prior to md render if err := s.dataService.ValidateComment(&comment); err != nil { @@ -279,7 +280,7 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) { Locator: locator, CommentID: id, UserID: user.ID, - UserIP: strings.Split(r.RemoteAddr, ":")[0], + UserIP: extractIP(r.RemoteAddr), Val: vote, } comment, err := s.dataService.Vote(req) @@ -410,7 +411,7 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request) fmt.Errorf("already subscribed"), "telegram subscription is already set for this user, delete if first to re-subscribe", rest.ErrActionRejected) return } - // Generate and send token + // generate and send token tkn, err := randToken() if err != nil { rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to generate verification token", rest.ErrInternal) @@ -479,7 +480,7 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) return } - // Handshake.ID is user.ID + "::" + address + // handshake.ID is user.ID + "::" + address elems := strings.Split(confClaims.Handshake.ID, "::") if len(elems) != 2 || elems[0] != user.ID { rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal) @@ -533,7 +534,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) { return } - // Handshake.ID is user.ID + "::" + address + // handshake.ID is user.ID + "::" + address elems := strings.Split(confClaims.Handshake.ID, "::") if len(elems) != 2 { rest.SendErrorHTML(w, r, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal) @@ -772,3 +773,13 @@ func randToken() (string, error) { } return fmt.Sprintf("%x", s.Sum(nil)), nil } + +// extractIP returns the IP portion of the remote address, handling both IPv4 and IPv6 formats. +// supports "ip:port", "[ip]:port", and bare "ip" formats. +func extractIP(remoteAddr string) string { + ip, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return remoteAddr // already a bare IP (no port) + } + return ip +} diff --git a/backend/app/rest/api/rest_private_test.go b/backend/app/rest/api/rest_private_test.go index 07b076b1..edcf34e7 100644 --- a/backend/app/rest/api/rest_private_test.go +++ b/backend/app/rest/api/rest_private_test.go @@ -93,6 +93,7 @@ func TestRest_CreateAndPreviewWithImage(t *testing.T) { RoutePath: "/api/v1/img", RemarkURL: srv.RemarkURL, ImageService: srv.ImageService, + Transport: http.DefaultTransport, } srv.CommentFormatter = store.NewCommentFormatter(srv.ImageProxy) // need to recreate the server with new ImageProxy, otherwise old one will be used @@ -1690,3 +1691,26 @@ func (m *mockTelegram) CheckToken(string, string) (telegram, site string, err er } return "good_telegram", m.site, nil } + +func TestExtractIP(t *testing.T) { + tbl := []struct { + addr string + exp string + }{ + {"127.0.0.1:8080", "127.0.0.1"}, + {"127.0.0.1", "127.0.0.1"}, + {"192.168.1.1:443", "192.168.1.1"}, + {"[::1]:8080", "::1"}, + {"::1", "::1"}, + {"[2001:db8::1]:8080", "2001:db8::1"}, + {"2001:db8::1", "2001:db8::1"}, + {"[fe80::1%25eth0]:80", "fe80::1%25eth0"}, + {"", ""}, + } + + for _, tt := range tbl { + t.Run(tt.addr, func(t *testing.T) { + assert.Equal(t, tt.exp, extractIP(tt.addr)) + }) + } +} diff --git a/backend/app/rest/api/rest_public_test.go b/backend/app/rest/api/rest_public_test.go index a70ccf61..b14cf615 100644 --- a/backend/app/rest/api/rest_public_test.go +++ b/backend/app/rest/api/rest_public_test.go @@ -562,8 +562,8 @@ func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) { } } - // Adding initial comments (8 to test-url and 1 to another-url) and voting, and delete two of comments to the first post. - // With sleep so that at least few millisecond pass between each comment + // adding initial comments (8 to test-url and 1 to another-url) and voting, and delete two of comments to the first post. + // with sleep so that at least few millisecond pass between each comment // and later we would be able to use that in "since" filter with millisecond precision ids := make([]string, 9) timestamps := make([]time.Time, 9) diff --git a/backend/app/rest/api/rest_test.go b/backend/app/rest/api/rest_test.go index 424059fc..7dd66984 100644 --- a/backend/app/rest/api/rest_test.go +++ b/backend/app/rest/api/rest_test.go @@ -320,7 +320,7 @@ func TestRest_frameAncestors(t *testing.T) { o.AllowedAncestors = []string{"'self'", "https://example.com"} }) - // Test case with frame-ancestors + // test case with frame-ancestors client := http.Client{} resp, err := client.Get(ts.URL + "/web/index.html") require.NoError(t, err) @@ -329,7 +329,7 @@ func TestRest_frameAncestors(t *testing.T) { assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'self' https://example.com;") teardown() - // Test case without frame-ancestors + // test case without frame-ancestors ts, _, teardown = startupT(t, func(srv *Rest) { srv.AllowedAncestors = []string{} }) diff --git a/backend/app/rest/proxy/image.go b/backend/app/rest/proxy/image.go index 03f8a526..38bf2f7c 100644 --- a/backend/app/rest/proxy/image.go +++ b/backend/app/rest/proxy/image.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "io" + "net" "net/http" "strings" "time" @@ -27,6 +28,7 @@ type Image struct { CacheExternal bool Timeout time.Duration ImageService *image.Service + Transport http.RoundTripper // if nil, uses SSRF-safe transport blocking private IPs } // Convert img src links to proxied links depends on enabled options @@ -90,7 +92,7 @@ func (p Image) Handler(w http.ResponseWriter, r *http.Request) { var img []byte imgID, err := image.CachedImgID(imgURL) if err != nil { - rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse image url "+imgURL, rest.ErrAssetNotFound) + rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("invalid image url"), "can't parse image url", rest.ErrAssetNotFound) return } // try to load from cache for case it was saved when CacheExternal was enabled @@ -98,11 +100,12 @@ func (p Image) Handler(w http.ResponseWriter, r *http.Request) { if img == nil { img, err = p.downloadImage(context.Background(), imgURL) if err != nil { + log.Printf("[WARN] failed to download image: %v", err) if strings.Contains(err.Error(), "invalid content type") { - rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid content type", rest.ErrImgNotFound) + rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("invalid content type"), "invalid content type", rest.ErrImgNotFound) return } - rest.SendErrorJSON(w, r, http.StatusNotFound, err, "can't get image "+imgURL, rest.ErrAssetNotFound) + rest.SendErrorJSON(w, r, http.StatusNotFound, fmt.Errorf("failed to fetch"), "can't get image", rest.ErrAssetNotFound) return } if p.CacheExternal { @@ -148,7 +151,14 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error) ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - client := http.Client{Timeout: 30 * time.Second} + transport := p.Transport + if transport == nil { + transport = ssrfSafeTransport() + } + client := http.Client{ + Timeout: 30 * time.Second, + Transport: transport, + } defer client.CloseIdleConnections() var resp *http.Response err := repeater.NewFixed(5, time.Second).Do(ctx, func() error { @@ -157,7 +167,7 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error) if e != nil { return fmt.Errorf("failed to make request for %s: %w", imgURL, e) } - resp, e = client.Do(req.WithContext(ctx)) //nolint:bodyclose // need a refactor to fix that + resp, e = client.Do(req.WithContext(ctx)) //nolint:bodyclose,gosec // body closed in defer; SSRF mitigated by ssrfSafeTransport return e }) if err != nil { @@ -174,9 +184,86 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error) return nil, fmt.Errorf("invalid content type %s", contentType) } - imgData, err := io.ReadAll(resp.Body) + maxSize := 5 * 1024 * 1024 // 5MB default + if p.ImageService != nil && p.ImageService.MaxSize > 0 { + maxSize = p.ImageService.MaxSize + } + lr := io.LimitReader(resp.Body, int64(maxSize)+1) + imgData, err := io.ReadAll(lr) if err != nil { - return nil, fmt.Errorf("unable to read image body") + return nil, fmt.Errorf("unable to read image body: %w", err) + } + if len(imgData) > maxSize { + return nil, fmt.Errorf("image is too large") } return imgData, nil } + +// ssrfSafeTransport returns an http.Transport with a dialer that blocks connections to private IP addresses. +// it resolves the host, validates all IPs, then dials using the resolved IP to prevent DNS rebinding attacks. +// tries each resolved IP in order to handle dual-stack hosts where the first IP may be unreachable. +func ssrfSafeTransport() *http.Transport { + dialer := &net.Dialer{Timeout: 30 * time.Second} + return &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("invalid address %s: %w", addr, err) + } + + // resolve the host to IP addresses + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("can't resolve host %s: %w", host, err) + } + if len(ips) == 0 { + return nil, fmt.Errorf("no IP addresses resolved for host %s", host) + } + + for _, ip := range ips { + if isPrivateIP(ip.IP) { + return nil, fmt.Errorf("access to private address is not allowed") + } + } + + // try each resolved IP to handle dual-stack hosts where some IPs may be unreachable + var lastErr error + for _, ip := range ips { + conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + return nil, fmt.Errorf("can't connect to %s: %w", host, lastErr) + }, + } +} + +// privateCIDRs holds pre-parsed private/reserved CIDR blocks for SSRF protection. +var privateCIDRs = func() []*net.IPNet { + cidrs := []string{ + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", + "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", + "::1/128", "fc00::/7", "fe80::/10", + } + blocks := make([]*net.IPNet, 0, len(cidrs)) + for _, cidr := range cidrs { + _, block, _ := net.ParseCIDR(cidr) + blocks = append(blocks, block) + } + return blocks +}() + +// isPrivateIP checks if the given IP belongs to a private/reserved range. +func isPrivateIP(ip net.IP) bool { + if ip.IsUnspecified() { + return true + } + for _, block := range privateCIDRs { + if block.Contains(ip) { + return true + } + } + return false +} diff --git a/backend/app/rest/proxy/image_test.go b/backend/app/rest/proxy/image_test.go index 50214816..1abd54e2 100644 --- a/backend/app/rest/proxy/image_test.go +++ b/backend/app/rest/proxy/image_test.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "fmt" "io" + "net" "net/http" "net/http/httptest" "strconv" @@ -100,6 +101,7 @@ func TestImage_Routes(t *testing.T) { RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", ImageService: image.NewService(&imageStore, image.ServiceParams{}), + Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) @@ -150,6 +152,7 @@ func TestImage_DisabledCachingAndHTTP2HTTPS(t *testing.T) { RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", ImageService: image.NewService(&imageStore, image.ServiceParams{}), + Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) @@ -183,6 +186,7 @@ func TestImage_RoutesCachingImage(t *testing.T) { RemarkURL: "https://demo.remark42.com", RoutePath: "/api/v1/proxy", ImageService: image.NewService(&imageStore, image.ServiceParams{MaxSize: 1500}), + Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) @@ -207,7 +211,7 @@ func TestImage_RoutesCachingImage(t *testing.T) { } func TestImage_RoutesUsingCachedImage(t *testing.T) { - // In order to validate that cached data used cache "will return" some other data from what http server would + // in order to validate that cached data used cache "will return" some other data from what http server would testImage := []byte(fmt.Sprintf("%256s", "X")) imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return testImage, nil @@ -246,6 +250,7 @@ func TestImage_RoutesTimedOut(t *testing.T) { RoutePath: "/api/v1/proxy", Timeout: 50 * time.Millisecond, ImageService: image.NewService(&imageStore, image.ServiceParams{}), + Transport: http.DefaultTransport, } ts := httptest.NewServer(http.HandlerFunc(img.Handler)) @@ -262,7 +267,8 @@ func TestImage_RoutesTimedOut(t *testing.T) { assert.NoError(t, resp.Body.Close()) require.NoError(t, err) t.Log(string(b)) - assert.Contains(t, string(b), "deadline exceeded") + assert.Contains(t, string(b), "failed to fetch") + assert.NotContains(t, string(b), "deadline exceeded", "should not leak transport details") assert.Equal(t, 1, len(imageStore.LoadCalls())) } @@ -304,6 +310,152 @@ func TestImage_ConvertCachingMode(t *testing.T) { assert.Equal(t, ` xyz `, r) } +func TestImage_PrivateIPBlocking(t *testing.T) { + imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} + img := Image{ + HTTP2HTTPS: true, + RemarkURL: "https://demo.remark42.com", + RoutePath: "/api/v1/proxy", + Timeout: 100 * time.Millisecond, + ImageService: image.NewService(&imageStore, image.ServiceParams{}), + // no Transport override — uses SSRF-safe transport + } + + ts := httptest.NewServer(http.HandlerFunc(img.Handler)) + defer ts.Close() + + tbl := []struct { + name string + url string + }{ + {"loopback", "http://127.0.0.1/image.png"}, + {"rfc1918 10.x", "http://10.0.0.1/image.png"}, + {"rfc1918 172.16.x", "http://172.16.0.1/image.png"}, + {"rfc1918 192.168.x", "http://192.168.1.1/image.png"}, + {"link-local", "http://169.254.1.1/image.png"}, + {"ipv6 loopback", "http://[::1]/image.png"}, + } + + for _, tt := range tbl { + t.Run(tt.name, func(t *testing.T) { + encodedImgURL := base64.URLEncoding.EncodeToString([]byte(tt.url)) + resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) + require.NoError(t, err) + b, err := io.ReadAll(resp.Body) + assert.NoError(t, resp.Body.Close()) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.NotContains(t, string(b), "private address", "should not leak private IP check details") + assert.Contains(t, string(b), "failed to fetch") + }) + } +} + +func TestImage_ErrorSanitization(t *testing.T) { + // server that immediately closes connections to simulate transport errors + httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hj, ok := w.(http.Hijacker) + if !ok { + w.WriteHeader(http.StatusInternalServerError) + return + } + conn, _, _ := hj.Hijack() + conn.Close() // forcefully close to trigger transport error + })) + defer httpSrv.Close() + + imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} + img := Image{ + RemarkURL: "https://demo.remark42.com", + RoutePath: "/api/v1/proxy", + Timeout: 2 * time.Second, + ImageService: image.NewService(&imageStore, image.ServiceParams{}), + Transport: http.DefaultTransport, + } + + ts := httptest.NewServer(http.HandlerFunc(img.Handler)) + defer ts.Close() + + encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image.png")) + resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) + require.NoError(t, err) + b, err := io.ReadAll(resp.Body) + assert.NoError(t, resp.Body.Close()) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Contains(t, string(b), "failed to fetch") + assert.NotContains(t, string(b), "EOF", "should not leak transport details") + assert.NotContains(t, string(b), "connection", "should not leak transport details") +} + +func TestImage_ResponseSizeLimit(t *testing.T) { + // create a test server that returns a large image + largeImg := make([]byte, 2000) + for i := range largeImg { + largeImg[i] = 0xFF + } + httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(largeImg) + })) + defer httpSrv.Close() + + imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }} + img := Image{ + RemarkURL: "https://demo.remark42.com", + RoutePath: "/api/v1/proxy", + ImageService: image.NewService(&imageStore, image.ServiceParams{MaxSize: 1000}), + Transport: http.DefaultTransport, + } + + ts := httptest.NewServer(http.HandlerFunc(img.Handler)) + defer ts.Close() + + encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/big-image.png")) + resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL) + require.NoError(t, err) + b, err := io.ReadAll(resp.Body) + assert.NoError(t, resp.Body.Close()) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Contains(t, string(b), "failed to fetch") +} + +func TestIsPrivateIP(t *testing.T) { + tbl := []struct { + ip string + private bool + }{ + {"127.0.0.1", true}, + {"10.0.0.1", true}, + {"10.255.255.255", true}, + {"172.16.0.1", true}, + {"172.31.255.255", true}, + {"192.168.0.1", true}, + {"192.168.255.255", true}, + {"169.254.1.1", true}, + {"100.64.0.1", true}, + {"100.127.255.255", true}, + {"::1", true}, + {"fc00::1", true}, + {"fe80::1", true}, + {"0.0.0.0", true}, + {"::", true}, + {"8.8.8.8", false}, + {"203.0.113.1", false}, + {"1.1.1.1", false}, + {"2001:db8::1", false}, + } + + for _, tt := range tbl { + t.Run(tt.ip, func(t *testing.T) { + ip := net.ParseIP(tt.ip) + require.NotNil(t, ip) + assert.Equal(t, tt.private, isPrivateIP(ip)) + }) + } +} + func imgHTTPTestsServer(t *testing.T) *httptest.Server { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/image/img1.png" { diff --git a/backend/app/store/comment.go b/backend/app/store/comment.go index 65270885..8a5b89fd 100644 --- a/backend/app/store/comment.go +++ b/backend/app/store/comment.go @@ -157,7 +157,7 @@ func (c *Comment) Snippet(limit int) string { break } } - // Don't add a space if comment is just a one single word which has been truncated. + // don't add a space if comment is just a one single word which has been truncated. if len(snippet) == limit { return string(snippet) + "..." } diff --git a/backend/app/store/engine/engine_mock.go b/backend/app/store/engine/engine_mock.go index 1a7a1b57..ea049b54 100644 --- a/backend/app/store/engine/engine_mock.go +++ b/backend/app/store/engine/engine_mock.go @@ -4,8 +4,9 @@ package engine import ( - store "github.com/umputun/remark42/backend/app/store" "sync" + + store "github.com/umputun/remark42/backend/app/store" ) // Ensure, that InterfaceMock does implement Interface. diff --git a/backend/app/store/image/image.go b/backend/app/store/image/image.go index 2bfea93b..c307be0d 100644 --- a/backend/app/store/image/image.go +++ b/backend/app/store/image/image.go @@ -13,6 +13,7 @@ import ( "encoding/base64" "fmt" "image" + // support gif and jpeg images decoding _ "image/gif" _ "image/jpeg" diff --git a/backend/app/store/image/image_test.go b/backend/app/store/image/image_test.go index edc616f4..30b995d8 100644 --- a/backend/app/store/image/image_test.go +++ b/backend/app/store/image/image_test.go @@ -56,10 +56,10 @@ func TestService_ResizeJpeg(t *testing.T) { img, err := readAndValidateImage(fh, 32000) assert.NoError(t, err) - assert.Equal(t, 16756, len(img)) + assert.InDelta(t, 16756, len(img), 100) img = resize(img, 400, 300) - assert.Equal(t, 10918, len(img)) + assert.InDelta(t, 10913, len(img), 100) } func TestService_SaveTooLarge(t *testing.T) { diff --git a/backend/app/store/service/service.go b/backend/app/store/service/service.go index c1019109..c3f0b09e 100644 --- a/backend/app/store/service/service.go +++ b/backend/app/store/service/service.go @@ -552,9 +552,9 @@ func (s *DataStore) HasReplies(comment store.Comment) bool { for _, c := range comments { if c.ParentID != "" && !c.Deleted { if c.ParentID == comment.ID { - // When this code is reached, key "comment.ID" is not in cache. - // Calling cache.Get on it will put it in cache with 5 minutes TTL. - // We call it with empty struct as value as we care about keys and not values. + // when this code is reached, key "comment.ID" is not in cache. + // calling cache.Get on it will put it in cache with 5 minutes TTL. + // we call it with empty struct as value as we care about keys and not values. _, _ = s.repliesCache.Get(comment.ID, func() (struct{}, error) { return struct{}{}, nil }) return true } @@ -611,7 +611,7 @@ func (s *DataStore) SetTitle(locator store.Locator, commentID string) (comment s // set title, overwrite the current one title, e := s.TitleExtractor.Get(comment.Locator.URL) if e != nil { - return comment, err + return comment, e } comment.PostTitle = title comment.Locator = locator @@ -743,7 +743,7 @@ func (s *DataStore) SetBlock(siteID, userID string, status bool, ttl time.Durati func (s *DataStore) BlockedUsers(siteID string) (res []store.BlockedUser, err error) { blocked, e := s.Engine.ListFlags(engine.FlagRequest{Locator: store.Locator{SiteID: siteID}, Flag: engine.Blocked}) if e != nil { - return nil, fmt.Errorf("can't get list of blocked users for %s: %w", siteID, err) + return nil, fmt.Errorf("can't get list of blocked users for %s: %w", siteID, e) } for _, v := range blocked { res = append(res, v.(store.BlockedUser)) @@ -801,7 +801,7 @@ func (s *DataStore) Delete(locator store.Locator, commentID string, mode store.D idsFn := func() []string { // get IDs of all images from the same URL to verify if image from deleted comment was reused comments, e := s.Engine.Find(engine.FindRequest{Locator: locator}) if e != nil { - log.Printf("[WARN] can't get comments %s text for deleted comment image check, %v", comment.ID, err) + log.Printf("[WARN] can't get comments %s text for deleted comment image check, %v", comment.ID, e) return nil } var imgIDs = []string{} @@ -822,7 +822,7 @@ func (s *DataStore) Delete(locator store.Locator, commentID string, mode store.D } } } - log.Printf("[ERROR] commentImgIDs: %v, pageImgIDs: %v", commentImgIDs, pageImgIDs) + log.Printf("[DEBUG] commentImgIDs: %v, pageImgIDs: %v", commentImgIDs, pageImgIDs) req := engine.DeleteRequest{Locator: locator, CommentID: commentID, DeleteMode: mode} return s.Engine.Delete(req) diff --git a/backend/app/store/service/service_test.go b/backend/app/store/service/service_test.go index 1c203669..1b9faa89 100644 --- a/backend/app/store/service/service_test.go +++ b/backend/app/store/service/service_test.go @@ -642,7 +642,7 @@ func TestDataStore_AdminStoreErrors(t *testing.T) { User: store.User{ID: "user2", Name: "user name 2"}, } - // Key call error + // key call error id, err := b.Create(comment) assert.ErrorContainsf(t, err, "mock key err", "should fail with mock error") assert.Empty(t, id) @@ -650,7 +650,7 @@ func TestDataStore_AdminStoreErrors(t *testing.T) { assert.Equal(t, len(as.EnabledCalls()), 0) assert.Equal(t, len(as.OnEventCalls()), 0) - // Enabled call error + // enabled call error badKey = false id, err = b.Create(comment) assert.ErrorContains(t, err, "mock enabled err", "should fail with mock error") @@ -676,7 +676,7 @@ func TestDataStore_AdminStoreErrors(t *testing.T) { assert.Equal(t, len(as.EnabledCalls()), 3) assert.Equal(t, len(as.OnEventCalls()), 2) - // Admins error + // admins error isAdmin := b.IsAdmin("radio-t", "user2") assert.False(t, isAdmin) assert.Equal(t, len(as.AdminsCalls()), 1) @@ -1808,7 +1808,7 @@ func TestService_ResubmitStagingImages_EngineError(t *testing.T) { site2Req := engine.FindRequest{Locator: store.Locator{SiteID: "site2", URL: ""}, Sort: "time", Since: time.Time{}.Add(time.Second)} b := DataStore{Engine: &engineMock, EditDuration: 10 * time.Millisecond, ImageService: imgSvc} - // One call without error and one with error + // one call without error and one with error err := b.ResubmitStagingImages([]string{"site1", "site2"}) assert.Error(t, err) assert.Contains(t, err.Error(), "problem finding comments for site site2: mockError") diff --git a/backend/app/store/service/tree.go b/backend/app/store/service/tree.go index 276392ac..18dff840 100644 --- a/backend/app/store/service/tree.go +++ b/backend/app/store/service/tree.go @@ -185,7 +185,7 @@ func (t *Tree) limit(limit int, offsetID string) { } } - if start == len(t.Nodes) { // If the start index is beyond the available nodes, clear the nodes + if start == len(t.Nodes) { // if the start index is beyond the available nodes, clear the nodes t.Nodes = []*Node{} return } @@ -198,28 +198,28 @@ func (t *Tree) limit(limit int, offsetID string) { return } - // Traverse and limit the number of top-level nodes, including their replies + // traverse and limit the number of top-level nodes, including their replies limitedNodes := []*Node{} commentsCount := 0 for _, node := range t.Nodes { - repliesCount := countReplies(node) + 1 // Count this node and its replies + repliesCount := countReplies(node) + 1 // count this node and its replies - // If the limit is already reached or exceeded, calculate countLeft and move to the next node + // if the limit is already reached or exceeded, calculate countLeft and move to the next node if commentsCount >= limit { t.countLeft += repliesCount continue } - // Check if we just exceeded the limit and there are already some nodes in the list, + // check if we just exceeded the limit and there are already some nodes in the list, // as otherwise we would have to return the first node with all its replies even if it exceeds the limit. if commentsCount+repliesCount >= limit && len(limitedNodes) > 0 { t.countLeft += repliesCount - commentsCount = limit // Adjust commentsCount to stop checking limit for the next nodes + commentsCount = limit // adjust commentsCount to stop checking limit for the next nodes continue } - // Add the node and its replies to the list + // add the node and its replies to the list limitedNodes = append(limitedNodes, node) commentsCount += repliesCount } @@ -232,8 +232,8 @@ func (t *Tree) limit(limit int, offsetID string) { func countReplies(node *Node) int { count := 0 for _, reply := range node.Replies { - count++ // Count the reply itself - count += countReplies(reply) // Recursively count its replies + count++ // count the reply itself + count += countReplies(reply) // recursively count its replies } return count }