DEV Community

Cover image for BOLA: your API checks the URL but misses IDs in the JSON body
Jules Robineau
Jules Robineau

Posted on Originally published at jrobineau.com

BOLA: your API checks the URL but misses IDs in the JSON body

Your access control test is green. Account A asks for a document that belongs to account B, and the API answers 404. That’s the right check.

Now A edits its own document. In the request body, it slips in the ID of one of B's projects. The API accepts it, and the response shows B's project.

TL;DR: access control often checks the object in the URL. It forgets the IDs in the request body. One account then attaches another account's object to its own, and the response hands over that data. This is BOLA, number one in the OWASP API Top 10. Check the owner of every ID you receive, not just that it exists.

This article is for backend developers who write PUT or PATCH routes, or who test access rights. The examples are in Go, but the flaw exists in every language.

The setup: four routes, one flaw

I audited an API as a white-box review. Its backend is written in Laravel, a PHP framework.

Four write routes had the same defect. One PUT, three PATCH.

Each time, the route did check that the object in the URL belonged to the logged-in account. But the request carried another ID, in its body.

For that second ID, the API checked one thing only: that it exists. It never asked who owned it.

In three cases, the response then returned the other account's data. Names, contact details, more IDs. In the fourth, the object moved into the other account, which then saw it appear in its own data.

The list routes I reviewed were correctly scoped to the account. The hole was in the writes. I proved each case with an HTTP test, on an isolated database with fake accounts. Production never received a single request. The method will get its own article.

The flaw in one request

Here is the scenario with generic objects. Account A owns a document. Account B owns a project. The IDs are made up for the example.

PATCH /documents/d-123
Authorization: Bearer <token of account A>
Content-Type: application/json

{"project_id": "p-999"}
Enter fullscreen mode Exit fullscreen mode

The server asks two questions. Does document d-123 belong to A? Yes. Does project p-999 exist? Yes. The third question is missing: does that project belong to A?

The update goes through. Then the API returns the updated document, with its project. Handy for the frontend, which shows everything at once.

200 OK
{
  "id": "d-123",
  "title": "Meeting notes",
  "project": {
    "id": "p-999",
    "name": "Account B project",
    "owner_email": "b@example.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

A never called GET /projects/p-999. It read B's data by writing to its own document.

One PATCH request with two IDs and a single owner check

"Exists" does not mean "belongs to you". The API checks the owner of d-123, in the URL. For p-999, in the body, it only checks that it exists. B's project gets attached, and its data comes back to A.

Why your access test misses it

The classic IDOR test swaps the ID in the URL. I already recommended it in the article on the French tax leak. That test is still useful, but it never looks at the request body.

Here, the URL is fine. A points at its own document. The URL check does its job, and the test passes.

Body validation gives a false sense of safety. In Laravel, the rule exists:projects,id checks that the row exists in the table. It says nothing about the owner. Read too fast, it still looks like an access check.

A foreign key does the same thing in the database. It is a column that points to a row in another table. It guarantees that the project exists, and it accepts any account's project.

Even Postgres RLS lets the link through

Maybe you filter rows in Postgres with RLS, Row-Level Security. It hides other accounts' rows from each account. I described it in the article on RLS in Go.

But the Postgres documentation is explicit. Integrity checks, foreign keys included, always bypass RLS. Otherwise the database could not guarantee its own links.

You can reproduce it in a few lines on Postgres 18. Under RLS, account A cannot see B's project. It can still attach a document to it, and the database accepts.

If the next read goes through RLS, the project stays hidden. But the link between the two accounts is written. So your barrier must cover writes, not only reads.

The right name: BOLA, and sometimes mass assignment

OWASP ranks API flaws in a top 10. The latest edition is from 2023. Number one is BOLA, for Broken Object Level Authorization.

Put simply, the API does not check that you have rights on the object you point at. OWASP says the ID can sit in the path, in the query string or in the payload. So this is BOLA on the project, not on the document.

Another name comes up often: mass assignment. That is when the API lets the client change a field it should not touch. Since 2023, OWASP files it at number three, under BOPLA, for Broken Object Property Level Authorization.

The difference comes down to one question. Does the product need to move a document to another project? If not, accepting project_id on PATCH is already the mistake. That is mass assignment, and the fix is to drop the field.

If yes, the field is legitimate, and its value is what you must check. In the audit, I recommended refusing these moves wherever the product does not need them.

A UUID is not a password

OWASP also advises random IDs, such as UUIDs. That helps against guessing. But a UUID shows up in responses, links and logs.

In the audit, one rigged response leaked more IDs from the other account. Those IDs then opened a second route. All it took was a first foreign ID to start the chain.

A hard-to-guess ID slows the attack down. The owner check stops it.

The fix: check the owner in the same query

The rule is simple. Every ID you receive gets the same check as the one in the URL. It must exist and belong to the logged-in account.

Here is a generic example in Go, with Postgres. It is not the audited API's code. It only shows the shape of the check.

// ErrNotFound covers three cases: no such document, no such project,
// or one of them belongs to another account. The caller cannot tell which.
var ErrNotFound = errors.New("not found")

// The owner is checked on both IDs, in the same statement as the write.
const moveDocument = `
UPDATE documents d
SET project_id = p.id
FROM projects p
WHERE d.id = $1 AND d.owner_id = $3
  AND p.id = $2 AND p.owner_id = $3`

// MoveDocument handles PATCH /documents/{id} with {"project_id": "..."}.
// userID comes from the verified session, never from the request body.
func MoveDocument(ctx context.Context, db *sql.DB, userID, docID, projectID string) error {
    res, err := db.ExecContext(ctx, moveDocument, docID, projectID, userID)
    if err != nil {
        return err
    }
    n, err := res.RowsAffected()
    if err != nil {
        return err
    }
    if n == 0 {
        return ErrNotFound
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

This code does three things.

  • It checks the owner of both IDs in the same SQL statement as the write. This avoids two separate calls. The composite constraint below also protects against concurrent ownership changes.
  • It takes userID from the verified session. That value never comes from the request body.
  • It returns ErrNotFound when no row moves, whether the object is missing or belongs to another account.

That last point matters. A "forbidden" answer would confirm that the project exists. I explain the "not found" choice in the article on the LLM agent in Go.

In Laravel, the same idea fits in the validation rule. Rule::exists('projects', 'id')->where('owner_id', $userId) adds the owner to the check. In the audited API, the fix took the form of a single rule, reused on every field concerned.

Put the rule in the database too

Code can forget a field. A database constraint applies to every write. When both tables carry the owner column, a composite foreign key shuts the door.

ALTER TABLE projects
    ADD CONSTRAINT projects_id_owner UNIQUE (id, owner_id);

ALTER TABLE documents
    ADD CONSTRAINT documents_project_same_owner
    FOREIGN KEY (project_id, owner_id)
    REFERENCES projects (id, owner_id);
Enter fullscreen mode Exit fullscreen mode

The key no longer points to any project. It points to a project with the same owner as the document. Postgres then rejects the link to B's project, even if the code let it through.

Postgres requires a unique constraint on the referenced columns, hence the first command. And this guard does not work when ownership goes through a parent. The check in code stays essential there.

Look for links already written

Fixing the code does not clean the database. If the flaw was already used, cross-account links exist. In the audit, I recommended finding them before calling the problem solved.

One join is enough to find them:

SELECT d.id
FROM documents d
JOIN projects p ON p.id = d.project_id
WHERE d.owner_id <> p.owner_id;
Enter fullscreen mode Exit fullscreen mode

Each row returned is a document attached to another account's project. Decide case by case: repair the link or set the row aside.

Do this cleanup before adding the composite key. Postgres checks existing rows when you create the constraint. If it finds a bad one, it refuses to create it.

The test: two accounts, and the ID in the body

This generic Laravel example is separate from the audited code. Adapt its factories, route and message to your API contract.

// Generic Laravel example: adapt the factories and route to your API.
public function test_cannot_attach_another_accounts_project(): void
{
    $alice = User::factory()->create();
    $bob = User::factory()->create();
    $own = Project::factory()->create(['owner_id' => $alice->id]);
    $foreign = Project::factory()->create(['owner_id' => $bob->id]);
    $document = Document::factory()->create([
        'owner_id' => $alice->id,
        'project_id' => $own->id,
    ]);

    $response = $this->actingAs($alice)->patchJson(
        "/documents/{$document->id}", ['project_id' => $foreign->id]
    );
    $response->assertStatus(404)->assertExactJson(['message' => 'Not found']);
    $this->assertDatabaseHas('documents', [
        'id' => $document->id, 'project_id' => $own->id,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

For every route that accepts an ID in its body, write a test with two accounts. A owns the document, B owns the project. A sends its PATCH with B's project.

The test checks three things.

  • The response is a 404, the same as for a project that does not exist.
  • A's document did not change in the database.
  • No data from B shows up in the response.

To find these routes, look for fields ending in _id in the inputs of your write routes. In Laravel, also look for every exists: rule. Do it for POST creations too, not only for updates.

Some fields accept a type and an ID, for example an attachment that can target several kinds of objects. Check the owner for every accepted type. Those are the easiest cases to forget.

The checklist

"Exists" does not mean "belongs to you".

  • [ ] List the POST, PUT and PATCH routes that receive an ID in the body
  • [ ] For each field, decide whether the client may change it
  • [ ] Drop the fields the product does not need to change
  • [ ] Check the owner of every ID you receive, not just that it exists
  • [ ] Take the account from the session, never from the body
  • [ ] Answer 404 for a missing object and for a foreign object alike
  • [ ] Add a composite foreign key when both tables carry the owner
  • [ ] Look for cross-account links already in the database before calling the flaw fixed
  • [ ] Write one two-account test per route, with the foreign ID in the body

The takeaway

An ID in a request body points at an object, just like the one in the URL. So it needs the same check.

Neither validation, nor the foreign key, nor RLS checks the owner for you. That is the job of your code, or of a constraint written for it.

Want to know whether your API lets these IDs through? Let's talk.


Sources: OWASP, API1:2023 Broken Object Level Authorization · OWASP, API3:2023 Broken Object Property Level Authorization · OWASP API Security Top 10, 2023 edition · PostgreSQL 18, Row Security Policies · PostgreSQL 18, constraints and foreign keys · Laravel 12, exists validation rule

Top comments (0)