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.
urgent_otp backdoor when Twilio is down..env leaks a live Twilio token / two MySQL passwords / the APP_KEY, a static shared admin OTP backdoor (982154) is reachable on the public unthrottled verify endpoint, and the API has effectively no authorization layer.| Category | Technology | Version | Notes |
|---|---|---|---|
| Language | PHP | ^8.2 | composer.json line 9 |
| Framework | laravel/framework | ^11.31 | Current LTS-era release; deps fresh |
| Auth | laravel/passport | ^13.0 | OAuth2 + personal access tokens; auth:api driver |
| SMS / OTP | twilio/sdk | ^8.7 | Twilio Verify v2 service |
| ID obfuscation | vinkla/hashids | ^12.0 | Encodes campaign IDs for public URLs |
| Notifications | laravel/slack-notification-channel | ^3.6 | Imported but actual Slack calls bypass it and use raw Http::post |
| Build | Vite + Tailwind | 6 / 3 | Skeleton only; this repo serves no HTML |
| Tests | PHPUnit | ^11.0 | Two default ExampleTest stubs; no real coverage |
| DB | SQLite (dev) / MySQL (prod assumed) | — | Only delta migrations from 2025-09 onward — base schema lives in a sibling repo |
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).
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.
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-deserializationtesting-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-authorizationtesting-jwt-token-security testing-for-json-web-token-vulnerabilities exploiting-jwt-algorithm-confusion-attack testing-oauth2-implementation-flaws exploiting-oauth-misconfiguration testing-api-authentication-weaknessesimplementing-secret-scanning-with-gitleaks detecting-aws-credential-exposure-with-trufflehog performing-cryptographic-audit-of-application testing-for-sensitive-data-exposureexploiting-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-injectionperforming-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-prioritizationtesting-for-business-logic-vulnerabilities exploiting-race-condition-vulnerabilities
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.
.env with live Twilio/MySQL/APP_KEY, tracked Passport oauth-private.key, hardcoded Slack webhooks, hardcoded OAuth client secret in a seeder, and a committed .rnd — rotate-before-refactor applies here too. Correlate APP_KEY/DB/Twilio reuse across creators-website / tryalist / other repos.registration_type string checks rather than Policies/Gates/middleware — audit sibling repos for the same missing object/function-level authorization pattern..gitignore, and CI workflows that trigger on a non-existent master branch — verify whether the master-vs-main dead-CI and unpinned laravel/.github@main workflows are copied across all repos.try/catch(\Throwable) returning $e->getMessage() and leftover /test-db, /test-api debug routes look like a shared scaffolding pattern; check other repos for the same error-leak and debug-route habit.| Category | Status | Notes |
|---|---|---|
| A01:2021 Broken Access Control | vuln | Pervasive: 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 Failures | vuln | Committed 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 Injection | clean | All 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 Design | vuln | Unauthenticated 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 Misconfiguration | vuln | .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 Components | clean | composer 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 Failures | vuln | Static 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 Failures | vuln | pull_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 Failures | partial | No 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 Forgery | n/a | No 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-ref | vuln | API1 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. |
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.
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.
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.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.
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.
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.
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.Confirmed via git show on both commits; identical APP_KEY in both versions ties this to AP-03.
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.
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.
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.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.
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.
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).
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.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.
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.
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.
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().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.
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.
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.
$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.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.
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.
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.
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.Confirmed across both controllers; merged the account-bulk and campaign-bulk findings into one BFLA entry.
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.
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.)
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.Confirmed in seeder; no hashClientSecrets anywhere. Scored just under AP-01 because the raw private key (AP-01) is the more direct forgery primitive.
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).
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.
Confirmed; PIN is hashed at rest (good) but the value is committed and shared, defeating the hashing.
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).
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.
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.Confirmed; merged the access_control and business_logic copies of this finding.
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.
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.
Confirmed; venueFiles are served from public web root, so document exfil is direct.
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.
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.
AccountPolicy::view + Gate/can(), or scope the query by the authenticated account id for partner role.Confirmed; sensitive pin/urgent_otp are $hidden so confidentiality is High not Critical.
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.
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.
Confirmed; impact I:H, C:L (creates an account, limited read).
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.
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).
hash_equals; add rate limiting/lockout.Confirmed; overlaps AP-04 but captures the loose-== and fail-open design issues separately for remediation tracking.
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).
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.
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.Confirmed empty withMiddleware and zero throttle usage; merged the two raw rate-limit findings (auth_jwt + business_logic).
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.
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.
SLACK_CAMPAIGN_APPROVE_WEBHOOK) referenced via config; never commit. Purge from history. Add secret-scanning push protection.Confirmed; merged the three duplicate scanner entries (secrets_crypto + supply_chain) into one.
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.
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.
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.Confirmed defaults; 'likely' because actual prod override is unverified, but the committed .env also had APP_DEBUG=true, raising the odds.
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.
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.
pull_request and never run untrusted PR head code in a privileged context. Enable the repo setting requiring SHA-pinned actions.Confirmed exact triggers/permissions/refs in all three workflow files.
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.
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.
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.Confirmed across all four controllers and web.php; merged the four duplicate scanner entries (secrets_crypto/injection/auth_jwt/ssrf).
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.
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.
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.Confirmed no Passport:: configuration and no logout route; 'likely' as the default lifetime's exact value isn't asserted here.
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.
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.
Confirmed; lower than AP-18 because tests.yml has only contents:read and no pull_request_target.
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.
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).
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.Confirmed branch mismatch and stub-only tests.
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.
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).
Confirmed; distinct from AP-04 (the value) and AP-14 (the comparison) — this is the fail-open send behavior.
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.
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.
Confirmed; the index() method's deleted-filter is commented out but listFullAccounts() exposes it live.
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.
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.
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.
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.
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.
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.Confirmed no transactions/locks anywhere; 'likely' and downgraded to I:L since concrete impact in this repo is duplicate notifications, not double-spend.
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.
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.
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).Confirmed no salt and no config; low severity — the underlying routes consume the raw integer id anyway, so this only marginally aids enumeration.
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.
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.
vendor/, storage/*.key, .rnd, *.rnd entries). Generate a triaged gitleaks baseline only after rotating the exposed secrets.Confirmed the null-byte corruption via od -c; corrected the scanner's incorrect '.env not matched' sub-claim (it is matched by *.env).
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).
Minimal direct cryptographic impact; primarily a hygiene/artifact-leakage indicator that the key-generation process ran inside the working tree.
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.Confirmed tracked; lowest severity.
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.
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.
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.Confirmed no lockfile; severity capped because the JS tree is unused at runtime.
php artisan passport:keys --force; git rm --cached storage/oauth-private.key storage/oauth-public.key; deploy keys via env.php artisan key:generate (migrate Crypt data via APP_PREVIOUS_KEYS).urgent_otp values and remove the urgent_otp/fail-open branch; rotate admin credentials.registration_type/status from mass-assignment; gate all delete/bulk/create routes to admin/subadmin.throttle:api + per-route throttles to all OTP/PIN endpoints; add lockout./api/update/account, add TTL/nonce/replay protection.APP_DEBUG=false/APP_ENV=production in deploys; centralize exception handling to stop leaking $e->getMessage(); delete /test-db and /test-api..rnd; purge committed secrets from history (BFG/git-filter-repo).main, add a dependency-scan gate.DB::transaction + lockForUpdate; add idempotency keys.config/hashids.php; never treat decoded ids as authorization.npm ci, or remove the unused Vite/Tailwind toolchain entirely.ExampleTest stubs (tests/Feature/ExampleTest.php, tests/Unit/ExampleTest.php).CampaignController::dashboard (lines 522-747) is a 225-line method that runs ~8 separate queries, builds weekly aggregates with PHP-side filter() instead of SQL, and computes profile_completion inline. Needs splitting into a query class + a resource.try { ... } catch (\Throwable $e) wrappers that return $e->getMessage(). ~30 occurrences. Defeats Laravel's exception handler entirely (see AP-19).$request->validate() with skeleton rules; no FormRequest classes; no centralised messages; rules drift between store and update within the same controller (compare AccountsController.php:35-47 vs :193-204).auth:api. Role checks are repeated inline (if ($user->registration_type === 'accounts')) in ~15 places — easy to forget one (and indeed several endpoints have none, see AP-05/07/10/11/12).with('venue.category')) correctly. updateOffersCount reloads FoodOffers::with('venue.accounts')->find() twice in a row (lines 565 + 571) — dead duplicate query.UsersExport/Maatwebsite\Excel imports unused; commented DB::raw dashboard query block; VenuesController::edit empty stub; VenuesController::destroy empty stub but route doesn't reach it.composer.lock last touched 2025-11-06.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
APP_KEY, APP_URL, APP_ENVDB_CONNECTION (default sqlite in .env.example — production will be MySQL; DB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD required)TWILIO_SID, TWILIO_TOKEN, TWILIO_FROM, TWILIO_VERIFY_SERVICE_SID (mandatory — TwilioService throws at boot if missing)PASSPORT_PRIVATE_KEY, PASSPORT_PUBLIC_KEY (or run passport:keys)MAIL_* (SMTP for PIN delivery + campaign status emails)SLACK_BOT_USER_OAUTH_TOKEN, SLACK_BOT_USER_DEFAULT_CHANNEL (used by laravel/slack-notification-channel but no notification class actually uses it; real Slack writes hit hardcoded webhooks)AWS_* declared but no S3 usage in code — uploads go to local public/assets/uploadsGET /upNo 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).
.env (Twilio token, two MySQL passwords, APP_KEY), the Slack webhooks, the OAuth client secret and the admin PIN/urgent_otp are all compromised. Do this before any refactor (see the Immediate roadmap phase).food_offers, venues, users, signups, users_reviews, food_offers_users, dedicated_offer_users, random_keys are assumed to exist and almost certainly come from a sibling project (likely the creators/influencer backend). Never run migrate:fresh against a real environment.App\Models\User is the default Laravel skeleton and is the influencer side of the relation graph; App\Models\Account is the only one that authenticates here. AuthServiceProvider::boot() silently overrides the api guard provider — easy to miss when debugging auth.registration_type strings applied inconsistently and absent on the most dangerous endpoints (AP-05/07/10/11/12). Assume any authenticated token can reach anything until Policies/Gates are added.POST /api/update/account endpoint exists. When refactoring auth, don't accidentally pull it under auth:api without coordinating with the public site that calls it — but also do not leave it where it is for long (AP-03/AP-25).intervention/image isn't manually installed on the server — verify before deploying a venue-creation change. Note the file-upload paths that bypass Image execute the move regardless (AP-06)./api/validate/pin server-side — the front-end gates the UI. Anyone with a Passport token can call them directly.SoftDeletes trait + deleted_at), but venue listing in listBrandsForPurpose does not filter on account_id trashed state, and trusts a client-supplied account_id (AP-24).master; default branch is main. Anything you push will not be tested (AP-22).