The Filter Is Only in the UI: An Internal Pentest of a Project-Management SaaS

How client-side filtering and missing server-side authorization checks allowed a standard account to harvest org-wide data and manipulate project membership

Disclaimer: The SaaS platform and the organization that commissioned this test are both withheld; remediation is still pending with the vendor at time of writing. Every technique below generalizes to any single-page application that hands the browser a full dataset and trusts client-side JavaScript to decide what the user actually gets to see.

This was supposed to be a routine internal test. The target was a project-management tool that basically every employee in the company already has a login for, the kind of SaaS platform that gets rolled out for scheduling and client-facing project tracking and then never gets a second security look because it's "just" PM software. We went in expecting maybe an over-permissioned export button. What we found was that a single standard, non-admin employee account could pull the financial details of every customer engagement in the company, harvest the entire customer contact list on every login without lifting a finger, and, worst of all, unilaterally strip project membership from any user on any project org-wide, including projects that account had no visibility into whatsoever.

Short version: a sanctioned internal test, using nothing but a standard employee login and a proxy, ended with a chain from "log in normally" to "delete every customer and colleague from every active project in the org," with zero admin access required anywhere in the chain.


Setting the Ground Rules Before Poking at Anything

This was manual only, no scanners, no fuzzers, and we deliberately avoided anything that would cause irreversible damage to production data, no bulk deletion, no destructive cross-tenant writes. Two accounts, one admin and one standard individual-contributor account, both fully legitimate. The platform authenticates via an API key sent in a custom header rather than a cookie-based session, generated per login and invalidated on logout, which matters later because it means every request in this writeup used nothing more than a normal, currently-valid credential for a low-privilege account.

The approach for the whole engagement was the same question asked over and over: what does our own browser's network traffic contain that the screen in front of us doesn't show? That single question, answered five separate times against five different endpoints, is basically the entire report.

What Normal Use Handed Over for Free

Every login triggered a background request to the member directory endpoint. On screen, a standard user sees the handful of colleagues and contacts relevant to their assigned work. In the actual HTTP response sitting in Burp's history, unprompted, on every single login, was a multi-megabyte JSON payload containing full records—name, email, internal user ID, account status, and role—for every member across every client company in the org's entire tenant, several thousand records across several hundred companies. A second endpoint backing the in-app people-search made this worse: passing an empty query and a very large limit returned the entire user base in one request, no pagination enforced.

None of this required touching a single parameter. It's what the application does on ordinary use. The only "attack" was opening the proxy history tab.

A companion endpoint driving the permission-management screen showed the same pattern with organizational structure instead of contact data. A query parameter clearly intended to scope the response to "your own role only" was accepted by the server and then silently ignored: every authenticated user, regardless of role, got the complete role-and-permission model for the whole org, every object type, every action, every internal permission ID. Buried in the descriptions of some of those permission sets were operational notes clearly written by an admin for internal reference, one naming a specific employee, another referencing a specific internal system integration by name. Low severity on its own. Paired with the full member roster from the first finding, it's a complete org chart with exact role definitions attached, reconnaissance a targeted attacker would otherwise have to piece together manually.

flowchart TB
    Employee["Standard employee logs in"]
    Browser["Browser sends authenticated request with normal API key"]
    Server["Server returns complete tenant-wide dataset without authorization scope"]
    Response["Underlying API response\nThousands of member records and internal user IDs\nAccount roles, statuses, permission model and operational notes"]
    Filter["Client-side JavaScript filters complete response"]
    Proxy["Proxy or network inspector reveals already-transmitted response\nNo authentication bypass"]

    subgraph Visible["Visible UI"]
        UI["Assigned colleagues\nRelevant contacts\nExpected project scope"]
    end

    Employee --> Browser --> Server --> Response
    Response --> Filter --> UI
    Response --> Proxy
            
Figure 1. The server returns the complete tenant-wide dataset and relies on client-side JavaScript to decide what the employee sees. A proxy exposes the unfiltered response because the data has already left the server.

Where It Stopped Being "Someone Can See Too Much"

The custom reporting feature is where read-only excess exposure turned into something worse. The platform's own user-profile endpoint defines, in plain JSON, exactly which projects a given standard user is authorized to see, in this case twenty specific project IDs. The reporting engine, when asked for financial and operational report data, doesn't check that list at all. The unmodified request our own browser sent during normal use returned well over a hundred projects, most of them outside that twenty-project authorized scope, including internal contract numbers, billing identifiers, and paragraph-length internal staff notes about specific customer accounts. Stripping the report's own business filter (the thing that looked, at a glance, like the access control) expanded the response further still, into the low hundreds of projects. The filter was never an authorization boundary. It was a convenience filter sitting on top of a fully unscoped dataset, and the server would return whatever slice of that dataset the client asked for.

That's not a UI bug, it's an open query interface into the financial and operational data of every customer relationship the company has, reachable with a standard login and no special role.

From Reading Data to Rewriting It

Two more findings moved from over-exposure into outright write-authorization failures, and this is where sequencing them by increasing severity really matters, because they compound.

The first: endpoints for creating and deleting an employee's time-off entries and personal tasks accepted the target user's ID as a plain field in the request body, with no check that the caller had any relationship to that target whatsoever. Using nothing but a standard account, we created a time-off entry on the admin account, with the "applied by" field also set to the admin's own ID, so it appeared in every HR and manager view as if the admin had booked it themselves. Only a separate, rarely-surfaced "created by" field preserved the true actor. We then deleted that entry, and separately deleted one of the admin's personal tasks, cleanly, with a 200 OK and no ownership check anywhere in the flow. Both resource IDs involved were small sequential integers, meaning an attacker wouldn't need to discover valid targets in advance, just walk the number line.

The second, and the most serious finding of the engagement: the endpoint that removes a member from a project checks neither whether the caller belongs to that project, nor whether the caller holds any role capable of managing membership, at all. We confirmed this cross-project: an account with zero membership in, and zero UI visibility into, a target project successfully removed a different, fully legitimate member from that project using only its own credentials. The one guardrail the server does enforce, refusing to remove a project's sole owner, turned out to be trivially routed around by transferring ownership first through the UI and then removing the now-former owner. That ownership-transfer path itself wasn't fully tested for the identical flaw, which is its own open question.

Why This Is One Story, Not Five Bugs

Read individually, these are a spread of severities. Chained, they describe a single standard employee account, no admin access, no password theft, no privilege escalation, walking from "log in" to "org-wide sabotage" in four steps:

  1. The member directory hands over every user ID and role in the company on login;
  2. The permission-schema leak confirms who holds which role and what each role can do;
  3. The reporting engine hands over every project ID and the financial detail behind it;
  4. The membership-removal flaw accepts any of those project IDs and user IDs with no ownership check at all.
flowchart TB
    Login["Standard employee authenticates normally"]
    Directory["Member directory exposes every user ID, role and customer contact"]
    Permissions["Permission schema exposes roles, capabilities and permission IDs"]
    Reports["Unscoped reporting exposes project IDs, contract and billing IDs, customer information and staff notes"]
    Combine["Excessive read access supplies identifiers required by under-protected write endpoints"]
    Request["Attacker combines exposed project and user IDs"]
    Remove["Membership-removal request accepts client-supplied IDs without scope or role check"]
    Confirmed["Confirmed: server removes an arbitrary member from an out-of-scope project"]
    Potential["Repeated requests could disrupt active projects organization-wide"]

    Login --> Directory --> Permissions --> Reports --> Combine --> Request --> Remove --> Confirmed --> Potential

    subgraph Related["Related write-authorization failures"]
        OtherID["Client supplies another employee user ID"]
        Ownership["Time-off or personal-task endpoint does not verify ownership"]
        Records["Creates or deletes another employee record"]
        Appearance["Record can appear to be performed by target employee"]
        OtherID --> Ownership --> Records --> Appearance
    end

    Directory -. related identifiers .-> OtherID
            
Figure 2. Tenant-wide data exposure supplies the user and project identifiers needed to exploit under-protected write endpoints, turning passive information disclosure into organization-wide project disruption.

Three of those four steps require no request modification whatsoever, the data arrives from completely ordinary application use. The fourth requires changing exactly one field in an otherwise legitimate request. A single disgruntled or compromised standard account, with nothing more than a browser and a proxy, has everything needed to strip project membership from every active engagement in the company and every one of its customers, and no obvious audit trail from the UI a manager or HR reviewer would normally check.

Root Cause

Two related architectural habits produced five findings. Three of them come from a single pattern: the server serializes a complete, unscoped dataset and ships it to the browser, trusting client-side JavaScript to decide what the logged-in user actually gets to see. That's a well-known anti-pattern in single-page apps, and it fails as a security control the instant anyone looks at their own network tab, because the data has already left the server by the time any filtering happens. The other two findings share a different but related habit: write endpoints accepting the identity of the resource owner, or the target user, as a plain client-supplied parameter, with no server-side check that the authenticated caller actually has a relationship to that identity. Both habits boil down to the same misplaced trust: treating the client as a reliable enforcer of rules that only the server can actually guarantee.

Fix Guidance

  • Enforce authorization scope server-side, before any client-supplied filter is applied. The reporting engine and the member/permission endpoints all need the caller's actual authorized scope applied as a hard boundary first, with whatever filter or search the client sends narrowing that already-scoped result, not defining it. The tradeoff is real engineering work: every list and report endpoint needs an explicit scope check added, not just a query-string flag that's easy to leave unenforced (as happened here with the ignored inCurrentUserCtx parameter).
  • Derive identity from the authenticated session, never from the request body. For any write endpoint, whose record is being modified and who is doing the modifying should come from the caller's own authenticated context, not from a userId or appliedBy field the client can set to anything. This is close to free to fix per endpoint but easy to miss at scale, it requires an audit of every write endpoint that currently accepts an identity-shaped parameter, not just the ones already found vulnerable.
  • Treat internal-only capabilities (reporting, permission management) as requiring an explicit grant, not just "any authenticated user." Restricting the reporting endpoints to accounts with an actual reporting permission would have prevented the highest-severity read exposure even if the underlying scoping bug still existed. This is a compensating control, not a fix, worth having as defense in depth regardless.

None of this is exotic. It's the standard "never trust the client" principle, just not evenly applied across every endpoint the front end happens to call.

What Other Researchers Should Take From This

If you're testing (or building) an internal SaaS integration, don't stop at what the UI shows you, open the network tab during completely ordinary use and diff it against what's on screen. That single comparison, repeated across a handful of endpoints, found four of these five findings without a single crafted request. Then, separately, go find every write endpoint that accepts an ID in its body or path and ask whether the server actually verifies the caller owns or manages that ID, because over-fetched read access and under-checked write access tend to travel together, and the write side is where the real damage lives.

Glossary: Terms Used in This Post

  • IDOR (Insecure Direct Object Reference): A vulnerability where an application lets a user access or modify a record by directly referencing its ID (in a URL or request body) without verifying that user is actually allowed to touch that specific record.
  • RBAC (Role-Based Access Control): A permissions model where what a user can do is determined by the role(s) assigned to them, rather than being set individually per user.
  • API key: A credential (a string of characters) sent with each request to prove which account is making it, functioning similarly to a login session but often longer-lived and manually included in request headers rather than handled automatically like a browser cookie.
  • Client-side filtering (anti-pattern): A design flaw where the server sends a complete dataset to the browser and relies on the browser's own code to decide what portion of it to actually display, rather than the server only ever sending the portion the user is allowed to see.
  • Burp Suite / Repeater / Interceptor: A widely used web security testing tool. Interceptor lets a tester pause and inspect a request before it's sent; Repeater lets a tester resend a modified copy of a captured request to see how the server responds.
  • CVSS (Common Vulnerability Scoring System): A standardized numeric scale (0-10) used to rate how severe a vulnerability is, based on factors like how easy it is to exploit and what damage it can do.
  • CWE (Common Weakness Enumeration): A standardized catalog of general categories of software weaknesses (like "improper access control"), used to classify what kind of flaw a specific bug represents.
  • OWASP API Security Top 10: A published list of the most common and impactful security risks specific to APIs, maintained by the Open Web Application Security Project; "Broken Object Level Authorization" (API1) and "Broken Object Property Level Authorization" (API3) are two categories referenced in this post.
  • Authorization vs. authentication: Authentication confirms who a user is (logging in); authorization confirms what that specific, already-authenticated user is allowed to do or see. Every finding in this post is an authorization failure, not an authentication one, all activity used fully legitimate, valid logins.
  • Tenant / tenant-scoped: In multi-customer SaaS, a "tenant" is one customer organization's isolated slice of the platform. Data being "tenant-scoped" means it's limited to one customer's own org, even if it's completely unscoped within that org, which is the case throughout this report.