diff --git a/forge/README.md b/forge/README.md index 45125db..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 @@ -121,20 +127,58 @@ 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 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: + +``` +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..47b87f0 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,49 @@ 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 +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 }); +}; + +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 => { + 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); + await kvs.delete(REGISTERED_KEY); + await kvs.deleteSecret(SECRET_KEY); + return deleted; +}; + export const register = async (req: WebTriggerRequest): Promise => { let payload: { secret?: string }; try { @@ -150,19 +191,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/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 }, { 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..834793d 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 isForgeWebtriggerURL(urls.Reset) { + cfg.ForgeResetURL = urls.Reset + } + if isForgeWebtriggerURL(urls.Drain) { + cfg.ForgeDrainURL = urls.Drain + } + if isForgeWebtriggerURL(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..1ceeb77 --- /dev/null +++ b/server/forge_reset.go @@ -0,0 +1,203 @@ +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, 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 { + 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 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()) + 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 isForgeWebtriggerURL(urls.Reset) { + 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, "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{} +} + +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"` + 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 + 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`") + 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..f75d510 --- /dev/null +++ b/server/forge_reset_test.go @@ -0,0 +1,146 @@ +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("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) + })) + 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) +}