diff --git a/internal/gateway/methods/bitrix_portals.go b/internal/gateway/methods/bitrix_portals.go index 4157f8d7..99eb35a7 100644 --- a/internal/gateway/methods/bitrix_portals.go +++ b/internal/gateway/methods/bitrix_portals.go @@ -71,7 +71,11 @@ type bitrixPortalView struct { var ( // Bitrix24 cloud portal hosts. Matches *.bitrix24.{com,eu,ru,de,fr,jp,in,kz,ua,by} // plus self-hosted *.bitrix.info. Subdomain regex matches DNS label rules. - bitrixDomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.(bitrix24\.(com|eu|ru|de|fr|jp|in|kz|ua|by)|bitrix\.info)$`) + bitrixCloudDomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.(bitrix24\.(com|eu|ru|de|fr|jp|in|kz|ua|by)|bitrix\.info)$`) + + // Valid hostname regex for self-hosted Bitrix24 instances (custom domains). + // Accepts any valid FQDN or hostname with optional port (e.g. bx.example.com, portal.internal:8443). + selfHostedDomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*(\:\d+)?$`) // Portal name: lowercase slug used in install state token + channel config // reference. Underscore allowed for legacy CLI-created portals. @@ -142,8 +146,8 @@ func (m *BitrixPortalsMethods) handleCreate(ctx context.Context, client *gateway client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "name: lowercase letters, digits, hyphen, underscore (2-64 chars)"))) return } - if !bitrixDomainRegex.MatchString(domain) { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "domain: must be *.bitrix24.{com,eu,ru,…} or *.bitrix.info"))) + if !bitrixCloudDomainRegex.MatchString(domain) && !selfHostedDomainRegex.MatchString(domain) { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "domain: must be a valid hostname (e.g. *.bitrix24.com, *.bitrix.info, or your self-hosted domain)"))) return } if clientID == "" || clientSecret == "" { diff --git a/internal/gateway/methods/bitrix_portals_test.go b/internal/gateway/methods/bitrix_portals_test.go index 1405267b..f2d9995b 100644 --- a/internal/gateway/methods/bitrix_portals_test.go +++ b/internal/gateway/methods/bitrix_portals_test.go @@ -369,7 +369,7 @@ func TestBitrixPortals_Create_InvalidDomain(t *testing.T) { client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "u", 4) m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{ "name": "p", - "domain": "not-a-bitrix-domain.com", + "domain": "-invalid.com", "client_id": "x", "client_secret": "y", })) @@ -380,6 +380,29 @@ func TestBitrixPortals_Create_InvalidDomain(t *testing.T) { } } +func TestBitrixPortals_Create_SelfHostedDomain(t *testing.T) { + tid := uuid.MustParse("22222222-2222-2222-2222-222222222222") + pStore := newStubBitrixPortalStore() + m := NewBitrixPortalsMethods(pStore, newStubChannelInstanceStore(), gatewayURLFn("https://goclaw.tamgiac.com")) + + client, ch := gateway.NewCapturingTestClient(permissions.RoleAdmin, tid, "admin", 4) + m.handleCreate(store.WithTenantID(context.Background(), tid), client, buildBitrixReq(t, protocol.MethodBitrixPortalsCreate, map[string]string{ + "name": "myportal", + "domain": "bx.mycompany.com", + "client_id": "local.abc", + "client_secret": "secret123", + })) + + resp := readResponse(t, ch) + if resp.Error != nil { + t.Fatalf("create with self-hosted domain failed: %+v", resp.Error) + } + result := resp.Payload.(map[string]any) + if result["domain"] != "bx.mycompany.com" { + t.Errorf("domain = %q, want bx.mycompany.com", result["domain"]) + } +} + func TestBitrixPortals_Create_InvalidName(t *testing.T) { tid := uuid.New() m := NewBitrixPortalsMethods(newStubBitrixPortalStore(), newStubChannelInstanceStore(), gatewayURLFn("https://gw.example.com")) @@ -543,19 +566,44 @@ func TestBitrixDomainRegex(t *testing.T) { } bad := []string{ "tamgiac.bitrix24", - "tamgiac.example.com", "tamgiac.bitrix24.xx", "-bad.bitrix24.com", "UPPER.bitrix24.com", // we lowercase before match "a.b.bitrix24.com", // multi-level subdomain not allowed } for _, d := range good { - if !bitrixDomainRegex.MatchString(d) { + if !bitrixCloudDomainRegex.MatchString(d) { t.Errorf("should accept %q", d) } } for _, d := range bad { - if bitrixDomainRegex.MatchString(d) { + if bitrixCloudDomainRegex.MatchString(d) { + t.Errorf("should reject %q", d) + } + } +} + +func TestSelfHostedDomainRegex(t *testing.T) { + good := []string{ + "bx.example.com", + "portal.internal", + "bitrix.mycompany.co.uk", + "portal.example.com:8443", + "bx.corp", + } + bad := []string{ + "-bad.example.com", + "UPPER.example.com", // we lowercase before match + "", + "not a domain", + } + for _, d := range good { + if !selfHostedDomainRegex.MatchString(d) { + t.Errorf("should accept %q", d) + } + } + for _, d := range bad { + if selfHostedDomainRegex.MatchString(d) { t.Errorf("should reject %q", d) } }