From 8ee76da5a7cf26e5550c13766cca7331912f7133 Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Tue, 23 Jun 2026 17:18:40 +0300 Subject: [PATCH 1/6] Migrate Forge bridge to @forge/kvs and add /confluence forge reset command --- forge/README.md | 54 ++++++++++--- forge/manifest.yml | 9 +++ forge/package.json | 3 +- forge/src/index.ts | 105 ++++++++++++++++++++---- server/command.go | 11 ++- server/config/main.go | 2 + server/flow.go | 43 ++++++++-- server/forge_poller.go | 2 +- server/forge_reset.go | 160 +++++++++++++++++++++++++++++++++++++ server/forge_reset_test.go | 121 ++++++++++++++++++++++++++++ 10 files changed, 471 insertions(+), 39 deletions(-) create mode 100644 server/forge_reset.go create mode 100644 server/forge_reset_test.go diff --git a/forge/README.md b/forge/README.md index 45125db..48fe67b 100644 --- a/forge/README.md +++ b/forge/README.md @@ -121,20 +121,52 @@ notification. ### Operational notes -- `register` is one-shot. If you need to rotate the shared secret, - clear `mm.registered` from Forge storage first (use `forge install - --upgrade` after manually wiping the entry), then re-run the - Mattermost wizard. -- Forge storage values are capped at 240 KiB per entry. The bridge - drops the inline page body for events that would exceed this; the - channel notification still fires but @-mention DMs are skipped for - that single oversized event. +#### Rotating the shared secret + +The `register` endpoint is single-use per secret. To rotate without +re-running the install wizard, use the in-band command from Mattermost: + +``` +/confluence forge reset +``` + +This calls the bridge's HMAC-authenticated `reset` web trigger, which +wipes `mm.registered`, `mm.drainSecret`, and any queued events; the +plugin then generates a new secret and re-registers automatically. +Polling resumes within ~30s. No `install cloud` rerun required. + +If the in-band reset fails because the plugin and bridge have drifted +out of sync (i.e. the bridge no longer accepts the plugin's HMAC), +break-glass via the Forge CLI: + +``` +forge invoke -f wipeRegistrationFn -e +``` + +This requires Forge developer access to the app. After it returns, +run `/confluence install cloud` to re-register. + +#### Storage backend + +The bridge uses Forge `@forge/kvs` (the legacy `@forge/api` `storage` +module was removed by Atlassian on 2026-06-22). Keys used: + +- `mm.registered` — registration flag (boolean) +- `mm.drainSecret` — shared HMAC secret (Forge secret-type entry) +- `evt:*` — buffered Confluence events, drained by the Mattermost plugin + +#### Other limits + +- KVS values are capped at 240 KiB per entry. The bridge drops the + inline page body for events that would exceed this; the channel + notification still fires but @-mention DMs are skipped for that + single oversized event. - Forge web trigger throttle is 1000 req/min per app/environment. At a 30-second poll cadence that is 2 req/min per tenant, so one bridge accommodates ~500 Confluence Cloud tenants before throttling. -- Forge storage is wiped 28 days after the app is uninstalled. The - bridge is a buffer, not a system of record; the Mattermost plugin - is the durable side. +- Forge KVS is wiped 28 days after the app is uninstalled. The bridge + is a buffer, not a system of record; the Mattermost plugin is the + durable side. ## Shape diff --git a/forge/manifest.yml b/forge/manifest.yml index 8a0935e..7a22198 100644 --- a/forge/manifest.yml +++ b/forge/manifest.yml @@ -48,6 +48,11 @@ modules: urlFormat: v2 response: type: dynamic + - key: reset + function: resetFn + urlFormat: v2 + response: + type: dynamic function: - key: enqueueFn @@ -56,8 +61,12 @@ modules: handler: index.drain - key: registerFn handler: index.register + - key: resetFn + handler: index.reset - key: onInstalledFn handler: index.onInstalled + - key: wipeRegistrationFn + handler: index.wipeRegistration app: runtime: diff --git a/forge/package.json b/forge/package.json index 8449136..be0d036 100644 --- a/forge/package.json +++ b/forge/package.json @@ -12,7 +12,8 @@ "install:dev": "forge install --upgrade" }, "dependencies": { - "@forge/api": "^4.0.0" + "@forge/api": "^4.0.0", + "@forge/kvs": "^2.0.0" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/forge/src/index.ts b/forge/src/index.ts index a2ff4a2..05eae5c 100644 --- a/forge/src/index.ts +++ b/forge/src/index.ts @@ -1,4 +1,5 @@ -import api, { route, storage, webTrigger } from '@forge/api'; +import api, { route, webTrigger } from '@forge/api'; +import { kvs, WhereConditions } from '@forge/kvs'; import { createHmac, timingSafeEqual } from 'crypto'; const QUEUE_PREFIX = 'evt:'; @@ -34,10 +35,10 @@ export const enqueue = async (event: unknown, context: unknown): Promise = const safe = enforceStorageLimit(enriched, contentID); try { - await storage.set(key, { event: safe, context, enqueuedAt: Date.now() }); + await kvs.set(key, { event: safe, context, enqueuedAt: Date.now() }); console.log(`enqueue: stored key=${key} bodyAttached=${Boolean(safe.content?.body)}`); } catch (err) { - console.error(`enqueue: storage.set failed key=${key} error=${(err as Error)?.message ?? err}`); + console.error(`enqueue: kvs.set failed key=${key} error=${(err as Error)?.message ?? err}`); throw err; } }; @@ -97,7 +98,7 @@ const enrichWithBody = async (evt: ForgeEvent): Promise => { // body with the shared secret set via the `register` trigger. export const drain = async (req: WebTriggerRequest): Promise => { console.log('drain: invoked'); - const secret = (await storage.getSecret(SECRET_KEY)) as string | undefined; + const secret = (await kvs.getSecret(SECRET_KEY)) as string | undefined; if (!secret) { console.log('drain: rejected, bridge not registered'); return jsonResponse(503, { error: 'bridge not registered; POST credentials to register web trigger first' }); @@ -119,14 +120,14 @@ export const drain = async (req: WebTriggerRequest): Promise if (body.ack?.length) { const ackable = body.ack.filter((k) => typeof k === 'string' && k.startsWith(QUEUE_PREFIX)); - await Promise.all(ackable.map((k) => storage.delete(k))); + await Promise.all(ackable.map((k) => kvs.delete(k))); console.log(`drain: acked ${ackable.length} keys`); } const limit = clampLimit(body.limit); - const results = await storage + const results = await kvs .query() - .where('key', { condition: 'STARTS_WITH', value: QUEUE_PREFIX }) + .where('key', WhereConditions.beginsWith(QUEUE_PREFIX)) .limit(limit) .getMany(); @@ -135,9 +136,70 @@ export const drain = async (req: WebTriggerRequest): Promise return jsonResponse(200, { events, nextCursor: results.nextCursor ?? null }); }; -// register is a one-shot. Once `mm.registered` is set, further calls are -// refused. To re-register, an operator must clear the flag via the Forge CLI: -// forge install --upgrade then re-POST to register +// reset wipes the registration so a fresh secret can be installed. Authenticated +// via HMAC using the currently-registered secret, so only a caller that already +// holds the shared secret (i.e. the Mattermost plugin that registered) can use +// it. Use the `/confluence forge reset` slash command in Mattermost. +// +// When secrets have drifted (the plugin lost its copy, or a different MM +// instance is trying to re-register) this endpoint cannot help — use the +// `wipeRegistration` break-glass function via `forge invoke` instead. +export const reset = async (req: WebTriggerRequest): Promise => { + console.log('reset: invoked'); + const secret = (await kvs.getSecret(SECRET_KEY)) as string | undefined; + if (!secret) { + console.log('reset: bridge not registered, nothing to do'); + return jsonResponse(200, { ok: true, alreadyClear: true }); + } + + if (!verifySignature(secret, headerValue(req, 'x-mm-signature'), req.body ?? '')) { + console.log('reset: rejected, invalid signature'); + return jsonResponse(403, { error: 'invalid signature' }); + } + + const queuedDeleted = await wipeAllStorage(); + console.log(`reset: cleared registration + ${queuedDeleted} queued events`); + return jsonResponse(200, { ok: true, queuedDeleted }); +}; + +// wipeRegistration is the break-glass equivalent of `reset`. Invoke via the +// Forge CLI when the in-band reset cannot run (drifted secrets, plugin lost +// its secret, etc.): +// +// forge invoke -f wipeRegistrationFn -e +// +// The Forge CLI authenticates the caller (must have developer access to this +// app), which is the right gate for a break-glass operation. +export const wipeRegistration = async (): Promise<{ ok: true; queuedDeleted: number }> => { + const queuedDeleted = await wipeAllStorage(); + console.log(`wipeRegistration: cleared registration + ${queuedDeleted} queued events`); + return { ok: true, queuedDeleted }; +}; + +const wipeAllStorage = async (): Promise => { + await kvs.delete(REGISTERED_KEY); + await kvs.deleteSecret(SECRET_KEY); + let cursor: string | undefined; + let deleted = 0; + do { + const q = kvs + .query() + .where('key', WhereConditions.beginsWith(QUEUE_PREFIX)) + .limit(100); + if (cursor) q.cursor(cursor); + const page = await q.getMany(); + await Promise.all(page.results.map((r) => kvs.delete(r.key))); + deleted += page.results.length; + cursor = page.nextCursor ?? undefined; + } while (cursor); + return deleted; +}; + +// register accepts the shared secret used to HMAC-sign drain requests. It is +// idempotent for the same secret (returns 200 with alreadyRegistered:true). A +// caller presenting a different secret is rejected with 409; the Mattermost +// plugin should run `/confluence forge reset` to rotate, or fall back to +// `forge invoke -f wipeRegistrationFn -e ` if the in-band path can't auth. export const register = async (req: WebTriggerRequest): Promise => { let payload: { secret?: string }; try { @@ -150,19 +212,28 @@ export const register = async (req: WebTriggerRequest): Promise` to break-glass', }); } - await storage.setSecret(SECRET_KEY, payload.secret); - await storage.set(REGISTERED_KEY, true); - return jsonResponse(200, { ok: true }); + await kvs.setSecret(SECRET_KEY, payload.secret); + await kvs.set(REGISTERED_KEY, true); + return jsonResponse(200, { ok: true, urls: await allWebtriggerURLs() }); +}; + +const allWebtriggerURLs = async (): Promise<{ drain: string; register: string; reset: string }> => { + const [drain, register, reset] = await Promise.all([ + webTrigger.getUrl('drain'), + webTrigger.getUrl('register'), + webTrigger.getUrl('reset'), + ]); + return { drain, register, reset }; }; const secretsMatch = (a: string, b: string): boolean => { diff --git a/server/command.go b/server/command.go index 02b3ec9..9916853 100644 --- a/server/command.go +++ b/server/command.go @@ -43,7 +43,8 @@ const ( sysAdminHelpText = "\n###### For System Administrators:\n" + "Setup Instructions:\n" + "* `/confluence install cloud` - Connect Mattermost to a Confluence Cloud instance.\n" + - "* `/confluence install server` - Connect Mattermost to a Confluence Server or Data Center instance.\n" + "* `/confluence install server` - Connect Mattermost to a Confluence Server or Data Center instance.\n" + + "* `/confluence forge reset` - Rotate the Forge bridge shared secret in-place (Cloud only).\n" invalidCommand = "Invalid command." installOnlySystemAdmin = "`/confluence install` can only be run by a system administrator." @@ -73,6 +74,7 @@ var ConfluenceCommandHandler = Handler{ "settings/notifications": executeNotificationsStatus, "settings/notifications/on": executeNotificationsOn, "settings/notifications/off": executeNotificationsOff, + "forge/reset": executeForgeReset, }, defaultHandler: executeConfluenceDefault, } @@ -142,6 +144,13 @@ func getAutoCompleteData() *model.AutocompleteData { settings.AddCommand(notifications) confluence.AddCommand(settings) + forge := model.NewAutocompleteData("forge", "", "Manage the Confluence Forge bridge (System Admin)") + forge.RoleID = model.SystemAdminRoleId + reset := model.NewAutocompleteData("reset", "", "Rotate the Forge bridge shared secret") + reset.RoleID = model.SystemAdminRoleId + forge.AddCommand(reset) + confluence.AddCommand(forge) + return confluence } diff --git a/server/config/main.go b/server/config/main.go index 40476d7..6310998 100644 --- a/server/config/main.go +++ b/server/config/main.go @@ -33,6 +33,8 @@ type Configuration struct { IsCloud bool `json:"iscloud"` ForgeSharedSecret string `json:"forgesharedsecret"` ForgeDrainURL string `json:"forgedrainurl"` + ForgeResetURL string `json:"forgereseturl"` + ForgeRegisterURL string `json:"forgeregisterurl"` ForgeInstallURL string `json:"forgeinstallurl"` } diff --git a/server/flow.go b/server/flow.go index a87a4b1..2f0cf80 100644 --- a/server/flow.go +++ b/server/flow.go @@ -847,12 +847,25 @@ func (fm *FlowManager) submitForgeBridgeURLs(_ *flow.Flow, submitted map[string] return "", nil, nil, errors.New("Forge Bridge Shared Secret is not set on this plugin; reload the plugin to regenerate it") } - if err := postForgeRegister(registerURL, cfg.ForgeSharedSecret); err != nil { + urls, err := postForgeRegister(registerURL, cfg.ForgeSharedSecret) + if err != nil { errorList["register_url"] = err.Error() return "", nil, errorList, nil } cfg.ForgeDrainURL = drainURL + cfg.ForgeRegisterURL = registerURL + if urls != nil { + if urls.Reset != "" { + cfg.ForgeResetURL = urls.Reset + } + if urls.Drain != "" { + cfg.ForgeDrainURL = urls.Drain + } + if urls.Register != "" { + cfg.ForgeRegisterURL = urls.Register + } + } cfg.Sanitize() configMap, err := cfg.ToMap() if err != nil { @@ -885,10 +898,21 @@ func isForgeWebtriggerURL(raw string) bool { return strings.HasPrefix(u.Path, "/public/") } -func postForgeRegister(registerURL, secret string) error { +type ForgeWebtriggerURLs struct { + Drain string `json:"drain"` + Register string `json:"register"` + Reset string `json:"reset"` +} + +type forgeRegisterResponse struct { + OK bool `json:"ok"` + URLs ForgeWebtriggerURLs `json:"urls"` +} + +func postForgeRegister(registerURL, secret string) (*ForgeWebtriggerURLs, error) { body, err := json.Marshal(map[string]string{"secret": secret}) if err != nil { - return errors.Wrap(err, "failed to encode register payload") + return nil, errors.Wrap(err, "failed to encode register payload") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) @@ -896,7 +920,7 @@ func postForgeRegister(registerURL, secret string) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, registerURL, bytes.NewReader(body)) if err != nil { - return errors.Wrap(err, "failed to build register request") + return nil, errors.Wrap(err, "failed to build register request") } req.Header.Set("Content-Type", "application/json") @@ -909,21 +933,24 @@ func postForgeRegister(registerURL, secret string) error { } resp, err := client.Do(req) if err != nil { - return errors.Wrap(err, "failed to reach register URL") + return nil, errors.Wrap(err, "failed to reach register URL") } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK, http.StatusNoContent: - return nil + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + var parsed forgeRegisterResponse + _ = json.Unmarshal(respBody, &parsed) // tolerate older bridges with no urls field + return &parsed.URLs, nil case http.StatusConflict: - return errors.New("Forge bridge is already registered with a different shared secret. Have the Forge admin delete the `mm.registered` and `mm.drainSecret` storage keys (`forge storage delete mm.registered && forge storage delete mm.drainSecret`), then re-run this wizard.") + return nil, errors.New("Forge bridge is already registered with a different shared secret. Run `/confluence forge reset` to rotate, or if that fails ask your Forge admin to run `forge invoke -f wipeRegistrationFn -e `, then re-run this wizard.") default: respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) snippet := strings.TrimSpace(string(respBody)) if snippet == "" { snippet = resp.Status } - return errors.Errorf("bridge rejected registration (%d): %s", resp.StatusCode, snippet) + return nil, errors.Errorf("bridge rejected registration (%d): %s", resp.StatusCode, snippet) } } diff --git a/server/forge_poller.go b/server/forge_poller.go index e44306e..697713a 100644 --- a/server/forge_poller.go +++ b/server/forge_poller.go @@ -291,7 +291,7 @@ func (fp *ForgePoller) handleDrainError(err error) { switch httpErr.StatusCode { case http.StatusUnauthorized, http.StatusForbidden: - fp.alertOnce(alertKeyHMAC, fmt.Sprintf("Confluence Forge bridge rejected drain request (HTTP %d): the shared secret on this plugin does not match the secret stored in the Forge bridge. Re-run `/confluence install cloud` to re-register, or have the Forge admin delete the `mm.registered` and `mm.drainSecret` storage keys and re-run the wizard.", httpErr.StatusCode)) + fp.alertOnce(alertKeyHMAC, fmt.Sprintf("Confluence Forge bridge rejected drain request (HTTP %d): the shared secret on this plugin does not match the secret stored in the Forge bridge. Run `/confluence forge reset` to rotate the secret in-place. If that also fails (because the plugin and bridge have drifted out of sync), ask a Forge admin to run `forge invoke -f wipeRegistrationFn -e `, then re-run `/confluence install cloud`.", httpErr.StatusCode)) case http.StatusNotFound, http.StatusServiceUnavailable: fp.alertOnce(alertKeyNotRegd, fmt.Sprintf("Confluence Forge bridge reports it is not registered (HTTP %d). Forge events are not being delivered. Re-run `/confluence install cloud` to re-register the bridge.", httpErr.StatusCode)) default: diff --git a/server/forge_reset.go b/server/forge_reset.go new file mode 100644 index 0000000..aa40045 --- /dev/null +++ b/server/forge_reset.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/pkg/errors" + + "github.com/mattermost/mattermost/server/public/model" + + "github.com/mattermost/mattermost-plugin-confluence/server/config" + "github.com/mattermost/mattermost-plugin-confluence/server/util" +) + +const ( + forgeResetTimeout = 20 * time.Second + + forgeResetSuccessMsg = "Forge bridge secret rotated successfully. Queued events cleared: %d. Polling will resume within ~30s." + + forgeResetMissingURLsMsg = "This plugin does not have the Forge reset URL on file. " + + "Make sure the Forge app has been redeployed (so the `reset` webtrigger exists) " + + "and run `/confluence install cloud` once to capture its URL. " + + "Subsequent rotations will then run inline via this command." + + forgeResetBridgeNotRegisteredMsg = "Forge bridge reports it was already cleared. Run `/confluence install cloud` to re-register from scratch." +) + +func executeForgeReset(p *Plugin, ctx *model.CommandArgs, _ ...string) *model.CommandResponse { + if !util.IsSystemAdmin(ctx.UserId) { + postCommandResponse(ctx, commandsOnlySystemAdmin) + return &model.CommandResponse{} + } + + cfg := config.GetConfig() + resetURL := strings.TrimSpace(cfg.ForgeResetURL) + registerURL := strings.TrimSpace(cfg.ForgeRegisterURL) + currentSecret := strings.TrimSpace(cfg.ForgeSharedSecret) + + if resetURL == "" || registerURL == "" { + postCommandResponse(ctx, forgeResetMissingURLsMsg) + return &model.CommandResponse{} + } + if currentSecret == "" { + postCommandResponse(ctx, "Forge Bridge Shared Secret is not set on this plugin; reload the plugin to regenerate it.") + return &model.CommandResponse{} + } + + queuedDeleted, err := postForgeReset(resetURL, currentSecret) + if err != nil { + p.client.Log.Warn("forge reset: bridge wipe failed", "error", err.Error()) + postCommandResponse(ctx, "Forge bridge reset failed: "+err.Error()) + return &model.CommandResponse{} + } + + newSecret, err := generateRandomKey(32) + if err != nil { + postCommandResponse(ctx, "Failed to generate a new shared secret: "+err.Error()) + return &model.CommandResponse{} + } + + urls, err := postForgeRegister(registerURL, newSecret) + if err != nil { + p.client.Log.Error("forge reset: bridge wiped but re-register failed", "error", err.Error()) + postCommandResponse(ctx, + "Forge bridge was wiped, but re-registering the new secret failed: "+err.Error()+ + "\n\nThe bridge currently has no shared secret. Run `/confluence install cloud` to re-register, or retry `/confluence forge reset`.") + return &model.CommandResponse{} + } + + cfg.ForgeSharedSecret = newSecret + if urls != nil { + if urls.Reset != "" { + cfg.ForgeResetURL = urls.Reset + } + if urls.Drain != "" { + cfg.ForgeDrainURL = urls.Drain + } + if urls.Register != "" { + cfg.ForgeRegisterURL = urls.Register + } + } + cfg.Sanitize() + configMap, err := cfg.ToMap() + if err != nil { + postCommandResponse(ctx, "Secret rotated on the bridge, but failed to serialize plugin config: "+err.Error()+". Re-run `/confluence install cloud` to recover.") + return &model.CommandResponse{} + } + if err := p.client.Configuration.SavePluginConfig(configMap); err != nil { + postCommandResponse(ctx, "Secret rotated on the bridge, but failed to save plugin config: "+err.Error()+". Re-run `/confluence install cloud` to recover.") + return &model.CommandResponse{} + } + + p.client.Log.Info("forge reset: rotated bridge secret", "queued_deleted", queuedDeleted, "user_id", ctx.UserId) + postCommandResponse(ctx, fmt.Sprintf(forgeResetSuccessMsg, queuedDeleted)) + return &model.CommandResponse{} +} + +type forgeResetResponse struct { + OK bool `json:"ok"` + QueuedDeleted int `json:"queuedDeleted"` + AlreadyClear bool `json:"alreadyClear"` +} + +func postForgeReset(resetURL, secret string) (int, error) { + body := []byte{} + signature := hmacHexSHA256(secret, body) + + ctx, cancel := context.WithTimeout(context.Background(), forgeResetTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, resetURL, bytes.NewReader(body)) + if err != nil { + return 0, errors.Wrap(err, "failed to build reset request") + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-MM-Signature", signature) + + client := &http.Client{ + Timeout: forgeResetTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return 0, errors.Wrap(err, "failed to reach reset URL") + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + switch resp.StatusCode { + case http.StatusOK: + var parsed forgeResetResponse + _ = json.Unmarshal(respBody, &parsed) + return parsed.QueuedDeleted, nil + case http.StatusForbidden: + return 0, errors.New("bridge rejected reset signature: the plugin's shared secret no longer matches the bridge. Have a Forge admin run `forge invoke -f wipeRegistrationFn -e `, then re-run `/confluence install cloud`") + default: + snippet := strings.TrimSpace(string(respBody)) + if snippet == "" { + snippet = resp.Status + } + return 0, errors.Errorf("bridge rejected reset (%d): %s", resp.StatusCode, snippet) + } +} + +func hmacHexSHA256(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/server/forge_reset_test.go b/server/forge_reset_test.go new file mode 100644 index 0000000..6fd1f9d --- /dev/null +++ b/server/forge_reset_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostForgeReset(t *testing.T) { + const secret = "test-shared-secret-32-chars-len!" + + t.Run("happy path returns queuedDeleted", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + assert.Equal(t, expected, r.Header.Get("X-MM-Signature"), "plugin must sign the body with the current secret") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "queuedDeleted": 7}) + })) + defer server.Close() + + deleted, err := postForgeReset(server.URL, secret) + require.NoError(t, err) + assert.Equal(t, 7, deleted) + }) + + t.Run("403 maps to drift-recovery instructions", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"error":"invalid signature"}`, http.StatusForbidden) + })) + defer server.Close() + + _, err := postForgeReset(server.URL, secret) + require.Error(t, err) + assert.Contains(t, err.Error(), "wipeRegistrationFn") + assert.Contains(t, err.Error(), "install cloud") + }) + + t.Run("500 surfaces upstream body", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer server.Close() + + _, err := postForgeReset(server.URL, secret) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") + assert.Contains(t, err.Error(), "boom") + }) + + t.Run("refuses to follow redirects", func(t *testing.T) { + // A malicious bridge that redirects elsewhere should not cause the + // signed body to be replayed against the redirect target. + called := 0 + dst := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called++ + w.WriteHeader(http.StatusOK) + })) + defer dst.Close() + src := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Redirect(w, &http.Request{}, dst.URL, http.StatusTemporaryRedirect) + })) + defer src.Close() + + _, err := postForgeReset(src.URL, secret) + require.Error(t, err) + assert.Zero(t, called, "redirect target must not be hit") + }) +} + +func TestPostForgeRegisterParsesURLs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "urls": map[string]string{ + "drain": "https://x.webtrigger.atlassian.app/public/drain", + "register": "https://x.webtrigger.atlassian.app/public/register", + "reset": "https://x.webtrigger.atlassian.app/public/reset", + }, + }) + })) + defer server.Close() + + urls, err := postForgeRegister(server.URL, "secret") + require.NoError(t, err) + require.NotNil(t, urls) + assert.Equal(t, "https://x.webtrigger.atlassian.app/public/reset", urls.Reset) + assert.Equal(t, "https://x.webtrigger.atlassian.app/public/drain", urls.Drain) + assert.Equal(t, "https://x.webtrigger.atlassian.app/public/register", urls.Register) +} + +func TestPostForgeRegisterToleratesOldBridge(t *testing.T) { + // Old bridge responses returned just {"ok":true} with no urls field; + // the plugin must not crash and must return nil-or-zero URLs. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + urls, err := postForgeRegister(server.URL, "secret") + require.NoError(t, err) + require.NotNil(t, urls) + assert.Empty(t, urls.Reset) +} From fbad9f0ecf02cc5bac7745882f5e429daa278150 Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Tue, 23 Jun 2026 17:38:01 +0300 Subject: [PATCH 2/6] coderabbit ai feedbac --- forge/src/index.ts | 25 ++----------------------- server/flow.go | 6 +++--- server/forge_reset.go | 18 ++++++++++++------ server/forge_reset_test.go | 25 +++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 32 deletions(-) diff --git a/forge/src/index.ts b/forge/src/index.ts index 05eae5c..47b87f0 100644 --- a/forge/src/index.ts +++ b/forge/src/index.ts @@ -136,14 +136,6 @@ export const drain = async (req: WebTriggerRequest): Promise return jsonResponse(200, { events, nextCursor: results.nextCursor ?? null }); }; -// reset wipes the registration so a fresh secret can be installed. Authenticated -// via HMAC using the currently-registered secret, so only a caller that already -// holds the shared secret (i.e. the Mattermost plugin that registered) can use -// it. Use the `/confluence forge reset` slash command in Mattermost. -// -// When secrets have drifted (the plugin lost its copy, or a different MM -// instance is trying to re-register) this endpoint cannot help — use the -// `wipeRegistration` break-glass function via `forge invoke` instead. export const reset = async (req: WebTriggerRequest): Promise => { console.log('reset: invoked'); const secret = (await kvs.getSecret(SECRET_KEY)) as string | undefined; @@ -162,14 +154,6 @@ export const reset = async (req: WebTriggerRequest): Promise return jsonResponse(200, { ok: true, queuedDeleted }); }; -// wipeRegistration is the break-glass equivalent of `reset`. Invoke via the -// Forge CLI when the in-band reset cannot run (drifted secrets, plugin lost -// its secret, etc.): -// -// forge invoke -f wipeRegistrationFn -e -// -// The Forge CLI authenticates the caller (must have developer access to this -// app), which is the right gate for a break-glass operation. export const wipeRegistration = async (): Promise<{ ok: true; queuedDeleted: number }> => { const queuedDeleted = await wipeAllStorage(); console.log(`wipeRegistration: cleared registration + ${queuedDeleted} queued events`); @@ -177,8 +161,6 @@ export const wipeRegistration = async (): Promise<{ ok: true; queuedDeleted: num }; const wipeAllStorage = async (): Promise => { - await kvs.delete(REGISTERED_KEY); - await kvs.deleteSecret(SECRET_KEY); let cursor: string | undefined; let deleted = 0; do { @@ -192,14 +174,11 @@ const wipeAllStorage = async (): Promise => { deleted += page.results.length; cursor = page.nextCursor ?? undefined; } while (cursor); + await kvs.delete(REGISTERED_KEY); + await kvs.deleteSecret(SECRET_KEY); return deleted; }; -// register accepts the shared secret used to HMAC-sign drain requests. It is -// idempotent for the same secret (returns 200 with alreadyRegistered:true). A -// caller presenting a different secret is rejected with 409; the Mattermost -// plugin should run `/confluence forge reset` to rotate, or fall back to -// `forge invoke -f wipeRegistrationFn -e ` if the in-band path can't auth. export const register = async (req: WebTriggerRequest): Promise => { let payload: { secret?: string }; try { diff --git a/server/flow.go b/server/flow.go index 2f0cf80..834793d 100644 --- a/server/flow.go +++ b/server/flow.go @@ -856,13 +856,13 @@ func (fm *FlowManager) submitForgeBridgeURLs(_ *flow.Flow, submitted map[string] cfg.ForgeDrainURL = drainURL cfg.ForgeRegisterURL = registerURL if urls != nil { - if urls.Reset != "" { + if isForgeWebtriggerURL(urls.Reset) { cfg.ForgeResetURL = urls.Reset } - if urls.Drain != "" { + if isForgeWebtriggerURL(urls.Drain) { cfg.ForgeDrainURL = urls.Drain } - if urls.Register != "" { + if isForgeWebtriggerURL(urls.Register) { cfg.ForgeRegisterURL = urls.Register } } diff --git a/server/forge_reset.go b/server/forge_reset.go index aa40045..c42736b 100644 --- a/server/forge_reset.go +++ b/server/forge_reset.go @@ -30,8 +30,6 @@ const ( "Make sure the Forge app has been redeployed (so the `reset` webtrigger exists) " + "and run `/confluence install cloud` once to capture its URL. " + "Subsequent rotations will then run inline via this command." - - forgeResetBridgeNotRegisteredMsg = "Forge bridge reports it was already cleared. Run `/confluence install cloud` to re-register from scratch." ) func executeForgeReset(p *Plugin, ctx *model.CommandArgs, _ ...string) *model.CommandResponse { @@ -78,13 +76,13 @@ func executeForgeReset(p *Plugin, ctx *model.CommandArgs, _ ...string) *model.Co cfg.ForgeSharedSecret = newSecret if urls != nil { - if urls.Reset != "" { + if isForgeWebtriggerURL(urls.Reset) { cfg.ForgeResetURL = urls.Reset } - if urls.Drain != "" { + if isForgeWebtriggerURL(urls.Drain) { cfg.ForgeDrainURL = urls.Drain } - if urls.Register != "" { + if isForgeWebtriggerURL(urls.Register) { cfg.ForgeRegisterURL = urls.Register } } @@ -140,7 +138,15 @@ func postForgeReset(resetURL, secret string) (int, error) { switch resp.StatusCode { case http.StatusOK: var parsed forgeResetResponse - _ = json.Unmarshal(respBody, &parsed) + if err := json.Unmarshal(respBody, &parsed); err != nil { + return 0, errors.Wrap(err, "bridge returned 200 with unparseable body; reset not confirmed") + } + if !parsed.OK { + return 0, errors.New("bridge returned 200 but ok=false; reset not confirmed") + } + if parsed.QueuedDeleted < 0 { + return 0, errors.Errorf("bridge returned invalid queuedDeleted=%d", parsed.QueuedDeleted) + } return parsed.QueuedDeleted, nil case http.StatusForbidden: return 0, errors.New("bridge rejected reset signature: the plugin's shared secret no longer matches the bridge. Have a Forge admin run `forge invoke -f wipeRegistrationFn -e `, then re-run `/confluence install cloud`") diff --git a/server/forge_reset_test.go b/server/forge_reset_test.go index 6fd1f9d..f75d510 100644 --- a/server/forge_reset_test.go +++ b/server/forge_reset_test.go @@ -50,6 +50,31 @@ func TestPostForgeReset(t *testing.T) { assert.Contains(t, err.Error(), "install cloud") }) + t.Run("200 with ok=false is rejected", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":false}`)) + })) + defer server.Close() + + _, err := postForgeReset(server.URL, secret) + require.Error(t, err) + assert.Contains(t, err.Error(), "ok=false") + }) + + t.Run("200 with unparseable body is rejected", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`not json`)) + })) + defer server.Close() + + _, err := postForgeReset(server.URL, secret) + require.Error(t, err) + assert.Contains(t, err.Error(), "unparseable") + }) + t.Run("500 surfaces upstream body", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) From e20a6b9b601a46d7f1a0e8fcd4c8e1fe49df6311 Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Tue, 30 Jun 2026 14:51:22 +0300 Subject: [PATCH 3/6] Add small fix for mising reset url --- server/forge_reset.go | 61 ++++++++++++++++++++++++++++++-------- server/forge_reset_test.go | 8 +++++ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/server/forge_reset.go b/server/forge_reset.go index c42736b..1ceeb77 100644 --- a/server/forge_reset.go +++ b/server/forge_reset.go @@ -26,10 +26,12 @@ const ( forgeResetSuccessMsg = "Forge bridge secret rotated successfully. Queued events cleared: %d. Polling will resume within ~30s." - forgeResetMissingURLsMsg = "This plugin does not have the Forge reset URL on file. " + + forgeResetMissingURLsMsg = "This plugin does not have the Forge reset URL on file, and the register URL is also missing so it cannot self-recover. " + "Make sure the Forge app has been redeployed (so the `reset` webtrigger exists) " + "and run `/confluence install cloud` once to capture its URL. " + "Subsequent rotations will then run inline via this command." + + forgeResetSelfHealMsg = "Forge bridge reset URL was missing from plugin config; recovered it from the register webtrigger. Retrying the rotation..." ) func executeForgeReset(p *Plugin, ctx *model.CommandArgs, _ ...string) *model.CommandResponse { @@ -43,15 +45,44 @@ func executeForgeReset(p *Plugin, ctx *model.CommandArgs, _ ...string) *model.Co registerURL := strings.TrimSpace(cfg.ForgeRegisterURL) currentSecret := strings.TrimSpace(cfg.ForgeSharedSecret) - if resetURL == "" || registerURL == "" { - postCommandResponse(ctx, forgeResetMissingURLsMsg) - return &model.CommandResponse{} - } if currentSecret == "" { postCommandResponse(ctx, "Forge Bridge Shared Secret is not set on this plugin; reload the plugin to regenerate it.") return &model.CommandResponse{} } + if resetURL == "" && registerURL != "" { + urls, err := postForgeRegister(registerURL, currentSecret) + if err != nil { + p.client.Log.Warn("forge reset: self-heal via register failed", "error", err.Error()) + postCommandResponse(ctx, "Forge reset URL is missing and recovery via the register webtrigger failed: "+err.Error()+"\n\nRe-run `/confluence install cloud` to repopulate the URLs.") + return &model.CommandResponse{} + } + if urls == nil || !isForgeWebtriggerURL(urls.Reset) { + postCommandResponse(ctx, "Forge bridge did not return a `reset` webtrigger URL. Redeploy the Forge app from this branch (so the `reset` trigger exists), then retry.") + return &model.CommandResponse{} + } + cfg.ForgeResetURL = urls.Reset + if isForgeWebtriggerURL(urls.Drain) { + cfg.ForgeDrainURL = urls.Drain + } + if isForgeWebtriggerURL(urls.Register) { + cfg.ForgeRegisterURL = urls.Register + } + if err := persistForgeConfig(p, cfg); err != nil { + postCommandResponse(ctx, "Recovered Forge URLs from the bridge, but failed to save them: "+err.Error()+". Re-run `/confluence install cloud` to recover.") + return &model.CommandResponse{} + } + p.client.Log.Info("forge reset: self-healed missing reset URL from register webtrigger", "user_id", ctx.UserId) + resetURL = strings.TrimSpace(cfg.ForgeResetURL) + registerURL = strings.TrimSpace(cfg.ForgeRegisterURL) + postCommandResponse(ctx, forgeResetSelfHealMsg) + } + + if resetURL == "" || registerURL == "" { + postCommandResponse(ctx, forgeResetMissingURLsMsg) + return &model.CommandResponse{} + } + queuedDeleted, err := postForgeReset(resetURL, currentSecret) if err != nil { p.client.Log.Warn("forge reset: bridge wipe failed", "error", err.Error()) @@ -86,13 +117,7 @@ func executeForgeReset(p *Plugin, ctx *model.CommandArgs, _ ...string) *model.Co cfg.ForgeRegisterURL = urls.Register } } - cfg.Sanitize() - configMap, err := cfg.ToMap() - if err != nil { - postCommandResponse(ctx, "Secret rotated on the bridge, but failed to serialize plugin config: "+err.Error()+". Re-run `/confluence install cloud` to recover.") - return &model.CommandResponse{} - } - if err := p.client.Configuration.SavePluginConfig(configMap); err != nil { + if err := persistForgeConfig(p, cfg); err != nil { postCommandResponse(ctx, "Secret rotated on the bridge, but failed to save plugin config: "+err.Error()+". Re-run `/confluence install cloud` to recover.") return &model.CommandResponse{} } @@ -102,6 +127,18 @@ func executeForgeReset(p *Plugin, ctx *model.CommandArgs, _ ...string) *model.Co return &model.CommandResponse{} } +func persistForgeConfig(p *Plugin, cfg *config.Configuration) error { + cfg.Sanitize() + configMap, err := cfg.ToMap() + if err != nil { + return errors.Wrap(err, "serialize plugin config") + } + if err := p.client.Configuration.SavePluginConfig(configMap); err != nil { + return errors.Wrap(err, "save plugin config") + } + return nil +} + type forgeResetResponse struct { OK bool `json:"ok"` QueuedDeleted int `json:"queuedDeleted"` diff --git a/server/forge_reset_test.go b/server/forge_reset_test.go index f75d510..2d9b291 100644 --- a/server/forge_reset_test.go +++ b/server/forge_reset_test.go @@ -8,10 +8,18 @@ import ( "io" "net/http" "net/http/httptest" + "strings" + "sync/atomic" "testing" + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest" + "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-plugin-confluence/server/config" ) func TestPostForgeReset(t *testing.T) { From 82869081901da96a27c4e2b90bf48ca7d38469c3 Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Tue, 30 Jun 2026 16:20:27 +0300 Subject: [PATCH 4/6] lint --- server/forge_reset_test.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/server/forge_reset_test.go b/server/forge_reset_test.go index 2d9b291..f75d510 100644 --- a/server/forge_reset_test.go +++ b/server/forge_reset_test.go @@ -8,18 +8,10 @@ import ( "io" "net/http" "net/http/httptest" - "strings" - "sync/atomic" "testing" - "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/plugin/plugintest" - "github.com/mattermost/mattermost/server/public/pluginapi" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - - "github.com/mattermost/mattermost-plugin-confluence/server/config" ) func TestPostForgeReset(t *testing.T) { From 1f5840cacf53d91abebea2dfc3232ce45c3aabc7 Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Tue, 30 Jun 2026 16:21:57 +0300 Subject: [PATCH 5/6] update readme --- forge/README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/forge/README.md b/forge/README.md index 48fe67b..c3315cd 100644 --- a/forge/README.md +++ b/forge/README.md @@ -97,9 +97,15 @@ account that owns the app: forge webtrigger --environment production ``` -Pick the installed tenant when prompted. The CLI prints two URLs: +Pick the installed tenant when prompted. The CLI prints three URLs: - `drain` → the URL Mattermost will poll - `register` → a one-shot URL used to set the shared secret +- `reset` → HMAC-authenticated URL used by `/confluence forge reset` to + rotate the shared secret in-band + +You only need to paste `drain` and `register` into the wizard; the +bridge announces the `reset` URL back in its `register` response and +the plugin persists it automatically. ### Step 8 — Run the Mattermost setup wizard @@ -135,6 +141,12 @@ wipes `mm.registered`, `mm.drainSecret`, and any queued events; the plugin then generates a new secret and re-registers automatically. Polling resumes within ~30s. No `install cloud` rerun required. +If the plugin is missing the `reset` URL on file (e.g. it was installed +before this command existed) but still has the `register` URL, the +command self-heals by calling `register` to fetch the current webtrigger +URLs, persisting them, and then performing the rotation. No manual +re-install required in that case either. + If the in-band reset fails because the plugin and bridge have drifted out of sync (i.e. the bridge no longer accepts the plugin's HMAC), break-glass via the Forge CLI: From 14cd363897c241862ddd048b86ce51aa1d35f96c Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Wed, 1 Jul 2026 15:53:19 +0300 Subject: [PATCH 6/6] remove redundant regenerate button --- plugin.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugin.json b/plugin.json index 80b59a8..9aac183 100644 --- a/plugin.json +++ b/plugin.json @@ -68,9 +68,8 @@ { "key": "ForgeSharedSecret", "display_name": "Forge Bridge Shared Secret", - "type": "generated", - "help_text": "Shared secret used to HMAC-sign poll requests to the Forge bridge that delivers Confluence Cloud events (the GA replacement for the legacy Atlassian Connect descriptor). After regenerating, POST the new value to the Forge app's register web trigger.", - "regenerate_help_text": "Regenerates the shared secret. Existing Forge bridge installations must be re-registered with the new value.", + "type": "text", + "help_text": "Shared secret used to HMAC-sign poll requests to the Forge bridge that delivers Confluence Cloud events (the GA replacement for the legacy Atlassian Connect descriptor). Managed by the plugin — do not edit manually. To rotate on an already-registered bridge, run /confluence forge reset; the bridge's register endpoint is one-shot per secret and will reject a manually regenerated value with HTTP 409.", "secret": true }, {