diff --git a/docs/content/docs/guides/workspace-export-import.mdx b/docs/content/docs/guides/workspace-export-import.mdx index a0f5ab391..312cb0a6d 100644 --- a/docs/content/docs/guides/workspace-export-import.mdx +++ b/docs/content/docs/guides/workspace-export-import.mdx @@ -109,6 +109,7 @@ Some things belong to an instance rather than to a workspace, so they are not ap | Sends still in flight | A campaign step handed to a worker on the source has no worker on the destination to report back, so it arrives queued and is sent there instead of waiting forever. Steps already sent keep their history | | An invalid workspace name | An archive's workspace name is applied only when it passes the same [naming rules](/api/error-codes/#name-refusals) as a rename. Otherwise the destination keeps its own | | Invalid form settings | Each form passes the same checks as an edit. A redirect address that is not an `http` or `https` URL is cleared, a design the editor would refuse resets to the default, and an embed domain that is not a domain is dropped. A published form that lost an embed domain arrives as a draft, so review its domains and publish it again | +| Webhook endpoints the destination refuses | An endpoint whose address the destination would not accept as a new one (not `https`, or not a public host) arrives disabled. Fix its address and enable it again | | Personal list layouts | Which columns each member shows on the contacts list and how they sort it, and how they arrange the unibox scope rail (Favorites, row order, hidden rows), belongs to the person, not the workspace. Everyone starts from the default view on the new instance and sets it up again | | Salesforce links and activity | Which Salesforce record each contact is, the activity waiting to be logged and its recent results, and how far the pull loop read. The destination links contacts again by address on its first sync, and activity already logged is in Salesforce. Saved list view and Campaign imports do travel, with the records each one already brought in | | Send plan snapshots | A campaign's precomputed "today's sending plan" is worked out from the campaign, its leads, its mailboxes and the instance's own limits, which all travel. The destination works it out again in the background, so a plan for today is current where the campaign now lives rather than a copy of what the source instance expected | diff --git a/internal/app/orgtransfer/import_rules.go b/internal/app/orgtransfer/import_rules.go index c04a31df2..9e49395a5 100644 --- a/internal/app/orgtransfer/import_rules.go +++ b/internal/app/orgtransfer/import_rules.go @@ -8,6 +8,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/warmbly/warmbly/internal/app/oauth" + "github.com/warmbly/warmbly/internal/app/webhook" "github.com/warmbly/warmbly/internal/infrastructure/storage" "github.com/warmbly/warmbly/internal/models" ) @@ -18,6 +19,14 @@ import ( var importRules = map[string]func(env *ruleEnv, row map[string]json.RawMessage){ "oauth_applications": cleanImportedApp, "forms": cleanImportedForm, + "webhook_endpoints": cleanImportedWebhook, +} + +// cleanImportedWebhook disables an endpoint whose address a create would refuse. +func cleanImportedWebhook(_ *ruleEnv, row map[string]json.RawMessage) { + if raw, ok := row["url"]; ok && webhook.ValidateOutboundURL(jsonString(raw)) != nil { + setJSON(row, "enabled", false) + } } // ruleEnv is what the rules need to know about the destination. diff --git a/internal/app/orgtransfer/import_rules_test.go b/internal/app/orgtransfer/import_rules_test.go index a09cda7d1..f559ec2e1 100644 --- a/internal/app/orgtransfer/import_rules_test.go +++ b/internal/app/orgtransfer/import_rules_test.go @@ -75,6 +75,20 @@ func TestImportedAppPassesTheAppWriteRules(t *testing.T) { } } +func TestImportedWebhookToAPrivateHostArrivesDisabled(t *testing.T) { + t.Setenv("WARMBLY_ALLOW_UNSAFE_WEBHOOK_URLS", "") + bad := rowOf(t, map[string]any{"url": "https://127.0.0.1/hook", "enabled": true}) + cleanImportedWebhook(nil, bad) + if string(bad["enabled"]) != "false" { + t.Errorf("enabled = %s", bad["enabled"]) + } + good := rowOf(t, map[string]any{"url": "https://hooks.example.com/in", "enabled": true}) + cleanImportedWebhook(nil, good) + if string(good["enabled"]) != "true" { + t.Errorf("a valid endpoint was disabled") + } +} + func TestImportedFormPassesTheFormWriteRules(t *testing.T) { row := rowOf(t, map[string]any{ "name": "Newsletter",