Files
jobtrackingapp/docs/security-assessments/M013-adversarial-security-assessment.md
T

8.8 KiB

M013 Adversarial Security Assessment

Scope

Tested as requested:

  • Input validation issues
  • Authentication flaws
  • Authorization issues
  • API security
  • File upload vulnerabilities
  • Data exposure

Assessment style: hostile, exploit-oriented, evidence-first.

Confirmed Findings

1. Authenticated SSRF via hostname-based loopback bypass in job import preview

  • Category: API security / input validation
  • Component: JobTrackerApi/Services/JobImport/JobImportService.cs
  • Endpoint: POST /api/jobimport/preview
  • Risk: High

Vulnerability

JobImportService.TryValidateUrl(...) blocks literal loopback and private IPs, but it does not resolve hostnames before allowing the request. That means a hostname that resolves to a loopback/private address can bypass the protection.

The validator rejects:

  • http://127.0.0.1:5202/...
  • http://[::1]:5202/...
  • http://2130706433:5202/...

But it accepted hostnames resolving to loopback, including:

  • http://127.0.0.1.nip.io:5202/api/auth/config
  • http://localhost.localdomain:5202/api/auth/config

Example exploit input

POST /api/jobimport/preview
Authorization: Bearer <valid local token>
Content-Type: application/json

{
  "url": "http://127.0.0.1.nip.io:5202/api/auth/config"
}

Observed result:

  • request was not rejected as local/private
  • server fetched the internal endpoint
  • response progressed to parser failure (No JobPosting schema found), which is enough to prove the internal fetch happened

Why this matters

An authenticated attacker can use the server as an HTTP client against internal-only services or private network resources reachable from the API host. Depending on deployment, this can expose:

  • internal admin/debug endpoints
  • cloud metadata services
  • internal service meshes
  • localhost-only ports
  • network topology and response behavior

Clear fix

  • Resolve DNS before allowing the request.
  • Reject any hostname whose resolved addresses are loopback, link-local, RFC1918 private, or otherwise internal.
  • Re-resolve after redirects, or disable redirects entirely.
  • Consider an allowlist of supported job domains instead of general outbound fetching.
  • Log and rate-limit preview fetch attempts.

2. Subjectless signed local JWTs authenticate successfully and can disable owner scoping

  • Category: Authentication flaws / authorization issues
  • Components:
    • JobTrackerApi/Program.cs
    • Data/JobTrackerContext.cs
    • several owner-scoped controllers relying on EF query filters
  • Risk: High

Vulnerability

Local JWT validation accepts a correctly signed token without a required subject / nameidentifier claim.

Runtime proof:

  • a signed local JWT with no ClaimTypes.NameIdentifier / sub
  • but with valid issuer/audience/signature
  • was accepted by the API
  • GET /api/auth/me returned 200

Observed response shape:

  • provider: external
  • id: null
  • email echoed from token

At the same time, owner scoping in Data/JobTrackerContext.cs is defined like this:

.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId)

If CurrentUserId is null, the filter collapses to allow all rows for owner-scoped entities.

That is a dangerous composition:

  1. token is authenticated
  2. current user id is null
  3. owner filters disable themselves
  4. endpoints that rely on implicit owner filtering become potentially cross-tenant

Example exploit input

A signed HS256 JWT using the app signing key, but omitting the nameidentifier claim.

Payload example:

{
  "iss": "JobTrackerApi",
  "aud": "job-tracker-ui",
  "nbf": 1775910345,
  "exp": 1775913945,
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "ghost@example.com",
  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "ghost@example.com"
}

Observed runtime request:

GET /api/auth/me
Authorization: Bearer <signed token without subject>

Observed result:

  • 200 OK
  • request treated as authenticated even though no user identity key existed for owner scoping

Why this matters

If an attacker can forge or obtain a valid local signing key, this flaw is not just “login bypass” — it can become a tenant-boundary bypass because owner filters stop applying.

This is especially serious in environments where:

  • the dev signing key is reused
  • a staging or preview environment leaks the local JWT key
  • operational mistakes deploy development auth settings

Clear fix

  • Require a non-empty subject / nameidentifier claim during local JWT validation.
  • Reject authenticated requests whose token does not map to a concrete application user identity.
  • Change query filters so CurrentUserId == null means deny, not allow-all, for owner-scoped entities.
  • Avoid relying on implicit global filters alone for sensitive raw-id endpoints; add explicit owner predicates in controller queries.

Example direction:

  • validate a required identity claim in the JWT bearer events pipeline
  • make owner filter logic equivalent to CurrentUserId != null && x.OwnerUserId == CurrentUserId

High-Risk Candidates Not Fully Confirmed In This Runtime

These are not counted as confirmed findings yet, but they remain serious candidates.

A. Raw-id child endpoints rely on implicit owner scoping

  • Category: Authorization issues
  • Components:
    • JobTrackerApi/Controllers/AttachmentsController.cs
    • JobTrackerApi/Controllers/CorrespondenceController.cs
    • selected job-linked endpoints
  • Risk: Medium to High

Patterns observed:

  • existence checks like AnyAsync(j => j.Id == jobId)
  • later child fetches through parent relationships
  • reliance on EF global filters instead of explicit per-request owner predicates

If the owner filter is ever bypassed, weakened, or accidentally ignored, these become cross-user read/write primitives.

This runtime could not prove the full cross-user exploit path because the active SQLite file is missing core domain tables (Companies, JobApplications, RuleSettings) and those requests fail before authorization behavior can be fully exercised.

Suggested fix

Add explicit owner predicates in the endpoint queries themselves instead of trusting global filters as the only boundary.


Tested Surfaces With No Confirmed Finding In This Pass

Anonymous API reachability

Observed anonymous results in this runtime:

  • GET /api/export/jobs401
  • POST /api/backup/encrypted401
  • POST /api/jobimport/preview401
  • POST /api/client-errors401

This is better than the earlier pre-hardening posture.

Gmail OAuth callback

  • GET /api/gmail/oauth/callback?code=fake&state=fake returned a generic failure page
  • no secret data exposure observed in this pass

File upload path traversal via visible filename

Code review on:

  • AuthController.UploadAvatar
  • AttachmentsController.Upload
  • AttachmentsController.Rename
  • ProfileCvController.Upload

Current handling uses Path.GetFileName(...) and generated storage names, which is a reasonable defense against straightforward path traversal through user-supplied filenames.

No confirmed traversal exploit in this pass.

  1. Fix authenticated SSRF in job import preview

    • hostname resolution checks
    • no internal/private destinations
    • preferably domain allowlist
  2. Fix JWT subjectless-auth acceptance and owner-filter allow-all behavior

    • require subject/nameidentifier
    • reject tokens that do not map to a real app identity
    • change owner filters to deny on null current user
  3. Harden raw-id owner-sensitive endpoints with explicit owner predicates

    • attachments
    • correspondence
    • job-linked child endpoints
  4. Run a second exploit pass after the fixes

    • repeat cross-user probes
    • retest SSRF bypasses
    • fuzz upload/parser surfaces further

Evidence Summary

Runtime probes performed

  • anonymous reachability checks against auth/config, csrf, auth/me, client-errors, jobimport preview, export, backup, and Gmail callback
  • authenticated SSRF probes against job import preview using loopback-resolving hostnames
  • authenticated malformed-token probe using a signed local JWT without subject/nameidentifier

Key observed outputs

  • /api/jobimport/preview accepted http://127.0.0.1.nip.io:5202/api/auth/config
  • /api/auth/me returned 200 for a signed local JWT without subject/nameidentifier
  • owner filters in JobTrackerContext explicitly allow all rows when CurrentUserId == null

Honest Boundaries

  • Some cross-user raw-id probes were limited by the local runtime using an incomplete SQLite schema.
  • Those areas are reported as high-risk candidates, not falsely upgraded to confirmed findings.
  • The two confirmed findings above are supported by direct runtime evidence plus code-path verification.