116f7557dd
* fix(auth): scope integration config/watch routes to workspace owner
The third-party integration route group is guarded by its own
query-only middleware (integrationWorkspaceScopeMiddleware), which
authorizes only the workspace_id query param and falls through when it
is absent. Two shapes bypassed it entirely under enforced auth:
- POST /{jira,linear,slack}/config/copy takes the copy TARGET from the
request body (targetWorkspaceId), which no layer checked. A caller
could copy their own connection settings + credential into another
user's workspace by naming its ID, after which that workspace's
identity-less poller would run against attacker-controlled config
(Jira/Linear issue watchers auto-creating tasks from attacker issues;
Slack bot token/webhook). CopyConfigToWorkspace now authorizes BOTH
source and target up front, before the pre-write UpdateAuthHealth
side effect. Mirrors github/copy.go.
- GET /{jira,linear}/watches/issue with workspace_id omitted returned
ListAllIssueWatches across every workspace (JQL/filters, repo, agent
and executor profile IDs, spawn prompt). It now filters to the
caller's workspaces; an identity-less internal caller still sees all,
preserving poller use.
- The config Get/Set/Delete entry points resolved an unowned global
"default" workspace when workspace_id was omitted; they now
resolve+authorize via resolveWorkspaceID.
Each service gains SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess),
wired in backendapp; nil (unit tests, auth disabled) stays unscoped.
Denials surface repoerrors.ErrWorkspaceNotFound, mapped to 404 by the
handlers (no existence leak). Recorded the query-only-middleware rule in
integrations/AGENTS.md, including that Sentry and GitLab's
ListAllIssueWatches still need the same filter.
Regression tests added per package and mutation-verified: dropping only
the copy up-front guard fails on the victim health-flip witness;
dropping both guards fails on the config/secret witness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(auth): scope integration watch-write and data-plane paths too
Review of the prior commit (Codex + Claude, converging) found the same
body/query workspace-bypass class in surfaces the first pass missed:
- CreateIssueWatch / UpdateIssueWatch (Jira, Linear) never authorized the
workspace. CreateIssueWatch takes WorkspaceID from the request body, so
a caller could plant a persistent watch in another user's workspace that
repeatedly spawns tasks with that user's credentials and an
attacker-supplied filter/prompt. Now authorize req.WorkspaceID (create)
and the loaded row's workspace (update). The by-ID mutation/trigger/reset
handlers were already guarded by assertWatchInWorkspace + the
query-authorizing middleware; the service-layer guard adds defense in
depth and covers non-HTTP callers.
- TestConnectionForWorkspace (Jira, Linear, Slack) resolved the default
workspace with no auth, so an omitted workspace_id + attacker-supplied
siteUrl could exfiltrate that workspace's stored token. Now authorizes
after normalize and returns a hard error (404), not a swallowed
{OK:false}.
- Data-plane reads funnel through clientFor, which now resolves+authorizes
the workspace — scoping search/projects/statuses in one chokepoint.
- filterIssueWatchesByAccess treated ANY authorizer error as a denial,
silently dropping watches on a transient failure and returning a
truncated 200. It now drops only on ErrWorkspaceNotFound and propagates
everything else.
Denials map to 404 via the shared writeClientError / writeIssueWatchError
sinks and the two list/test handlers. Renamed the test helper
denyExcept -> denyOnly (it denies exactly the named workspaces). Added
per-package regression tests for create/update/list denial, authorizer
error propagation, connection-test denial, and data-plane denial;
mutation-verified the CreateIssueWatch guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
5.6 KiB
Go
150 lines
5.6 KiB
Go
package jira
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
|
)
|
|
|
|
// denyOnly returns a workspace authorizer that allows every workspace except
|
|
// the named ones, which it denies with the same ErrWorkspaceNotFound the real
|
|
// task-service authorizer returns.
|
|
func denyOnly(denied ...string) func(context.Context, string) error {
|
|
blocked := make(map[string]bool, len(denied))
|
|
for _, ws := range denied {
|
|
blocked[ws] = true
|
|
}
|
|
return func(_ context.Context, ws string) error {
|
|
if blocked[ws] {
|
|
return repoerrors.ErrWorkspaceNotFound
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// TestCopyConfigToWorkspace_DeniesForeignTarget is the regression for the HIGH
|
|
// finding: the copy target arrives in the request body, which the query-only
|
|
// integration middleware never authorizes, so without a service-layer check a
|
|
// caller could copy their config + credential into another user's workspace.
|
|
func TestCopyConfigToWorkspace_DeniesForeignTarget(t *testing.T) {
|
|
f := newSvcFixture(t)
|
|
ctx := context.Background()
|
|
const src, victim = "ws-src", "ws-victim"
|
|
|
|
// Seed the attacker's own source config while unscoped, then turn on scoping.
|
|
if _, err := f.svc.SetConfigForWorkspace(ctx, src, &SetConfigRequest{
|
|
SiteURL: "https://attacker.atlassian.net",
|
|
Email: "a@example.com",
|
|
AuthMethod: AuthMethodAPIToken,
|
|
InstanceType: InstanceTypeCloud,
|
|
Secret: "tok-attacker",
|
|
}); err != nil {
|
|
t.Fatalf("seed source: %v", err)
|
|
}
|
|
drainProbe(t, f)
|
|
|
|
// The victim already has a healthy Jira connection of their own. A denied
|
|
// copy must leave it byte-for-byte untouched — including its health status,
|
|
// which the up-front guard protects from the pre-write UpdateAuthHealth flip.
|
|
if err := f.store.UpsertConfigForWorkspace(ctx, victim, &JiraConfig{
|
|
SiteURL: "https://victim.atlassian.net", Email: "v@example.com",
|
|
AuthMethod: AuthMethodAPIToken, InstanceType: InstanceTypeCloud,
|
|
}); err != nil {
|
|
t.Fatalf("seed victim config: %v", err)
|
|
}
|
|
if err := f.secrets.Set(ctx, SecretKeyForWorkspace(victim), "Jira token", "tok-victim"); err != nil {
|
|
t.Fatalf("seed victim secret: %v", err)
|
|
}
|
|
if err := f.store.UpdateAuthHealthForWorkspace(ctx, victim, true, "", time.Now().UTC()); err != nil {
|
|
t.Fatalf("seed victim health: %v", err)
|
|
}
|
|
|
|
f.svc.SetWorkspaceAuthorizer(denyOnly(victim))
|
|
|
|
_, err := f.svc.CopyConfigToWorkspace(ctx, src, victim)
|
|
if !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
|
|
t.Fatalf("expected ErrWorkspaceNotFound, got %v", err)
|
|
}
|
|
got, cfgErr := f.store.GetConfigForWorkspace(ctx, victim)
|
|
if cfgErr != nil || got == nil {
|
|
t.Fatalf("victim config vanished: cfg=%+v err=%v", got, cfgErr)
|
|
}
|
|
if got.SiteURL != "https://victim.atlassian.net" {
|
|
t.Errorf("victim config overwritten by copy: %q", got.SiteURL)
|
|
}
|
|
if !got.LastOk {
|
|
t.Errorf("victim health flipped to unhealthy by unauthorized copy")
|
|
}
|
|
if v, _ := f.secrets.Reveal(ctx, SecretKeyForWorkspace(victim)); v != "tok-victim" {
|
|
t.Errorf("victim secret overwritten by copy: %q", v)
|
|
}
|
|
}
|
|
|
|
// TestConfigForWorkspace_DeniesForeignWorkspace is the regression for the LOW
|
|
// finding: a scoped caller must not read, write, or delete a workspace's config
|
|
// it does not own (including via the resolved default when workspace_id is
|
|
// omitted — resolveWorkspaceID authorizes whatever normalize resolves).
|
|
func TestConfigForWorkspace_DeniesForeignWorkspace(t *testing.T) {
|
|
f := newSvcFixture(t)
|
|
ctx := context.Background()
|
|
const foreign = "ws-foreign"
|
|
f.svc.SetWorkspaceAuthorizer(denyOnly(foreign))
|
|
|
|
if _, err := f.svc.GetConfigForWorkspace(ctx, foreign); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
|
|
t.Errorf("Get: expected ErrWorkspaceNotFound, got %v", err)
|
|
}
|
|
if _, err := f.svc.SetConfigForWorkspace(ctx, foreign, &SetConfigRequest{
|
|
SiteURL: "https://x.atlassian.net", Email: "x@example.com",
|
|
AuthMethod: AuthMethodAPIToken, InstanceType: InstanceTypeCloud, Secret: "t",
|
|
}); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
|
|
t.Errorf("Set: expected ErrWorkspaceNotFound, got %v", err)
|
|
}
|
|
if err := f.svc.DeleteConfigForWorkspace(ctx, foreign); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) {
|
|
t.Errorf("Delete: expected ErrWorkspaceNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestListAllIssueWatches_FiltersToCallerWorkspaces is the regression for the
|
|
// MEDIUM finding: the unscoped list-all form (reached by omitting workspace_id)
|
|
// leaked every workspace's watch config. A scoped caller now sees only their
|
|
// own; an identity-less internal caller (nil authorizer) still sees all.
|
|
func TestListAllIssueWatches_FiltersToCallerWorkspaces(t *testing.T) {
|
|
f := newSvcFixture(t)
|
|
ctx := context.Background()
|
|
|
|
seed := func(ws, jql string) {
|
|
if err := f.store.CreateIssueWatch(ctx, &IssueWatch{
|
|
WorkspaceID: ws, WorkflowID: "wf", WorkflowStepID: "step",
|
|
JQL: jql, AgentProfileID: "ap", Enabled: true,
|
|
}); err != nil {
|
|
t.Fatalf("seed watch: %v", err)
|
|
}
|
|
}
|
|
seed("ws-mine", "project = MINE")
|
|
seed("ws-mine", "project = MINE2")
|
|
seed("ws-other", "project = OTHER")
|
|
|
|
// Unscoped (internal caller): every watch is visible, as before auth.
|
|
if all, err := f.svc.ListAllIssueWatches(ctx); err != nil || len(all) != 3 {
|
|
t.Fatalf("unscoped: want 3 watches, got %d (err=%v)", len(all), err)
|
|
}
|
|
|
|
// Scoped: only the caller's own workspace watches survive the filter.
|
|
f.svc.SetWorkspaceAuthorizer(denyOnly("ws-other"))
|
|
mine, err := f.svc.ListAllIssueWatches(ctx)
|
|
if err != nil {
|
|
t.Fatalf("scoped list: %v", err)
|
|
}
|
|
if len(mine) != 2 {
|
|
t.Fatalf("scoped: want 2 watches, got %d", len(mine))
|
|
}
|
|
for _, w := range mine {
|
|
if w.WorkspaceID != "ws-mine" {
|
|
t.Errorf("leaked foreign watch from %q", w.WorkspaceID)
|
|
}
|
|
}
|
|
}
|