Four flaws in an AI interview assistant's web/API stack
An authorized April 2026 audit of ParakeetAI found a cross-origin account-deletion chain, cross-account transcript injection, server-side request forgery, and a race in free-session creation. The founder engaged promptly; the April findings were remediated before the Q3 re-engagement.
Editor’s note on redactions: This is the public, post-embargo account of an authorized April 2026 assessment. It omits live session material, researcher identifiers, and runnable proof code; the client received the full private report and remediation guidance. The April web/API findings were treated as fixed before the Q3 re-engagement. This is a historical account, not a claim about the current state of a later release.
Target: ParakeetAI — an AI assistant for live interviews and meetings.
Disclosure status: Paid, authorized assessment. The April web/API findings were remediated before the Q3 re-engagement. A separate Q3 desktop finding was reported privately to the founder before this update; its public description below withholds the runnable deep-link payload and session material.
Executive summary
In April 2026, I assessed ParakeetAI’s web and API surface using only researcher-owned accounts and test data. Four findings mattered most: a credentialed null-origin CORS policy that turned ordinary browser isolation into account-level read and write access; an ownership check missing from one transcript-write path; a URL-fetch feature that trusted the typed hostname rather than the address it resolved to; and a race in free-session creation.
The worst chain was simple. A logged-in user could visit an attacker-controlled page; a sandboxed browser context with the null origin could make authenticated API requests and read the responses. One destructive mutation then allowed the account to be permanently deleted without a server-side re-authentication or confirmation challenge. I demonstrated the chain only against my own account.
This was not a “scanner found an outdated header” report. The work combined hand-driven traffic mapping, two-account authorization tests, controlled SSRF checks, and bounded concurrency tests. The useful result was a set of concrete fixes the founder could ship quickly, followed by a verification pass. The April web/API findings were remediated before the Q3 re-engagement.
| Application profile | Observed during the April assessment |
|---|---|
| Product | AI interview and meeting assistant |
| Web stack | Next.js application with tRPC API procedures |
| Authentication | Cookie-backed authenticated sessions |
| Test boundary | Researcher-owned accounts and non-destructive canary data only |
| Engagement | Authorized paid assessment and remediation retest |
Q3 update — a custom-protocol login-CSRF in the desktop app (High)
The April web/API work was the baseline for a Q3 review of the macOS Electron client. That review found a separate High-severity flaw in the desktop app’s parakeetai:// custom-protocol handler.
The purpose of a custom protocol is legitimate: after a browser login, an app can receive a callback and resume its own flow. The security property is equally important: the app must accept a login result only when it initiated that exact flow. ParakeetAI’s handler did not enforce that property.
It accepted a deep link containing a token-bearing payload, decoded the payload, and wrote the supplied value into the desktop app’s authenticated session cookie. There was no app-issued one-time state, nonce, signature, or pending-login binding. In effect, any delivered link could tell the app, “replace the current session with this account,” and the app trusted it.
I demonstrated the result with two researcher-controlled accounts: a desktop client already signed into account A silently switched into account B when given account B’s session material through the handler. No third-party account or data was touched.
The real-world consequence is session injection, not theft of the victim’s existing token. An attacker needs only their own account. A convincing web page can ask the browser to open the custom protocol; on a fresh attacker site, macOS presents a per-site confirmation. That user interaction keeps the issue at High, not Critical. The prompt identifies the app by an obscure internal name rather than “ParakeetAI,” and an “always allow” choice removes that friction for later attempts.
Once the desktop client has been switched into the attacker’s account, a victim may use it normally for an interview. Their transcript, AI assistance, recording, and uploaded CV then save into the attacker-controlled account rather than their own. That is a confidentiality failure in a workflow built around sensitive career information.
Fix: treat the deep link as a login callback, not as an authentication authority. Before the app opens the browser, it should generate and persist a cryptographically random one-time state value. The callback must carry that exact state; the desktop handler must reject missing, expired, reused, or mismatched state before it accepts any token. Both token-accepting actions must use the same validation—not only the ordinary login path.
The founder received the full Q3 report, including the exact affected paths and proof, privately. This public article deliberately does not reproduce the protocol payload or a working trigger while the desktop remediation status remains outside this article’s verification cutoff.
Method
- Mapped the authenticated application flow and its API procedures from normal product use.
- Replayed only researcher-account requests to identify where browser, authentication, and ownership controls differed.
- Used two accounts I controlled to test cross-account authorization, stopping once the proof was reproducible.
- Tested the URL-import feature with safe, controlled targets to validate SSRF defenses.
- Sent a bounded, synchronized burst against the free-session creation flow to test whether quota checks were atomic.
- Reported findings privately with remediation guidance and verified the April fixes before the next engagement.
Findings
F-01 — null-origin CORS turned browser isolation into authenticated API access (Critical)
The API accepted the browser’s special null origin while also permitting credentials. A sandboxed iframe can receive a null origin; treating that value as trusted lets an attacker-controlled page make authenticated cross-origin requests in a victim’s browser and read the result.
The browser facts are precise: requests carrying Origin: null received Access-Control-Allow-Origin: null and Access-Control-Allow-Credentials: true. The special null origin is assigned to contexts such as sandboxed iframes, local files, and data URLs. It is not a first-party origin and must never be treated as one.
That matters because the affected surface was not read-only. Authenticated tRPC procedures such as user.get and callSession.getMany were readable from that context, exposing account state and interview-session metadata. More importantly, the same policy covered mutations.
I confirmed that POST /api/trpc/user.delete?batch=1 accepted an authenticated, empty mutation body with no server-side re-authentication or confirmation challenge. That operation permanently deleted the account and its stored data. Together, the CORS defect and the destructive mutation created a zero-click account-deletion chain for a user who was already signed in.
The control matters: without the CORS failure, an attacker-controlled origin could not read the API response or issue credentialed cross-origin requests in the victim’s browser. The endpoint itself still warranted defense in depth, because a destructive API action should not rely on the browser’s same-origin policy as its final safeguard.
Fix: replace reflection-style CORS behavior with a strict allowlist of first-party origins; reject null; and require a server-validated, recent re-authentication or confirmation step for irreversible account actions. The CORS vector was closed during the engagement.
F-02 — A transcript-write procedure did not enforce session ownership (High)
One live-transcript creation path, callSession.transcription.createMany, checked that the target session existed and was active, but not that the caller owned it. Its request carried a callSessionId plus an array of transcript entries. With two accounts I controlled, account A could insert benign canary entries into account B’s active session even though the surrounding read, end, and delete operations correctly rejected cross-account access.
The denied-versus-allowed control pair made the defect unambiguous:
| Test | Result |
|---|---|
| A reads B’s transcripts | Denied: session not found |
| A ends B’s session | Denied: session not found |
A writes to B through createMany | Allowed: transcript entry saved |
| B reads B’s session | The canary entry is present |
This was not a guess based on predictable UUIDs. The test used a known session ID belonging to my second account and stopped once the write proved the ownership check was missing.
For an interview product, integrity matters as much as confidentiality: injected content can corrupt the record a candidate relies on and influence downstream AI summaries or suggestions.
Fix: enforce ownership at the write boundary, not just in neighboring API procedures, and audit every session-scoped mutation for the same invariant. The vulnerable procedure was removed as part of the remediation.
F-03 — URL fetching trusted a hostname before resolution (High)
The job-post import procedure, callSession.scrapeJobPost.scrape, fetched a user-supplied URL server-side and attempted to extract a company name and description. It blocked obvious private and metadata-service addresses when they appeared literally in the URL. That is only the first half of an SSRF control.
The procedure did not consistently validate the address reached after DNS resolution. A hostname can look public while resolving to a loopback, link-local, or private address. It also initially followed redirects without applying the same policy at every hop. The result was server-side request forgery conditions: the application could be induced to attempt requests to destinations that should never be reachable through a public URL-import feature.
The safe proof was response behavior only. Direct private-address inputs were rejected; controlled hostnames that resolved to blocked ranges and redirect chains were accepted by the initial validation and produced a server fetch result. I did not retrieve internal data, retain infrastructure details, or use the flaw for port scanning.
Fix: resolve hostnames before connecting; reject loopback, link-local, private, reserved, and otherwise non-public addresses; pin the validated address for the request; and repeat the validation on every redirect. The DNS-rebinding path was closed and the full redirect-aware validation requirement was included in the private remediation guidance.
F-04 — Free-session creation had a check-then-create race (High)
The free tier limited how frequently an account could create a session through callSession.create, but the cooldown check and session write were separate operations. A small synchronized burst let multiple requests pass the check before any one write made the new state visible.
In the bounded verification, 20 concurrent requests from one free account were released together. All 20 succeeded within a 220 ms window and each returned a distinct free-session identifier. Sequential requests had correctly hit the cooldown; concurrency was the only change. That difference separates a rate-limit configuration problem from a genuine check-then-create race.
The impact was both commercial and operational: free-tier limits could be bypassed, and concurrent session activation could consume shared transcription and model capacity.
Fix: make eligibility checking and session creation one atomic operation, backed by a transaction or a database-enforced invariant. Rate limits are not a security boundary when concurrent writes can all observe the same stale state.
Why these bugs travel together
None of the four findings depended on exotic exploitation. Each was a familiar product requirement implemented at the wrong layer:
- “Allow the app to call its API” became a dynamic browser trust decision.
- “Append a transcript line” became an existence check instead of an ownership check.
- “Fetch a job post from a URL” became a string blocklist rather than a network policy.
- “Give free users one session every 12 minutes” became a read, then a write, instead of one state transition.
AI-generated and fast-shipped SaaS often gets the happy-path shape right while omitting the invariant that makes the shape secure. A route returns the right JSON. A limit shows the right error in the UI. A blocklist rejects the obvious address. The important question is what happens one layer below: another origin, another account, a DNS lookup, or twenty requests at once.
The underlying pattern
| Root cause | Finding enabled | Durable control |
|---|---|---|
| Browser trust expressed as a broad CORS exception | F-01 | Explicit first-party origin allowlist; server-side protection for destructive actions |
| Authorization implemented inconsistently per procedure | F-02 | Central session-ownership check at every read and write boundary |
| URL validation performed on text, not the destination | F-03 | Resolve, classify, pin, and revalidate redirects |
| Quota check separated from the state change | F-04 | Transactional check-and-create or database constraint |
What made the engagement work
The founder engaged quickly, the assessment had a written authorization boundary, and remediation could be checked against the same researcher-owned accounts that established the initial proof. That is the shape I want more security work to take: clear scope, reproducible evidence, concrete fixes, and a verification pass—not vulnerability theatrics.
Disclosure timeline
| Date | Event |
|---|---|
| 2026-04-08 to 2026-04-20 | Authorized web/API assessment, reporting, and remediation retest |
| 2026-06-24 to 2026-06-25 | Q3 desktop assessment; custom-protocol session injection demonstrated on researcher-owned accounts and reported privately |
| 2026-08-17 | Public writeup released after the agreed embargo |
Research scope
All testing used accounts and data I controlled. I did not access another customer’s content, retain credentials, extract production data, or continue beyond the proof needed to establish each finding. This article describes historical, remediated assessment results and omits exploit payloads and sensitive implementation details.