Skip to content

fix(security): scope screenshots, baselines and metrics to the caller's team - #134

Merged
snevesbarros merged 1 commit into
masterfrom
claude/security-team-scoping
Oct 3, 2026
Merged

snevesbarros merged 1 commit into
masterfrom
claude/security-team-scoping

Conversation

@snevesbarros

Copy link
Copy Markdown
Collaborator

Summary

A security review found several endpoints that let an authenticated user of one team read or change another team's data. Each was confirmed against a running instance, acting as a plain (non-admin) user of a second team. This PR adds team checks to all of them.

Issue Before After
GET /metrics/screenshot?thumbnail=true, /screenshot/grouped/platform, /screenshot/grouped/tag Returned every team's screenshots, including thumbnails, build ids and server paths Only search builds of the caller's teams
/screenshot/views, /screenshot/tags Listed every team's view and tag names Same scoping
Baselines No team at all: only DELETE checked access. Any user could list, read, edit (e.g. ignore the whole image) and create baselines for any team. Comparison found the baseline by view and platform only, so a team using another team's view name got a diff image of that team's baseline Baselines record the team of their screenshot's build. Every endpoint checks that team, and comparisons only use the screenshot's own team's baseline
POST /execution, POST /screenshot Accepted writes into any team's build 403 without access to the build's team. A rejected screenshot upload's file is removed
GET /metrics/phase?teamId= Returned any team's metrics, test titles included 403 without access to the team
PUT /environment/:id, PUT /phase/:id Open to every user and API token Admin-only, like create and delete (environments and phases are shared by all teams)

Baselines in detail

  • Create: requires access to the screenshot's team. The "already exists" check only considers that team's baselines, so two teams can each have a baseline for the same view name.
  • Update: requires access to the baseline's team. A new screenshotId must belong to the same team.
  • GET /baseline:
    • New optional teamId, which must be a team the caller can read.
    • Without it, non-admins only get their own teams' baselines.
    • Admins still get every team's. The released UI and JS client look baselines up by view and platform without a team, so this keeps them working; comparisons are scoped correctly regardless.
  • Existing baselines: startup runs an idempotent backfill (baselineUtils.backfillTeams) that sets each legacy baseline's team from its screenshot's build, so existing comparisons keep working after the upgrade. A legacy baseline whose screenshot or build no longer exists is left without a team and is only accessible to admins.

Also fixed

  • GET /baseline/:id read the id from req.query instead of req.params, so it always returned 404.
  • GET /screenshot/tags without a limit always returned 500, because it defaulted to $limit: 0, which MongoDB rejects. It now defaults to 10, like /screenshot/views. This was broken on master too.

Compatibility

  • Users within their own team: no change.
  • API tokens or scripts that renamed environments or phases, or that wrote into another team's build, now get 403.
  • Admins viewing a screenshot whose view name is shared by several teams can still see more than one baseline from GET /baseline until the UI passes teamId. That's a small follow-up in angles-ui and angles-javascript-client.

Testing

  • New test/team-scoping.tests.js (20 tests). Each acts as a user of one team against another team's data and asserts 403 / 404 / filtered results. It also covers:
    • per-team baselines for the same view
    • comparisons against the team's own baseline only
    • the startup backfill
    • that the owner team still works
  • Full suite against a local MongoDB: 569 passing (549 before, plus 20 new). npx eslint app server.js is clean.
  • Re-ran the live attack script from the review against this branch. Every cross-team request now returns 403 or 404, and the startup log showed Baseline team backfill: 1 of 1 baseline(s) updated.

Notes

🤖 Generated with Claude Code

https://claude.ai/code/session_01KgQXSUjuLVXmLWxobnMfSf


Generated by Claude Code

…'s team

Several endpoints let an authenticated user of one team read or change
another team's data. All were confirmed against a running instance as a
plain user of a second team.

Screenshots
- GET /metrics/screenshot, /screenshot/grouped/platform and
  /screenshot/grouped/tag returned every team's screenshots (thumbnails,
  build ids and server paths included). They now only search builds of the
  caller's teams, as do the /screenshot/views and /screenshot/tags name
  lookups.
- POST /screenshot accepted uploads into any team's build. It now requires
  access to the build's team and removes the uploaded file when rejected.

Baselines
- Baselines now record the team of their screenshot's build. Listing, reading
  and updating are limited to that team; a baseline can only be created from,
  or pointed at, a screenshot of the same team; and the "already exists" check
  only considers that team's baselines.
- Comparing a screenshot against its baseline only uses the screenshot's own
  team's baseline. Before, a team using the same view name was compared
  against (and got a diff image of) another team's baseline.
- GET /baseline takes an optional teamId. Admins, who can read every team,
  still see all teams' baselines without it.
- Existing baselines are given their team on startup (idempotent backfill),
  so comparisons keep working after the upgrade.
- GET /baseline/:id read the id from the query string and always returned
  404; it now reads the path parameter.

Other
- POST /execution accepted executions into any team's build; it now requires
  access to the build's team.
- GET /metrics/phase returned any team's metrics, test titles included; it now
  requires access to the team.
- PUT /environment/:id and PUT /phase/:id were open to every user and API
  token. Environments and phases are shared, so renaming them is now
  admin-only, like creating and deleting them.
- GET /screenshot/tags without a limit always failed with a 500 (MongoDB
  rejects $limit: 0); it now defaults to 10 like the view lookup.

Adds test/team-scoping.tests.js (20 tests) and documents the 403 responses,
the baseline team and the teamId filter in swagger.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KgQXSUjuLVXmLWxobnMfSf
@snevesbarros
snevesbarros merged commit e157c62 into master Oct 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants