← All repos

alist-partner

Laravel 11 REST API powering the A-List partner / merchant portal — accounts, brands (venues), campaigns, voucher codes. Phone-OTP auth via Twilio Verify, Passport-issued bearer tokens.

Primary stackPHP 8.2 / Laravel 11 (API only) Last commit2025-11-06 Repo size3.2MB Files121 Branchmain Health Critical

Executive summary

Tech stack

PHP 8.2+ Laravel 11.31 Passport 13 Twilio SDK 8.7 Hashids 12 Slack webhooks SQLite (dev) Vite 6 (unused) Tailwind 3 (unused)
CategoryTechnologyVersionNotes
LanguagePHP^8.2composer.json line 9
Frameworklaravel/framework^11.31Current LTS-era release; deps fresh
Authlaravel/passport^13.0OAuth2 + personal access tokens; auth:api driver
SMS / OTPtwilio/sdk^8.7Twilio Verify v2 service
ID obfuscationvinkla/hashids^12.0Encodes campaign IDs for public URLs
Notificationslaravel/slack-notification-channel^3.6Imported but actual Slack calls bypass it and use raw Http::post
BuildVite + Tailwind6 / 3Skeleton only; this repo serves no HTML
TestsPHPUnit^11.0Two default ExampleTest stubs; no real coverage
DBSQLite (dev) / MySQL (prod assumed)Only delta migrations from 2025-09 onward — base schema lives in a sibling repo

Architecture

Single-tier Laravel 11 JSON API. Public routes for OTP send/verify and reference data (categories/countries/states); everything else is gated by Passport auth:api. Controllers do their own role checks against Account->registration_type (admin / subadmin / accounts). Three external services: Twilio Verify (OTP), Slack webhooks (campaign approval pings), SMTP (PIN delivery + status mail).

alist-partner/ ├── app/ │ ├── Http/Controllers/Api/ 4 controllers, ~2,100 LOC total │ │ ├── AccountsController.php (607) accounts CRUD, PIN, public offer-count webhook │ │ ├── VenuesController.php (429) brand CRUD, file uploads, lookups │ │ ├── CampaignController.php (800) food & dedicated offers, dashboard, vouchers │ │ └── OtpController.php (288) Twilio Verify send/check + phone-change flow │ ├── Models/ 16 Eloquent models (Account, Venue, FoodOffers, …) │ ├── Service/ TwilioService, SlackService (raw webhook poster) │ ├── Mail/ PinMail, CampaignStatusMail, DedicatedStatusMail │ └── Providers/AuthServiceProvider.php forces accounts provider on api guard ├── routes/ │ ├── api.php all real routes (one file, no grouping by version) │ └── web.php welcome view + a /test-db debug endpoint ├── database/migrations/ 19 *delta* migrations from 2025-09-01 onward — no base schema ├── config/thirdparty.php ⚠ hardcoded prod & dev Slack webhook URLs └── tests/ default Laravel ExampleTest stubs only

Security assessment — methodology

White-box review of the full source tree and git history, grounded in the anthropic-cybersecurity-skills playbook library. Each finding was produced by parallel per-dimension scans (source/sink tracing with ripgrep plus manual reading of the surrounding code), then put through an adversarial verification pass that confirmed exploitability against the actual code at HEAD and discarded theoretical hits. Severity is mapped to OWASP Top 10 2021 and the OWASP API Security Top 10 2023, classified by CWE, and scored with CVSS 3.1.

OWASP Top 10 2021 OWASP WSTG OWASP API Top 10 2023 OWASP MASVS (mobile only) CWE CVSS 3.1 NIST CSF 2.0
Playbooks applied for this repo (server-side Laravel API; no mobile, JS is dev-only build tooling):
Injection & unsafe input: exploiting-sql-injection-vulnerabilities performing-second-order-sql-injection exploiting-mass-assignment-in-rest-apis exploiting-template-injection-vulnerabilities testing-for-xxe-injection-vulnerabilities exploiting-insecure-deserialization
Broken access control: testing-for-broken-access-control testing-api-for-broken-object-level-authorization exploiting-idor-vulnerabilities exploiting-broken-function-level-authorization bypassing-authentication-with-forced-browsing detecting-broken-object-property-level-authorization
Auth, session & tokens: testing-jwt-token-security testing-for-json-web-token-vulnerabilities exploiting-jwt-algorithm-confusion-attack testing-oauth2-implementation-flaws exploiting-oauth-misconfiguration testing-api-authentication-weaknesses
Secrets & cryptography: implementing-secret-scanning-with-gitleaks detecting-aws-credential-exposure-with-trufflehog performing-cryptographic-audit-of-application testing-for-sensitive-data-exposure
SSRF, CORS & misconfig: exploiting-server-side-request-forgery testing-cors-misconfiguration testing-for-host-header-injection performing-csrf-attack-simulation testing-for-open-redirect-vulnerabilities testing-for-email-header-injection
Supply chain & CI/CD: performing-sca-dependency-scanning-with-snyk analyzing-sbom-for-supply-chain-vulnerabilities detecting-supply-chain-attacks-in-ci-cd prioritizing-vulnerabilities-with-cvss-scoring implementing-epss-score-for-vulnerability-prioritization
Business logic & abuse: testing-for-business-logic-vulnerabilities exploiting-race-condition-vulnerabilities

Risk scorecard

Risk narrative

alist-partner is in a critical state with multiple independent paths to full compromise. The Passport RSA signing key (storage/oauth-private.key) is committed and tracked at HEAD, letting anyone with repo access forge admin tokens and bypass the entire auth model; a committed git-history .env additionally leaks a live Twilio token, two shared-DB MySQL passwords, and the APP_KEY, and a migration seeds a static shared admin OTP backdoor (982154) reachable on the public, unthrottled /api/verify-otp. Even without the leaked secrets, the API has no real authorization layer: role and ownership checks are ad-hoc inline conditionals that are simply absent on the most dangerous endpoints, so any authenticated 'accounts' partner can self-promote to admin via mass assignment, delete or deactivate every account/campaign in the shared tenant, and read or tamper with arbitrary venues, campaigns, voucher codes and influencer PII. File uploads write attacker-controlled extensions into the public web root with validation that is declared but never executed, giving authenticated RCE on a PHP-executing host. The blast radius is amplified by a database shared across A-List services, ubiquitous error-message leakage, no rate limiting, no token expiry/revocation, and a dead CI pipeline with privileged unpinned third-party workflows — meaning none of these regressions are caught before deploy. Treat all committed secrets as compromised and rotate immediately, then add a centralized authorization layer before any further feature work.

Overall risk Critical
9 P0 9 P1 8 P2 4 P3

Systemic / cross-repo themes

OWASP coverage

CategoryStatusNotes
A01:2021 Broken Access ControlvulnPervasive: mass-assignment self-promotion to admin (AP-05), missing function-level auth on delete/bulk ops (AP-07), BOLA on campaigns/venues/accounts (AP-10/11/12), inconsistent role gating (AP-24). The worst category in the repo.
A02:2021 Cryptographic FailuresvulnCommitted Passport RSA private key (AP-01) and leaked APP_KEY enabling token forgery (AP-03); plaintext urgent_otp; Hashids with default empty salt (AP-27).
A03:2021 InjectioncleanAll whereRaw and LIKE filters use bound placeholders; no first-order SQLi found (scanner SQLi item rejected). No command/template injection sinks observed.
A04:2021 Insecure DesignvulnUnauthenticated replayable webhook with no nonce/TTL (AP-25); no concurrency control / TOCTOU on approval transitions (AP-26); auth design relies on inline role strings with no policy layer.
A05:2021 Security Misconfigurationvuln.env-in-history (AP-02), APP_ENV=local/APP_DEBUG=true defaults (AP-17), error-message leakage + /test-db (AP-19), unrestricted public-root file upload (AP-06), broken .gitignore / no secret scanning (AP-28).
A06:2021 Vulnerable & Outdated Componentscleancomposer deps all current majors (Laravel 11.31, Passport 13, Twilio 8.7, PHP 8.2+) with no known CVEs; composer.lock fresh. JS tooling lacks a lockfile (AP-30) but is dev-only/unused at runtime.
A07:2021 Identification & Authentication FailuresvulnStatic shared admin OTP backdoor 982154 (AP-04/AP-14), seeded admin PIN 123456 (AP-09), hardcoded OAuth client secret (AP-08), no rate limiting/lockout (AP-15), Twilio fail-open (AP-23).
A08:2021 Software & Data Integrity Failuresvulnpull_request_target reusable workflow on mutable laravel/.github@main with write scopes (AP-18), all actions unpinned (AP-21), no committed npm lockfile (AP-30), dead CI gate (AP-22).
A09:2021 Security Logging & Monitoring FailurespartialNo application audit logging of admin actions (bulk delete, approvals, emergency-OTP use), no alerting, and long-lived non-revocable tokens (AP-20) make incident response hard. Not a standalone confirmed finding but a clear gap.
A10:2021 Server-Side Request Forgeryn/aNo user-controlled outbound URL fetch in the codebase; Slack/Twilio targets are server-side config, not request-derived. No SSRF surface.
API Top 10 (2023) cross-refvulnAPI1 BOLA (AP-10/11/12), API2 Broken Auth (AP-04/08/09/14/20), API3 BOPLA mass assignment (AP-05/13), API4 Unrestricted Resource Consumption (AP-15), API5 BFLA (AP-07). The API Top 10 maps directly onto the highest-severity findings.

Detailed findings

P0 critical — 9 P1 high — 9 P2 medium — 8 P3 low / info — 4
AP-01

Passport OAuth2 RSA private key (storage/oauth-private.key) committed and tracked at HEAD — full token-forgery auth bypass

P0 OWASP A02:2021 Cryptographic Failures CWE-321 CVSS 9.8 EPSS n/a confirmed
Description

Laravel Passport signs every OAuth2 / personal-access token with the RSA private key in storage/oauth-private.key. That key is committed and currently tracked in the repository (verified: git check-ignore returns not-ignored and the file begins with a BEGIN RSA PRIVATE KEY block). Anyone with read access to the repo, a clone, fork, or backup possesses the signing key.

Evidence
git ls-files lists storage/oauth-private.key and storage/oauth-public.key as tracked (current HEAD, not just history) git check-ignore storage/oauth-private.key -> exit 1 (NOT ignored by .gitignore) head -1 storage/oauth-private.key -> -----BEGIN RSA PRIVATE KEY----- (full RSA private key present in tree) config/passport.php:29-31 -> 'private_key' => env('PASSPORT_PRIVATE_KEY'), 'public_key' => env('PASSPORT_PUBLIC_KEY') — Passport signs all access tokens with this pair OtpController.php:116/130 -> $account->createToken('otp-login') issues Passport tokens signed by this key
Attack scenario
Attacker clones the Bitbucket/GitHub repo (or obtains a backup), extracts storage/oauth-private.key, and uses a small PHP script with the same Passport/league-oauth2 library to mint a JWT/access token bound to an admin account id. They present it as a Bearer token to any auth:api route and operate as admin without ever touching the OTP flow.
Impact

An attacker holding the private key can forge a validly-signed Passport access token for ANY account id, including admin accounts, completely bypassing the Twilio-OTP login. Because all controller authorization keys off Account.registration_type, a forged admin token grants full control of accounts, venues, campaigns, voucher codes and bulk-delete operations. This is a complete authentication bypass for the entire authenticated API surface, independent of every other control in the app.

Remediation
Treat the key pair as compromised. Run php artisan passport:keys --force to regenerate (invalidates all existing tokens, forcing re-login). git rm --cached storage/oauth-private.key storage/oauth-public.key; add storage/*.key to .gitignore. Deploy keys via PASSPORT_PRIVATE_KEY/PASSPORT_PUBLIC_KEY env or a secrets manager. Purge from history with git-filter-repo/BFG and force-push after team coordination.
Verifier note

Confirmed both files tracked and the private-key PEM present at HEAD; this is the single highest-impact finding and subsumes the need for any other auth bypass.

AP-02

Production .env (live Twilio Auth Token, two MySQL passwords, APP_KEY) recoverable from git history

P0 OWASP A05:2021 Security Misconfiguration CWE-540 CVSS 9.1 EPSS n/a confirmed
Description

The application .env was committed twice and only later untracked, leaving live credentials permanently in git history. Recoverable secrets: a Twilio Account SID + Auth Token, two MySQL credential sets tied to shared parhaman_* databases, and the Laravel APP_KEY. The username parhaman_alist reveals the cPanel/hosting account prefix and the password !0325Parham! is a guessable human pattern.

Evidence
git log --all -- .env -> added 7b64f5b 'initial setup', modified bdacf1f 'env changes', untracked only in 6f102ae 'Stop tracking .env file' (persists in history) git show 7b64f5b:.env -> TWILIO_SID=AC117a71d260e59a73948b43d1e5d1fc0b, TWILIO_TOKEN=54f730f8a458a99f7f2fb76ec2d9bfa1, TWILIO_FROM=+17159628333, DB_USERNAME=alist_dev, DB_PASSWORD=4v3Fn8qSA3DXfKFR, APP_KEY=base64:JaWD9aQAeZz8mTAG9a5K064u6aJIddT+AfUqp35p298=, APP_DEBUG=true git show bdacf1f:.env -> DB_USERNAME=parhaman_alist, DB_PASSWORD=!0325Parham!, same APP_KEY CLAUDE.md notes DB is almost certainly shared with the influencer/creator service
Attack scenario
Attacker runs `git log --all` and `git show 7b64f5b:.env`, lifts the Twilio token and DB password, then either authenticates to the Twilio REST API to pump SMS / read verification logs, or connects to the shared MySQL host (parhaman_*) if it is network-reachable, reading cross-tenant PII.
Impact

Anyone with repo (or clone/fork/backup) access can: (1) take over the Twilio account for toll fraud / OTP interception and drain balance, (2) attempt direct MySQL access to the shared production database exposing all partner, venue, campaign and cross-app PII, and (3) use APP_KEY to forge Laravel encrypted payloads (see AP-03). Because the DB is shared across A-List services, blast radius extends beyond this repo.

Remediation
Rotate all four credential classes NOW: regenerate the Twilio Auth Token, change both MySQL passwords, and run php artisan key:generate (migrate Crypt data via APP_PREVIOUS_KEYS). Purge .env from history with git-filter-repo/BFG and force-push. Add gitleaks/trufflehog pre-commit + CI gates. Move secrets to a manager.
Verifier note

Confirmed via git show on both commits; identical APP_KEY in both versions ties this to AP-03.

AP-03

Leaked APP_KEY enables forgery of the unauthenticated /api/update/account offer_token and any Laravel-encrypted payload

P0 OWASP A02:2021 Cryptographic Failures CWE-321 CVSS 8.1 EPSS n/a confirmed
Description

The only access control on the public POST /api/update/account endpoint is Crypt::decryptString() of an attacker-supplied offer_token, which is keyed by APP_KEY (AES-256-CBC + HMAC). Because APP_KEY is exposed in git history (AP-02), an attacker can encrypt their own {"offer_id":N} payload with the leaked key and produce a valid offer_token, bypassing the secret-token gate. The same known key allows forging/decrypting any Laravel encrypted cookie/session and any Crypt-encrypted DB column.

Evidence
git show 7b64f5b:.env / bdacf1f:.env -> APP_KEY=base64:JaWD9aQAeZz8mTAG9a5K064u6aJIddT+AfUqp35p298= config/app.php:98-100 -> 'cipher' => 'AES-256-CBC', 'key' => env('APP_KEY') routes/api.php:52 -> Route::post('/update/account', [AccountsController::class,'updateOffersCount']) OUTSIDE the auth:api group AccountsController.php:558-563 -> $offer_token=$request->offer_token; $payload=json_decode(Crypt::decryptString($offer_token),true); $offer_id=$payload['offer_id'];
Attack scenario
Attacker extracts APP_KEY from git, writes a script using Laravel's Encrypter to produce Crypt::encryptString(json_encode(['offer_id'=>X])), then POSTs it to /api/update/account repeatedly (no auth, no rate limit) to drive counter recomputes and enumerate which offer_ids exist via the distinct 'Campaign does not exists' / 'not published yet' responses.
Impact

An unauthenticated attacker can mint valid offer_tokens for arbitrary offer_ids and trigger updateOffersCount, recomputing/overwriting account.food_offers_count for the venue that owns the offer (data-integrity tampering of dashboard metrics; the value is recomputed server-side so it cannot be set to an arbitrary number). More broadly a known APP_KEY undermines all Laravel encrypted payloads until rotated.

Remediation
Rotate APP_KEY immediately and migrate existing encrypted data via APP_PREVIOUS_KEYS. Do not use Crypt as an authorization boundary: require authentication or an HMAC with a dedicated rotated secret, validate issued_at/TTL, add replay protection, and bind the token to the acting account. Purge APP_KEY from history.
Verifier note

Confirmed route is outside auth:api and the decrypt-only gate. Impact tempered because food_offers_count is recomputed from server-side published-offer counts (and the foodOffers relation FK 'restaurant_name' may even be broken), not set to a client value — hence I:L not I:H.

AP-04

Hardcoded shared emergency OTP (982154) permanently bypasses Twilio MFA for all admin accounts

P0 OWASP A07:2021 Identification and Authentication Failures CWE-798 CVSS 9.8 EPSS n/a confirmed
Description

A migration seeds the SAME static plaintext value '982154' as urgent_otp for every admin account. verifyOtp() grants a full Passport access token whenever registration_type==='admin' AND urgent_otp == submitted otp (loose ==), reached on any non-approved Twilio result. The value is committed to git, identical across all admins and environments, never rotated, and the send path actively steers admins to it when Twilio errors. Admin phone/country_code identifiers are also committed in AccountSeeder.

Evidence
database/migrations/2025_10_30_031008_add_urgent_otp_to_accounts_table.php:16-19 -> DB::table('accounts')->where('registration_type','admin')->update(['urgent_otp'=>'982154']) OtpController.php:128-138 -> if($account->registration_type==='admin' && $account->urgent_otp == $otp){ ... createToken('otp-login'); 'success'=>true } OtpController.php:65-71 -> on any Twilio send failure for admin accounts, sendOtp returns success:true status:'pending' 'Please enter emergency OTP' (advertises the bypass) Account.php:21-25 -> urgent_otp stored as plaintext column, only hidden from JSON database/seeders/AccountSeeder.php:24-31,43-53,65-75 -> admin phones +91/9400592097, +971/503462656, +971/554046860 committed routes/api.php:11 -> POST /api/verify-otp is public; no throttle anywhere (grep)
Attack scenario
Attacker reads AccountSeeder.php for admin@gmail.com (+91 9400592097), POSTs {phone:9400592097,country_code:+91,otp:982154} to /api/verify-otp, receives token, and immediately drives every admin route (bulk delete accounts, approve campaigns, dump voucher codes).
Impact

Any attacker who knows (or enumerates) an admin phone+country_code can call public POST /api/verify-otp with otp=982154 and receive a valid admin Passport token — complete administrative takeover with no possession factor. Even without prior knowledge the 6-digit code is brute-forceable in <=10^6 attempts because there is no rate limiting (AP-15).

Remediation
Remove the urgent_otp bypass entirely. If a break-glass path is required, generate a per-account high-entropy single-use code, store it hashed (Hash::make), compare with hash_equals/Hash::check, enforce short TTL + single use, rate-limit, alert on use, and never advertise it in API responses. Migration to null all urgent_otp values now and rotate admin credentials.
Verifier note

Confirmed in migration + controller. The == is loose but the column is a string so PHP type-juggling is a secondary issue; the core flaw is a committed static shared secret.

AP-05

Mass-assignment privilege escalation: any authenticated partner can self-promote to admin via POST /api/account/{id}

P0 OWASP A01:2021 Broken Access Control (API3:2023 BOPLA / Mass Assignment) CWE-915 CVSS 9.1 EPSS n/a confirmed
Description

AccountsController::update() loads the target account purely from the {id} path with no ownership or role verification, validates a body that explicitly permits registration_type in {admin,accounts,subadmin}, and calls $account->update($validated). registration_type is in $fillable and is the field every authorization decision keys off. A low-privilege 'accounts' partner holding any valid Passport token can POST /api/account/{own_id} with {country_code,registration_type:'admin'} and become admin. The same missing object-level check also lets them rewrite any other account's id, email, phone or status.

Evidence
routes/api.php:19 -> Route::post('account/{id}', [AccountsController::class,'update']) inside auth:api only, no role middleware/policy AccountsController.php:184 -> $account = Account::find($id) (takes path id directly, no ownership/role check) AccountsController.php:201 -> 'registration_type' => 'required|in:admin,accounts,subadmin' (literal 'admin' accepted) AccountsController.php:206 -> $account->update($validated) (mass-assigns validated array) Account.php:27-39 -> $fillable includes registration_type, status, pin... so role field is mass-assignable
Attack scenario
Partner logs in via OTP, gets a token, POSTs /api/account/<their id> with body {country_code:'+971', registration_type:'admin'}; the row is updated, their next requests carry admin authority everywhere.
Impact

Vertical privilege escalation from a normal partner to full admin in one request (unlocking every admin path and the urgent_otp login), plus horizontal account takeover of arbitrary accounts (email/phone rewrite then PIN reset). Effectively a complete authorization bypass for the partner portal.

Remediation
Add object-level + role authorization (Policy/middleware): allow only Auth::id()===$id for self-edit OR admin/subadmin for others. Remove registration_type, status, created_by, food_offers_count, pin from mass-assignment; set role only via a dedicated admin-only endpoint. Use a FormRequest/DTO that excludes server-managed fields rather than passing the whole validated array to update().
Verifier note

Confirmed; raw set listed this twice (injection + access_control dimensions) — merged here. Only country_code and registration_type are 'required' on update, so the body is trivial to satisfy.

AP-06

Unrestricted file upload to web-accessible directory (declared validation never executed) — potential RCE

P0 OWASP A05:2021 Security Misconfiguration / A04 Insecure Design CWE-434 CVSS 8.8 EPSS n/a confirmed
Description

VenuesController::store()/update() move uploaded trade_license_file and vat_certificate_file into public_path('assets/uploads/venues/files') using the client-supplied extension with no server-side type enforcement; the only validate() call covers three unrelated text fields. The image-field $validate_array is declared but never executed. uploadVenueFiles() preserves the client filename and writes into the public menu directory. Because the destination is inside the web root, an authenticated partner can upload a .php (or .phtml) file and request it directly to achieve RCE on a PHP-executing host.

Evidence
VenuesController.php:58-65 -> trade_license_file: $fileName=Str::uuid().'.'.$file->getClientOriginalExtension(); $file->move(public_path('assets/uploads/venues/files'),$fileName) — attacker-controlled extension, no MIME/extension validation VenuesController.php:68-74 -> vat_certificate_file: same pattern VenuesController.php:50-54 / 178-182 -> $request->validate() only validates venue_title, company_name, account_id — file fields unvalidated VenuesController.php:78-80 / 188-190 -> $validate_array image|mimes rule assigned to a local var and NEVER passed to a Validator (dead code) VenuesController.php:382-408 uploadVenueFiles -> $name=rand(0,999).$file->getClientOriginalName(); $file->move('assets/uploads/venues/menu/',$imgname) — original filename preserved, web-root destination, mimes list allows doc/docx
Attack scenario
Authenticated 'accounts' partner POSTs /api/venue/{id} with trade_license_file=shell.php; the file lands at /assets/uploads/venues/files/<uuid>.php; attacker requests https://host/assets/uploads/venues/files/<uuid>.php?cmd=id and runs commands. Note Image::make on the image field would fatal because intervention/image is absent, but the trade_license/vat/menu paths do not call Image and execute the move regardless.
Impact

Remote code execution / web shell by any authenticated account (including the lowest 'accounts' role, which can reach POST /api/add/venue, POST /api/venue/{id}, POST /api/upload/files) on a server that executes PHP from the uploads path. Full server compromise, data exfiltration, lateral movement. Secondary: stored XSS via SVG/HTML and malware hosting on a trusted domain.

Remediation
Actually pass the rule array to $request->validate()/Validator::make() and fail closed. Enforce a strict server-side extension+MIME allowlist (pdf/jpg/png) by sniffed content type, not client extension. Store uploads on a non-web-served disk (storage/app or S3) and stream downloads through an authenticated controller. Generate random server-side filenames for all upload paths. Disable script execution in the upload dir (php_admin_flag engine off). Add size caps and AV scanning.
Verifier note

Confirmed dead validation and web-root move with attacker-controlled extension. RCE is conditional on the web server executing PHP from uploads (common on shared LAMP hosts per the public_path pattern), so scored 8.8 rather than 10.

AP-07

Broken function-level authorization: any partner can delete/deactivate any or all accounts and campaigns (single + bulk)

P0 OWASP A01:2021 Broken Access Control (API5:2023 BFLA) CWE-862 CVSS 8.1 EPSS n/a confirmed
Description

destroy(), bulkDelete() and bulkUpdateStatus() on accounts, and destroy()/bulkDelete() on campaigns, perform destructive operations on arbitrary ids from the request with zero authorization logic — they do not even call Auth::user(). Any authenticated partner can soft-delete or deactivate any account (or all accounts) and soft-delete any campaign by enumerating sequential integer ids.

Evidence
AccountsController.php:234-260 destroy($id) -> Account::find($id)->delete() with no Auth::user(), no role/ownership check AccountsController.php:262-289 bulkUpdateStatus -> Account::whereIn('id',$ids)->update(['status'=>...]) no auth/role gate AccountsController.php:291-316 bulkDelete -> Account::whereIn('id',$ids)->delete() no auth/role gate CampaignController.php:404-421 destroy($id) -> FoodOffers::find($id)->delete() no auth/ownership/role check CampaignController.php:423-448 bulkDelete -> FoodOffers::whereIn('id',$ids)->delete() no ownership/role check routes/api.php:20-22,36-37 -> all inside auth:api group only
Attack scenario
Partner POSTs /api/accounts/bulk-update-status {account_ids:[1..5000], status:'inactive'} — every account is deactivated; no admin can log in; platform is offline until DB is manually restored.
Impact

Tenant-wide denial of service and integrity destruction: a single partner token can set status=inactive on every account (sendOtp rejects inactive accounts, locking out all logins including admins) or soft-delete every account/campaign in the shared platform. Sequential ids make full enumeration trivial.

Remediation
Gate all delete/bulk operations behind an admin/subadmin Policy or route middleware (e.g. role:admin). For partner-scoped ops, verify target ids belong to the caller's tenant before mutating and reject unauthorized ids rather than operating on all supplied ids. Add Auth::user() null checks.
Verifier note

Confirmed across both controllers; merged the account-bulk and campaign-bulk findings into one BFLA entry.

AP-08

Hardcoded Passport personal-access-client secret committed in AccountSeeder, stored unhashed

P0 OWASP A07:2021 Identification and Authentication Failures CWE-798 CVSS 8.6 EPSS n/a confirmed
Description

AccountSeeder hardcodes the OAuth personal-access-client secret and inserts it directly into oauth_clients. This secret is the credential Passport uses to mint personal access tokens (the exact mechanism createToken('otp-login') depends on). There is no Passport::hashClientSecrets() configuration, so it is stored and compared as plaintext, and the seed value is almost certainly the production value.

Evidence
database/seeders/AccountSeeder.php:88-99 -> DB::table('oauth_clients')->insert([... 'secret'=>'DtpYoe525hd4mmSv8dC2pBv7YoqkiN5vN0YL6RhL', 'personal_access_client'=>1 ...]) database/seeders/DatabaseSeeder wires AccountSeeder into db:seed No Passport::hashClientSecrets() call exists anywhere (grep) — secrets stored/compared as plaintext OtpController.php:116 -> Account::createToken('otp-login') relies on this personal-access client
Attack scenario
Attacker with the leaked client secret (and the leaked admin identifiers) drives the personal-access-token issuance flow to obtain admin tokens, or uses it to validate/forge client-bound flows.
Impact

Disclosure of the personal-access-client secret lets an attacker who can reach the token issuance machinery (or who can write to/read the DB) mint personal access tokens for arbitrary accounts, undermining the integrity of the bearer-token scheme. Combined with the committed admin identifiers it materially aids admin impersonation. (Partly redundant with AP-01, which is the cleaner path, but this is an independent compromised credential.)

Remediation
Remove the secret from the seeder; generate the client via php artisan passport:client --personal per environment and store the secret only in secrets management. Enable Passport::hashClientSecrets(). Rotate the leaked secret, purge from history, and revoke all tokens issued under the old client.
Verifier note

Confirmed in seeder; no hashClientSecrets anywhere. Scored just under AP-01 because the raw private key (AP-01) is the more direct forgery primitive.

AP-09

Seeded admin accounts with weak shared hardcoded PIN (123456) and committed identifiers

P0 OWASP A07:2021 Identification and Authentication Failures CWE-1392 CVSS 8.1 EPSS n/a confirmed
Description

The seeder creates three live admin accounts with publicly committed phone numbers/emails and an identical PIN of 123456 used for the sensitive-action re-auth gate (POST /api/validate/pin). The PIN is a 4-6 digit value, shared across all three admins, and committed in git. These are the same accounts targeted by the urgent_otp backdoor (AP-04).

Evidence
AccountSeeder.php:24-31 -> admin@gmail.com +91 9400592097 pin=Hash::make('123456') registration_type=admin AccountSeeder.php:43-53,65-75 -> admin1@alist.com +971 503462656 and admin2@alist.com +971 554046860, both pin Hash::make('123456') AccountsController.php:529 -> validatePin uses Hash::check($request->pin,$account->pin); no throttle (grep)
Attack scenario
Attacker gains an admin token via AP-04, then any UI step-up that calls /api/validate/pin is cleared with 123456.
Impact

Attackers who pull the repo know valid admin login identifiers and the secondary PIN re-auth value, removing the step-up factor protecting sensitive operations and confirming which accounts to target for the OTP bypass. Predictable seeded credentials are directly exploitable in any environment that ran db:seed.

Remediation
Do not seed real admins with static credentials. Provision admins out-of-band, force a unique PIN on first use, never commit PINs, increase PIN entropy, add rate-limiting/lockout to validatePin, and rotate the PIN of any account that carried 123456.
Verifier note

Confirmed; PIN is hashed at rest (good) but the value is committed and shared, defeating the hashing.

AP-10

BOLA / cross-tenant tampering on campaign approve/reject, voucher codes and detail endpoints

P1 OWASP API1:2023 Broken Object Level Authorization CWE-639 CVSS 8.1 EPSS n/a confirmed
Description

List/dashboard queries correctly scope to the caller's venues for the 'accounts' role, proving an ownership model exists, but the per-object endpoints reached by {id} do not apply it. show, voucherCodes, byDateCampaign, dedicatedOfferDetails read any campaign; updateAccountStatus/dedAccountStatus/creatorStatusUpdate write approval state on any campaign/offer-user. Ids are sequential integers (the Hashids campaign_id is only a display field; routes consume the raw integer).

Evidence
CampaignController.php:184-213 updateAccountStatus($id) -> FoodOffers::find($id); $offer->account_status=...; save() — no venue.account_id==Auth::id() check, no role check CampaignController.php:251-280 dedAccountStatus($id) -> same on DedicatedOffer CampaignController.php:363-384 creatorStatusUpdate($id) -> DedicatedOfferUsers::find($id) approve(6)/decline(7) with NO Auth/ownership check CampaignController.php:499-512 voucherCodes($id) -> FoodOffersUsers where offer_id=$id returns offer_code + user name/email for ANY offer, no ownership filter CampaignController.php:317-352 show / 749-770 byDateCampaign / 772-799 dedicatedOfferDetails -> read any id, no account scoping Contrast: index/dedicatedOffers/dashboard DO scope by account_id for registration_type=='accounts' (lines 53,139,541)
Attack scenario
Partner iterates /api/campaign/voucher-code/{1..N}, dumping every merchant's voucher codes and redeeming influencers' emails; then POSTs /api/campaign/{competitorOfferId}/status {status:'Rejected'} to sabotage a rival's campaign.
Impact

Cross-tenant breach of influencer PII (name, email, Instagram handle, follower data) and theft of voucher/offer codes for any merchant, plus unauthorized approval/rejection of competitors' campaigns and flipping creator decisions — integrity and confidentiality loss across the shared database.

Remediation
For registration_type=='accounts', scope every per-object campaign query by the owning venue's account_id (whereHas('venue', fn($q)=>$q->where('account_id',$user->id))) before find, or use a FoodOffer/DedicatedOffer policy that loads-then-authorizes. Restrict approval actions to admin/subadmin via Gate/Policy. Apply the same ownership filter to voucherCodes/byDateCampaign/reviews/posts/dedicatedOfferDetails.
Verifier note

Confirmed; merged the access_control and business_logic copies of this finding.

AP-11

BOLA on venue update/show and file deletion: any partner can hijack, tamper with, or destroy any venue and its documents

P1 OWASP API1:2023 Broken Object Level Authorization CWE-639 CVSS 8.1 EPSS n/a confirmed
Description

VenuesController::update fetches a venue by URL id with no authorization and writes an attacker-supplied account_id, contact info and file paths; none of these methods call Auth::user(). show() returns any venue with its uploaded trade-license/VAT documents and offers. deleteVenueFile() deletes any VenueFiles row by id.

Evidence
routes/api.php:27-30 -> GET /venue/{id} show, POST venue/{id} update, POST /delete/venue_file deleteVenueFile VenuesController.php:167-252 update($id) -> Venue::find($id); $venue->account_id=$request->account_id; save() — no Auth, no ownership/role check, account_id attacker-controlled VenuesController.php:353-380 show($id) -> returns any venue with accounts, venueFiles (trade-license/VAT docs) and offers, no scoping VenuesController.php:420-428 deleteVenueFile -> VenueFiles::where('id',$file_id)->delete() with no ownership check
Attack scenario
Partner POSTs /api/venue/<competitorVenueId> with account_id=<own id>, reassigning the venue to themselves, then reads its trade-license PDF via /assets/uploads/venues/files/...
Impact

Cross-tenant venue takeover: a partner can reassign any merchant venue to themselves (account_id = own id), claiming its campaigns/offer counts; exfiltrate sensitive uploaded business documents served from the public web root; or destroy other merchants' uploaded files. Integrity and confidentiality impact across all venues.

Remediation
Add a VenuePolicy authorizing view/update/delete (admin/subadmin = all; accounts = only venues where account_id==user->id). Do not accept account_id from the request for partner-role callers; derive ownership server-side. For deleteVenueFile, verify the file's venue belongs to the caller.
Verifier note

Confirmed; venueFiles are served from public web root, so document exfil is direct.

AP-12

IDOR on account detail: GET /api/account/{id} returns any account's PII with no ownership or role check

P1 OWASP API1:2023 Broken Object Level Authorization CWE-639 CVSS 6.5 EPSS n/a confirmed
Description

show() loads an account by URL id with its venues and returns it with no authorization. A partner can enumerate sequential ids and read every account's first_name, last_name, email, phone, country_code, account_type, status, created_by and linked venues. pin/urgent_otp are hidden via $hidden, but the rest leaks.

Evidence
routes/api.php:17 -> Route::get('/account/{id}', [AccountsController::class,'show']) AccountsController.php:163-179 show($id) -> Account::with('venues')->find($id); returns account; no Auth::user(), no ownership/role check
Attack scenario
Partner loops GET /api/account/{1..N} and harvests every account's email/phone for a phishing campaign and to seed AP-04/AP-05.
Impact

Mass disclosure of partner/merchant contact PII across the whole tenant via sequential id enumeration; enables targeted phishing and supplies valid ids for the escalation (AP-05) and bulk-delete (AP-07) attacks.

Remediation
Add object-level authorization: non-admins may only fetch their own account; admin/subadmin may fetch others. Implement via AccountPolicy::view + Gate/can(), or scope the query by the authenticated account id for partner role.
Verifier note

Confirmed; sensitive pin/urgent_otp are $hidden so confidentiality is High not Critical.

AP-13

Mass-assignment on account creation (POST /api/add/account) lets non-admins create admin/subadmin accounts

P1 OWASP API3:2023 Broken Object Property Level Authorization CWE-915 CVSS 6.5 EPSS n/a confirmed
Description

store() is reachable by any authenticated user (no role check) and mass-assigns $validated into Account::create(). The validator permits registration_type=admin|subadmin and status=active, both $fillable, so a non-admin caller can create brand-new admin/subadmin accounts. Lower-impact sibling of AP-05 (creates new privileged identities rather than escalating the session) but provides persistence.

Evidence
AccountsController.php:35-47 -> validator allows 'registration_type'=>'required|in:admin,accounts,subadmin' and 'status'=>'required|in:active,inactive' AccountsController.php:56 -> $account=Account::create($validated) (mass-assign incl. attacker-chosen registration_type/status) routes/api.php:18 -> Route::post('/add/account',...'store') in auth:api group only, no role gate inside store() Note created_by is overwritten server-side (line 53) so that field is safe
Attack scenario
Partner POSTs /api/add/account with a new email/phone and registration_type='admin', status='active', then logs in as that fresh admin via OTP — a durable backdoor surviving cleanup of their own account.
Impact

An authenticated low-privilege user can create new admin/subadmin accounts (persistence / privilege escalation) and pre-set status=active, bypassing onboarding controls. Requires a unique email and phone, slightly raising the bar.

Remediation
Gate store() behind an admin/subadmin Policy/middleware and force registration_type to a safe default (validate against the caller's own privilege) rather than accepting it from the body. Exclude registration_type and status from self-service create input.
Verifier note

Confirmed; impact I:H, C:L (creates an account, limited read).

AP-14

Admin authentication bypass via plaintext urgent_otp with loose comparison and Twilio fail-open

P1 OWASP A07:2021 Identification and Authentication Failures CWE-697 CVSS 7.4 EPSS n/a confirmed
Description

This is the mechanism/secondary view of AP-04: verifyOtp accepts a static plaintext urgent_otp for admins using loose == comparison (type-juggling risk against a JSON numeric otp), and sendOtp fails open by advertising the emergency path whenever Twilio errors. Kept distinct from AP-04 to capture the fail-open/loose-comparison design defects beyond the committed value.

Evidence
OtpController.php:128 -> if($account->registration_type==='admin' && $account->urgent_otp == $otp) (loose ==) OtpController.php:63-72 -> sendOtp catch for admin returns success/'pending' 'Twilio is not working. Please enter emergency OTP' TwilioService.php throws on any send error, so any outage triggers the admin emergency-OTP branch Account.php:21-25 -> urgent_otp plaintext column hidden only from JSON
Attack scenario
Attacker floods sendOtp to trip Twilio's limits, observes the admin 'enter emergency OTP' prompt confirming the account is admin, then submits 982154 (or brute-forces) to log in.
Impact

Admin account takeover bypassing the OTP second factor whenever the static code is leaked, guessed, or brute-forced (no rate limiting / lockout), and the fail-open send path makes the bypass discoverable and reachable on demand (e.g. by degrading Twilio reachability).

Remediation
Fail closed when the provider errors — return a generic error, do not advertise an alternate code path, and do not branch auth behavior on registration_type in the send path. Replace urgent_otp with a hashed, single-use, time-limited code compared with hash_equals; add rate limiting/lockout.
Verifier note

Confirmed; overlaps AP-04 but captures the loose-== and fail-open design issues separately for remediation tracking.

AP-15

No rate limiting / lockout on OTP send & verify (and PIN) enables brute force and SMS-pumping

P1 OWASP API4:2023 Unrestricted Resource Consumption CWE-307 CVSS 8.1 EPSS n/a confirmed
Description

Neither the OTP send/verify endpoints, the phone-change OTP endpoints, nor the validatePin endpoint carry any throttle/RateLimiter middleware, and Laravel 11's empty withMiddleware() closure means the api group has no throttle:api. OTP verification, urgent_otp guessing, and the 4-digit PIN all allow unlimited attempts; sendOtp can be abused for SMS-pumping/toll fraud and account enumeration (distinct 'Account not found' vs 'pending' responses).

Evidence
bootstrap/app.php:14-16 -> ->withMiddleware(function(Middleware $middleware){ // }) empty: api group not assigned throttle:api grep across routes/ app/ bootstrap/ -> no 'throttle' or 'RateLimiter' occurrences routes/api.php:10-11 /send-otp /verify-otp and :43-44 /update/send-otp /update/verify-otp and :31 /validate/pin -> no throttle middleware OtpController.php:128 admin urgent_otp accepted with no attempt counter; AccountsController.php:529 validatePin no lockout
Attack scenario
Attacker scripts 10^6 POSTs to /api/verify-otp for a known admin phone cycling otp 000000-999999 with no lockout, guaranteed to hit 982154 (or any future rotated value).
Impact

Brute force of the static admin urgent_otp (982154) yields admin tokens; brute force of the 10^4 PIN space clears step-up auth; SMS-pumping drives Twilio cost; enumeration reveals registered/admin phone numbers. The missing throttle is the primary control that would otherwise blunt AP-04/AP-09.

Remediation
Apply throttle:api plus tighter per-route throttles (e.g. throttle:5,1 keyed by phone+IP) to all OTP send/verify and validatePin routes. Add per-account failed-attempt counters with temporary lockout and alerting. Return uniform responses to prevent enumeration. Remove the app-side urgent_otp comparison.
Verifier note

Confirmed empty withMiddleware and zero throttle usage; merged the two raw rate-limit findings (auth_jwt + business_logic).

AP-16

Slack incoming-webhook URLs (live + dev) hardcoded and committed in config/thirdparty.php

P1 OWASP A05:2021 Security Misconfiguration CWE-798 CVSS 7.5 EPSS n/a confirmed
Description

Both the production and development Slack incoming-webhook URLs are hardcoded as string literals in config/thirdparty.php and committed to git rather than loaded from env. A Slack incoming-webhook URL is itself a bearer credential — anyone holding it can post arbitrary messages into the target channel.

Evidence
config/thirdparty.php:10 -> 'campaign_approve' => 'https://hooks.slack.com/services/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' (LIVE Parham And Co alist-signup channel) config/thirdparty.php:20 -> dev hook https://hooks.slack.com/services/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 app/Service/SlackService.php:36 -> Http::post(config('thirdparty.slack.campaign_approve'),$payload)
Attack scenario
Attacker POSTs a crafted JSON to the live webhook posting a fake 'Campaign approved by <staff>' message to socially engineer staff who trust the automated feed.
Impact

Anyone with repo or git-history read access can spoof campaign-approval and other notifications into A-List's live signup Slack channel (social-engineering / fraud / spam) and flood it. Because the value is in history, rotation requires invalidating the webhook, not just editing the file.

Remediation
Rotate/revoke both webhooks in Slack now. Move them to env (e.g. SLACK_CAMPAIGN_APPROVE_WEBHOOK) referenced via config; never commit. Purge from history. Add secret-scanning push protection.
Verifier note

Confirmed; merged the three duplicate scanner entries (secrets_crypto + supply_chain) into one.

AP-17

Production defaults to APP_ENV=local with APP_DEBUG=true in committed .env.example

P1 OWASP A05:2021 Security Misconfiguration CWE-489 CVSS 7.5 EPSS n/a likely
Description

The committed .env.example — copied to .env during the documented setup — ships APP_ENV=local and APP_DEBUG=true. If deployed as-is (common for small teams), Laravel renders Ignition/Whoops detailed stack traces, env dumps, DB queries and source snippets on any uncaught exception. A misset APP_ENV also silently routes production Slack approvals to the dev workspace.

Evidence
.env.example:2 -> APP_ENV=local .env.example:4 -> APP_DEBUG=true config/app.php:42 -> 'debug'=>(bool)env('APP_DEBUG',false); :29 -> 'env'=>env('APP_ENV','production') config/thirdparty.php:3 -> if(config('app.env')=='production'){live hook}else{dev hook} — Slack routing depends on APP_ENV Committed .env (history) also had APP_DEBUG=true / APP_ENV=local
Attack scenario
Attacker triggers any uncaught exception (e.g. malformed offer_token on the public endpoint) and reads the Ignition page revealing APP_KEY-related stack context, DB host and full paths.
Impact

Full stack traces, file paths, framework/PHP versions, DB connection details and partial env data exposed to any unauthenticated client on error, enabling targeted exploitation; misset APP_ENV leaks operational notifications to the wrong Slack workspace.

Remediation
Set APP_ENV=production and APP_DEBUG=false in every non-local deployment; change the .env.example default to APP_DEBUG=false to fail safe. Add a deploy/CI assertion rejecting APP_DEBUG=true in production.
Verifier note

Confirmed defaults; 'likely' because actual prod override is unverified, but the committed .env also had APP_DEBUG=true, raising the odds.

AP-18

pull_request_target workflow calls mutable laravel/.github@main with pull-requests:write (CI supply-chain)

P1 OWASP A08:2021 Software and Data Integrity Failures CWE-829 CVSS 8.1 EPSS n/a confirmed
Description

Three workflows call reusable workflows from the external repo laravel/.github by the mutable branch ref @main. pull-requests.yml is triggered by pull_request_target (runs in the BASE repo context with secrets and a privileged GITHUB_TOKEN, even for fork PRs) and grants pull-requests: write; issues.yml grants issues: write; update-changelog.yml grants contents: write. Mutable third-party code + privileged trigger/permissions is the classic Actions supply-chain pattern.

Evidence
.github/workflows/pull-requests.yml -> on: pull_request_target: types:[opened]; permissions: pull-requests: write; uses: laravel/.github/.github/workflows/pull-requests.yml@main .github/workflows/issues.yml -> uses: laravel/.github/.github/workflows/issues.yml@main (issues: write) .github/workflows/update-changelog.yml -> uses: laravel/.github/.github/workflows/update-changelog.yml@main (contents: write)
Attack scenario
An actor who can push to laravel/.github@main adds an exfiltration step; the next opened PR on alist-partner runs it under pull_request_target, leaking GITHUB_TOKEN and any job secrets and pushing tampered commits.
Impact

A compromise or malicious change of laravel/.github@main executes attacker-controlled steps with contents/pull-requests/issues write and access to repository secrets, allowing codebase poisoning, PR/issue manipulation, and secret exfiltration. pull_request_target makes the privileged context reachable from fork PRs.

Remediation
Pin every reusable workflow/action to a full 40-char commit SHA and use Dependabot to bump pins. Apply least privilege (drop write scopes unless required, scope per-job). Reconsider pull_request_target for fork-triggered workflows; prefer pull_request and never run untrusted PR head code in a privileged context. Enable the repo setting requiring SHA-pinned actions.
Verifier note

Confirmed exact triggers/permissions/refs in all three workflow files.

AP-19

Internal exception messages (getMessage) returned to API clients on every endpoint, incl. unauthenticated paths

P2 OWASP A05:2021 Security Misconfiguration CWE-209 CVSS 5.3 EPSS n/a confirmed
Description

Every controller wraps logic in try/catch(\Throwable) and returns $e->getMessage() in the 500 body, independent of APP_DEBUG, including on unauthenticated endpoints (verify-otp, update/account). DB/driver exceptions leak SQL fragments, table/column names, file paths and crypto failure reasons. The unauthenticated /test-db debug endpoint additionally dumps the first 5 venue rows and raw DB errors.

Evidence
AccountsController.php:79,122,229,257,286,314,390,443,505,545,603 -> 'error'=>$e->getMessage() in catch(\Throwable) 500 responses OtpController.php:77,151,213,282 -> same (verify-otp is public) CampaignController.php:111,179,246,313,358,397,445,472,495,518,767,797 -> same VenuesController.php:144,273,303,323,341,415 -> same routes/web.php:19-24 -> GET /test-db returns DB::table('venues')->limit(5)->get() and $e->getMessage() unauthenticated
Attack scenario
Attacker POSTs malformed input to /api/verify-otp or /api/update/account and reads the leaked PDO/decrypt error to map schema and refine attacks; or simply GETs /test-db to read venue PII.
Impact

Information disclosure that accelerates further attacks: schema/column discovery (aiding BOLA targeting and the mass-assignment field discovery), internal path disclosure, Twilio/crypto failure-mode oracles, and confirmation of the shared parhaman_* database. /test-db leaks live venue PII to anyone on the internet.

Remediation
Return generic messages with a correlation id; log full detail server-side only. Centralize in App\Exceptions\Handler::render for the api/* group instead of per-controller try/catch. Ensure APP_DEBUG=false in production. Delete /test-db and /test-api debug routes.
Verifier note

Confirmed across all four controllers and web.php; merged the four duplicate scanner entries (secrets_crypto/injection/auth_jwt/ssrf).

AP-20

Long-lived Passport tokens: no expiration tuning, no logout/revocation, no revoke on phone change

P2 OWASP API2:2023 Broken Authentication CWE-613 CVSS 6.5 EPSS n/a likely
Description

Authentication issues Passport personal access tokens with no lifetime configuration, so they use Passport's long default lifetime. There is no logout route, no revocation on phone-number change or status=inactive, and no blocklist. verifyOtpUpdate issues a new token but leaves prior tokens live after a security-relevant change.

Evidence
OtpController.php:116/130/259 -> $account->createToken('otp-login') personal access tokens grep app/ bootstrap/ config/ -> no Passport::tokensExpireIn / personalAccessTokensExpireIn / refreshTokensExpireIn calls (no Passport:: calls at all) routes/api.php -> no /logout or token-revocation route OtpController.php:255-260 verifyOtpUpdate -> issues a fresh token on phone change but never revokes the old one
Attack scenario
Attacker steals a token from a leaked log; the victim changes their phone number expecting to be logged out, but the old token continues to work indefinitely.
Impact

A stolen or leaked bearer token (via the error-disclosure oracle, the auth bypasses here, device theft, or the leaked signing key) stays valid for an extended period, survives logout and phone change, and cannot be invalidated short of manual DB edits — widening the takeover window and hampering incident response.

Remediation
Set short lifetimes via Passport::personalAccessTokensExpireIn()/tokensExpireIn() with refresh-token rotation. Add a logout endpoint that revokes the current token, and revoke all tokens for an account on phone change, PIN reset and status=inactive. Consider a per-request revocation check.
Verifier note

Confirmed no Passport:: configuration and no logout route; 'likely' as the default lifetime's exact value isn't asserted here.

AP-21

All GitHub Actions referenced by mutable tags/branches instead of pinned commit SHAs

P2 OWASP A08:2021 Software and Data Integrity Failures CWE-1357 CVSS 6.5 EPSS n/a confirmed
Description

No action or reusable workflow is pinned to an immutable commit SHA. checkout@v4 and setup-php@v2 float on major-version tags; laravel/.github@main is a moving branch. setup-php downloads and installs the PHP toolchain during the job, so a tampered tag runs attacker code on the runner.

Evidence
.github/workflows/tests.yml:28 -> uses: actions/checkout@v4 .github/workflows/tests.yml:31 -> uses: shivammathur/setup-php@v2 pull-requests.yml/issues.yml/update-changelog.yml -> uses: laravel/.github/...@main
Attack scenario
Attacker who controls a re-tag of shivammathur/setup-php@v2 injects a step that reads the runner environment and exfiltrates any secrets present in test jobs.
Impact

A compromised or maliciously re-tagged action executes attacker code on the CI runner with that job's GITHUB_TOKEN scope and secrets — build-artifact poisoning and code tampering across the pipeline, not just the privileged workflows.

Remediation
Pin every action to a full commit SHA with a trailing version comment; enable Dependabot for github-actions; turn on the repo setting requiring SHA-pinned actions.
Verifier note

Confirmed; lower than AP-18 because tests.yml has only contents:read and no pull_request_target.

AP-22

CI test gate is dead — workflow triggers on non-existent master branch while default branch is main

P2 OWASP A05:2021 Security Misconfiguration CWE-1053 CVSS 5.3 EPSS n/a confirmed
Description

tests.yml runs only on push to master or *.x (plus pull_request and a nightly cron). The repo's only branch is main, so push never fires for normal development, and the suite is just Laravel example stubs. There is effectively no automated security/quality gate validating commits to the shipping branch.

Evidence
.github/workflows/tests.yml:4-8 -> push: branches: [master, '*.x']; default/only branch is main (git branch -a) tests/ contains only default Laravel ExampleTest stubs (no real assertions) No dependency-scan job exists in any workflow
Attack scenario
A developer (or attacker via the mass-assignment bug) pushes a regression to main; CI never runs, so nothing flags it before deploy.
Impact

No build-integrity or test gate executes on the branch that is deployed, so regressions, dependency drift, or introduced vulnerabilities are not caught before merge. This is the systemic precondition amplifying every other supply-chain risk (nothing scans lockfiles on push).

Remediation
Change the push trigger to branches:[main] (keep *.x for releases). Add a real dependency-scan job (composer audit / Snyk / Dependabot) gated on PR and push to main and require it as a branch-protection check. Replace example stubs with meaningful tests.
Verifier note

Confirmed branch mismatch and stub-only tests.

AP-23

Twilio-failure path silently issues 'pending success' for admins, advertising the OTP backdoor

P2 OWASP A07:2021 Identification and Authentication Failures CWE-755 CVSS 5.3 EPSS n/a confirmed
Description

In sendOtp, if Twilio throws for any reason, admin accounts receive success:true with a prompt to enter the emergency OTP — the application advertises and steers admins to the static urgent_otp backdoor whenever the legitimate MFA channel is unavailable, and discloses Twilio status to unauthenticated callers.

Evidence
OtpController.php:63-72 -> catch(\Throwable $e){ if($account->registration_type==='admin'){ return success:true,status:'pending','Twilio is not working. Please enter emergency OTP' }} TwilioService.php throws on any send error — any outage/misconfig triggers the admin emergency-OTP branch
Attack scenario
Attacker probes /api/send-otp with candidate admin phones; a 'enter emergency OTP' response both confirms the number is an admin and signals the 982154 path is live.
Impact

Failure-open behavior that funnels admins to the backdoor and an oracle revealing when MFA is degraded; amplifies AP-04/AP-14 by making the fallback discoverable and reachable on demand (e.g. via SMS-pumping rate limits).

Remediation
Fail closed: on provider error return a generic error and do not advertise or enable any alternate code path; do not branch authentication behavior on registration_type in the send path.
Verifier note

Confirmed; distinct from AP-04 (the value) and AP-14 (the comparison) — this is the fail-open send behavior.

AP-24

Inconsistent role gating: listFullAccounts and listBrandsForPurpose leak cross-tenant data

P2 OWASP A01:2021 Broken Access Control CWE-863 CVSS 5.3 EPSS n/a confirmed
Description

Authorization is ad-hoc inline registration_type checks applied inconsistently. listFullAccounts (POST /api/all/accounts) does no role check and exposes the full partner-account set plus soft-deleted records. listBrandsForPurpose trusts a client-supplied account_id to scope, so a partner can supply another account's id to enumerate its venues. Other endpoints (index, listBrands) do gate by role, proving the model is not uniformly enforced.

Evidence
AccountsController.php:395-446 listFullAccounts() -> no Auth::user(), no role check; returns all registration_type=='accounts' records and supports onlyTrashed/withTrashed via 'deleted' param to any caller AccountsController.php:127-160 listBrandsForPurpose() -> scopes venues only by client-supplied account_id, so a partner can pass any account_id and enumerate another account's venues routes/api.php:16,24 -> /list/venues and /all/accounts in auth:api group, no role gate
Attack scenario
Partner POSTs /api/all/accounts {deleted:'with'} and receives every partner account including deleted ones, then targets them via AP-12/AP-05.
Impact

Horizontal information disclosure: partners can enumerate other partners' account lists (incl. soft-deleted) and venue associations, supporting reconnaissance for the higher-severity IDOR/escalation/bulk-delete attacks.

Remediation
Introduce centralized authorization (policies/gates + role middleware). Gate listFullAccounts to admin/subadmin. In listBrandsForPurpose, derive account_id from the authenticated partner rather than trusting the request.
Verifier note

Confirmed; the index() method's deleted-filter is commented out but listFullAccounts() exposes it live.

AP-25

Unauthenticated, replayable mutating endpoint POST /api/update/account (no auth, expiry, nonce, or rate limit)

P2 OWASP A04:2021 Insecure Design CWE-294 CVSS 5.8 EPSS n/a confirmed
Description

updateOffersCount is intentionally public, gated only by Crypt::decryptString of an offer_token. The payload carries issued_at but the code never checks it, so any captured token is replayable forever; there is no nonce, expiry, per-caller binding or rate limit. The recompute derives food_offers_count from server-side published-offer data (so it cannot be set arbitrarily), but the endpoint is fully replayable and leaks campaign existence/state to unauthenticated callers.

Evidence
routes/api.php:52 -> /update/account outside auth:api AccountsController.php:558-563 -> decrypts client offer_token; payload offer_id used directly AccountsController.php:552-557 -> commented mint helper shows payload {offer_id, issued_at} but issued_at is NEVER validated; no nonce/TTL/single-use AccountsController.php:566-583 -> distinct 'FoodOffer not found' / 'Campaign does not exists' / 'not published yet' responses leak offer existence/state
Attack scenario
Attacker captures one legitimate offer_token from a network log and replays it indefinitely, or (with APP_KEY) mints tokens for offer_id 1..N and reads the distinct responses to enumerate valid offers.
Impact

Unauthenticated, indefinitely-replayable state mutation of an account counter and an offer existence/state enumeration oracle. Full forgery if APP_KEY leaks (it has — AP-03). Low direct financial impact because the value is recomputed server-side.

Remediation
Require authentication or a dedicated HMAC webhook secret distinct from APP_KEY. Validate/enforce issued_at with a short TTL, make tokens single-use (consumed-nonce table), bind to the specific offer, rate-limit, and return a uniform response regardless of offer existence.
Verifier note

Confirmed; this is the design/replay view that complements AP-03 (the crypto/forgery view). Kept separate because the remediation (replay protection, TTL, nonce) differs from rotating APP_KEY.

AP-26

No idempotency / locking on approval state transitions (TOCTOU, duplicate notifications)

P2 OWASP A04:2021 Insecure Design CWE-367 CVSS 4.3 EPSS n/a likely
Description

Approval handlers check offer_status then later set account_status and notify, non-atomically with no row lock, transaction or idempotency key. Concurrent approve/reject requests for the same offer both pass the guard and both reach the notify block, producing duplicate Slack messages/emails and last-writer-wins races on account_status. The codebase contains zero concurrency control.

Evidence
CampaignController.php:203-234 updateAccountStatus -> read offer_status guard, then set account_status, save, then Slack + Mail — no DB::transaction, no lockForUpdate CampaignController.php:270-300 dedAccountStatus -> same read-then-write-then-notify with no lock grep app/ -> no DB::transaction, lockForUpdate, sharedLock, or idempotency anywhere
Attack scenario
Attacker fires two simultaneous /api/campaign/{id}/status requests (one Approve, one Reject) via Burp Turbo Intruder; both pass the not-Published guard, sending conflicting notifications and leaving an inconsistent record.
Impact

Duplicate approval/rejection notifications (Slack + email spam) and inconsistent final state under concurrent requests. Low direct impact here (no monetary double-spend path in this repo), but the absence of any locking becomes a double-spend vector if redemption logic is added to or shared with the influencer service's food_offers_users tables.

Remediation
Wrap each transition in DB::transaction with lockForUpdate on the offer row, re-check the guard inside the lock, and notify only when the value actually changed. Add an idempotency key for client-driven transitions. Audit the shared redemption path.
Verifier note

Confirmed no transactions/locks anywhere; 'likely' and downgraded to I:L since concrete impact in this repo is duplicate notifications, not double-spend.

AP-27

Hashids constructed with default empty salt as the obfuscation boundary for ids

P3 OWASP A02:2021 Cryptographic Failures CWE-330 CVSS 3.7 EPSS n/a confirmed
Description

All Hashids instances are constructed with no salt, using the library's public default. Hashids is not a cryptographic primitive, and with the default config the encoding is trivially reversible by anyone using the same defaults; sequential integer ids leak adjacency/ordering. The encoded campaign_id is exposed in API responses.

Evidence
CampaignController.php:88 -> $hashids = new Hashids(); (no salt) FoodOffersUsers.php and UsersReviews.php -> new Hashids() with no salt (per scanner) No config/hashids.php present (ls config | grep hashid -> none) => library default empty salt + default alphabet
Attack scenario
Attacker decodes a campaign_id with default Hashids settings to recover the integer offer id, then targets the unscoped per-object endpoints.
Impact

Opaque ids provide no real protection: an attacker can decode them to recover raw sequential database ids, aiding enumeration and the IDOR/BOLA attacks (AP-10/11/12). This is weak obfuscation, not authorization.

Remediation
If Hashids is used for obfuscation, configure a project-specific secret salt and minimum length via config/hashids.php loaded from env. Do not treat decoded ids as access control — enforce per-object authorization on every id-addressed endpoint (the real fix is AP-10/11/12).
Verifier note

Confirmed no salt and no config; low severity — the underlying routes consume the raw integer id anyway, so this only marginally aids enumeration.

AP-28

No secret-scanning / pre-commit controls and corrupted .gitignore vendor entry

P3 OWASP A05:2021 Security Misconfiguration CWE-1188 CVSS 3.1 EPSS n/a confirmed
Description

There is no automated guardrail (gitleaks/trufflehog pre-commit or CI) to prevent secrets entering the repo, and the .gitignore's vendor entry is byte-corrupted with interleaved null bytes so vendor/ is not actually ignored. This is the root-cause control gap behind the P0/P1 secret findings. NOTE: contrary to one scanner claim, the pattern '*.env' DOES match '.env' (git check-ignore -v .env -> matched by .gitignore:18:*.env), so .env itself is currently ignored — but it was already committed before that mattered.

Evidence
No .gitleaks.toml / .pre-commit-config.yaml (glob no matches) tests.yml triggers on master/*.x while active branch is main, and has no secret-scan job tail -c 80 .gitignore | od -c -> final line is 'v\0e\0n\0d\0o\0r\0/\0' (UTF-16/null-byte corrupted) so 'vendor/' is not matched Demonstrated: .env, oauth-private.key, Slack webhooks, .rnd all reached the repo
Attack scenario
A future commit adds another secret-bearing file; with no scanning it lands in history exactly as .env and oauth-private.key did.
Impact

Secrets will continue to be committable with no detection; the .env / key / webhook / .rnd leaks already demonstrate the gap. Low direct severity but it is the systemic enabler for the critical secret findings.

Remediation
Add gitleaks/trufflehog as a pre-commit hook and a CI job on push/PR to main. Repair .gitignore (clean vendor/, storage/*.key, .rnd, *.rnd entries). Generate a triaged gitleaks baseline only after rotating the exposed secrets.
Verifier note

Confirmed the null-byte corruption via od -c; corrected the scanner's incorrect '.env not matched' sub-claim (it is matched by *.env).

AP-29

OpenSSL entropy seed file (.rnd) committed to git

P3 OWASP A05:2021 Security Misconfiguration CWE-1188 CVSS 2.6 EPSS n/a confirmed
Description

A 1024-byte OpenSSL random seed file (.rnd) was committed. It should never be version-controlled. A stale 1KB seed has low direct value (the PRNG is reseeded from the OS CSPRNG), but its presence indicates openssl was run with $HOME pointed at the repo root during key/cert generation, raising the question whether other generated key material was produced in-tree (cf. AP-01).

Evidence
git ls-files lists .rnd as tracked file .rnd -> data (1024-byte OpenSSL RANDFILE seed) .gitignore does not exclude .rnd
Attack scenario
No realistic standalone exploit; flagged as a hygiene signal correlated with AP-01's in-tree key generation.
Impact

Minimal direct cryptographic impact; primarily a hygiene/artifact-leakage indicator that the key-generation process ran inside the working tree.

Remediation
git rm --cached .rnd and add .rnd / *.rnd to .gitignore. Audit the machine/CI where keys were generated; regenerate Passport/TLS keys if there is any doubt they were produced with this seed in-tree.
Verifier note

Confirmed tracked; lowest severity.

AP-30

No npm lockfile committed — JS dev dependencies float on caret ranges (non-reproducible builds)

P3 OWASP A08:2021 Software and Data Integrity Failures CWE-1104 CVSS 3.1 EPSS n/a confirmed
Description

No package-lock/yarn.lock/pnpm-lock is committed and all npm deps use caret ranges, so each npm install resolves to whatever latest in-range version exists, with no integrity hashes. This breaks reproducibility and removes lockfile-based integrity defense against a hijacked newer release. Mitigating factor: the JS toolchain is dev-only build tooling unused by the server API, so runtime API exposure is limited to the build/CI host.

Evidence
ls package-lock.json yarn.lock pnpm-lock.yaml -> all No such file or directory package.json devDependencies all caret ranges (axios ^1.7.4, vite ^6.0.11, tailwindcss ^3.4.13, etc.) CLAUDE.md: Vite/Tailwind scaffold is unused by the partner SPA (dev-only build tooling)
Attack scenario
Attacker publishes a malicious in-range patch of a transitive vite dependency; the next CI `npm install` pulls it and runs its install script on the runner.
Impact

Non-deterministic builds; a compromised in-range npm release could be silently pulled into any dev/CI environment running npm install, enabling build-time code execution / credential theft on that host. Absence of a lockfile also blocks automated JS dependency CVE scanning.

Remediation
Commit a package-lock.json and use npm ci in build steps. Enable Dependabot/Snyk on the lockfile. If the Vite/Tailwind scaffold is genuinely unused, remove package.json and the JS toolchain entirely to eliminate the attack surface.
Verifier note

Confirmed no lockfile; severity capped because the JS tree is unused at runtime.

Remediation roadmap

Immediate
(today, day 0)
Rotate-before-refactor — treat every committed secret as compromised:
  • AP-01: php artisan passport:keys --force; git rm --cached storage/oauth-private.key storage/oauth-public.key; deploy keys via env.
  • AP-02: regenerate the Twilio Auth Token, change both MySQL passwords, run php artisan key:generate (migrate Crypt data via APP_PREVIOUS_KEYS).
  • AP-03: APP_KEY rotation (above) revokes forged offer_tokens; stop using Crypt as an authz boundary.
  • AP-04 / AP-14 / AP-23: migration to null all urgent_otp values and remove the urgent_otp/fail-open branch; rotate admin credentials.
  • AP-08 / AP-09: rotate the Passport client secret and the seeded admin PIN (123456) on any account that ran the seeder.
  • AP-16: revoke both Slack webhooks in Slack; move to env.
This week
Close the unauthenticated and authorization-bypass attack paths:
  • AP-05 / AP-07 / AP-13: add role middleware + Policies; remove registration_type/status from mass-assignment; gate all delete/bulk/create routes to admin/subadmin.
  • AP-06: actually execute the upload validation, enforce a MIME allowlist, move uploads off the web root, disable PHP execution in the upload dir.
  • AP-15: apply throttle:api + per-route throttles to all OTP/PIN endpoints; add lockout.
  • AP-25: require auth or a dedicated HMAC secret on /api/update/account, add TTL/nonce/replay protection.
  • AP-17 / AP-19: force APP_DEBUG=false/APP_ENV=production in deploys; centralize exception handling to stop leaking $e->getMessage(); delete /test-db and /test-api.
  • AP-28 / AP-29: repair .gitignore, add gitleaks pre-commit + CI; remove .rnd; purge committed secrets from history (BFG/git-filter-repo).
This month
Build the missing authorization and token-lifecycle foundations:
  • AP-10 / AP-11 / AP-12 / AP-24: introduce a central Policy/Gate layer; scope every per-object query (campaign, venue, account, voucher) by owning account_id for partner role; derive ownership server-side instead of trusting request body.
  • AP-20: set short Passport token lifetimes with refresh rotation; add a logout/revocation route and revoke tokens on phone change / PIN reset / deactivation.
  • AP-18 / AP-21 / AP-22: pin all actions/reusable workflows to commit SHAs, drop unnecessary write scopes and pull_request_target, fix the CI trigger to main, add a dependency-scan gate.
  • AP-26: wrap approval transitions in DB::transaction + lockForUpdate; add idempotency keys.
Hardening
(ongoing)
Defense-in-depth and cross-repo correlation:
  • AP-27: configure a project-specific Hashids salt via config/hashids.php; never treat decoded ids as authorization.
  • AP-30: commit a lockfile and use npm ci, or remove the unused Vite/Tailwind toolchain entirely.
  • Add application audit logging of admin actions (bulk delete, approvals, emergency-OTP use) and alerting (A09 gap).
  • Correlate APP_KEY / DB / Twilio / Slack reuse and the urgent_otp backdoor across creators-website, tryalist and the other A-List repos; treat the shared parhaman_* DB as a single trust boundary.

Code quality

Run / deploy

Local setup

composer install
cp .env.example .env
php artisan key:generate
# Fill in the env vars below
php artisan passport:keys
php artisan passport:client --personal
# WARNING: only delta migrations are in this repo. Do NOT run migrate
# against a fresh DB — base tables (food_offers, venues, users, signups,
# users_reviews, food_offers_users, dedicated_offer_users, random_keys)
# are assumed to exist. They likely come from the creators/influencer app.
php artisan migrate
composer run dev   # serves http://127.0.0.1:8000 + queue + pail + vite

Environment

Deployment hints

No Dockerfile, no laravel/sail config (despite the dev dep), no deploy script, no fly.toml / render.yaml. The .github/workflows/tests.yml targets master — effectively disabled. The base DB schema living outside the repo strongly suggests deployment is coupled to a sibling repo's migrations. Confirm with Sachin where production runs (likely an EC2 / shared LAMP host given the public_path() upload pattern).

What to know before editing