{"slug": "we-hired-a-security-engineer-and-got-back-123-findings", "title": "We hired a security engineer and got back 123 findings", "summary": "Profullstack's first security engineer, Eduardo Camarillo, identified 123 confirmed vulnerabilities across the CoinPay Portal and QryptChat repositories, filed as 63 GitHub security advisories, including critical issues such as unauthenticated remote data destruction and exposure of all users' phone numbers. The fixes were deployed across roughly a dozen pull requests and ten database migrations, with all advisories now published.", "body_md": "# We hired a security engineer and got back 123 findings\n\n*2026-08-16, by Anthony “chovy” Ettinger.*\n\n**How this was written:** drafted with an AI assistant from the advisory\nrecords, the pull requests, and my own notes taken while doing the remediation. Every count in this\npost came out of the GitHub advisory API and the Postgres catalogs, not out of memory. The\nadvisories themselves are private to the repos, so you'll have to take the numbers on\nfaith.\n\n## The setup\n\nEduardo Camarillo joined Profullstack this month as our first dedicated security engineer. I gave\nthem two repositories and no scope restrictions: **CoinPay Portal**, our self-hosted\ncrypto payment processor, and **QryptChat**, our post-quantum encrypted messenger. Both are\nlive. Both handle exactly the kind of thing you do not want handled badly — one moves money,\nthe other is supposed to be the app where the contents are nobody's business but yours.\n\nI expected a report. What arrived over about seventy-two hours was a filing cabinet.\n\n## The count\n\nAcross the two repositories, **123 confirmed findings**, tracked as 63 individual\nGitHub security advisories:\n\n**CoinPay Portal**— 86 confirmed findings: 3 critical, 39 high, 29 medium, 15 low. The critical and high ones were filed individually; the mediums and lows arrived as a single umbrella “remediation list” document. Three more critical/high reports landed afterward, separately.**QryptChat**— 37 vulnerabilities plus 37 architectural deficiencies, filed as 17 advisories: 2 critical, 11 high, 4 medium.\n\nAs of today all 63 advisories are `published`\n\n, which on GitHub is the terminal state\nfor a fixed advisory — there is no separate “closed.” The fixes went out across\nroughly a dozen pull requests and about ten database migrations applied to production.\n\nI want to be honest about the shape of that number, because “123 vulnerabilities” is\nthe kind of headline that is usually doing marketing work. A meaningful fraction of these were\n*latent* — real defects in the code or the schema, genuinely exploitable under a\nplausible near-future configuration, but not reachable in production as it was deployed that\nmorning. More on why that distinction nearly tripped me up in a minute. A handful of others were\nduplicates of one root cause across a family of call sites. What is not negotiable is the other\nend: some of these were live, unauthenticated, and bad.\n\n## The five that made my stomach drop\n\nRanked by how long I stared at the screen after reading the title.\n\n**Unauthenticated remote data destruction.** QryptChat had a\n`SECURITY DEFINER`\n\nPostgres function named `delete_encrypted_data_only`\n\n,\ncallable by the `anon`\n\nrole. Any person on the internet with the public API key —\nwhich ships in the browser bundle, because that is what a public anon key *is* — could\ninvoke it. That is not a vulnerability so much as an unlabeled button wired to the building's\ndemolition charges. It was one of 48 `SECURITY DEFINER`\n\nfunctions in the public schema\nreachable by `anon`\n\n. All 48 are now unreachable, verified by querying\n`has_function_privilege`\n\nover `pg_proc`\n\nrather than by trusting the\nmigration.\n\n**Every user's phone number, readable by every user.** QryptChat's row-level\nsecurity policy on the users table was `USING (true)`\n\n. Any authenticated account could\nread every row: phone numbers, identifiers, the lot. It is now own-row OR\n`shares_conversation_with(id)`\n\n. The busiest account on the platform went from being able\nto see all 86 users to seeing 14.\n\n**Unauthenticated identity spoofing.** A `sync_user_with_auth`\n\nRPC\naccepted a phone number and overwrote the record. No authentication.\n\n**Hardcoded service-role key fallbacks.** On CoinPay Portal, the specific literal\nEduardo cited had already been removed — but the pattern hadn't. Sixteen *other* files fell\nback to the anon key or an empty string when the service-role key was missing. Falling back to anon\ndoes not throw. It silently downgrades, RLS begins applying to code written on the assumption that\nit does not, and the symptom surfaces three layers away from the cause a week later. Every one of\nthem now routes through a client that refuses to construct without a real key.\n\n**13,711 rows of payment amounts readable by anon.** A CoinPay Portal\nreputation-receipts table. This one is interesting specifically because almost all of its\nneighbours in the report were\n\n*not*live, and I only know which was which because I stopped reading policies and started assuming roles. Which brings me to the actual point of this post.\n\n## Four things I learned that generalize\n\nThe findings themselves are ours. These four are yours too, if you run Postgres behind an app.\n\n### 1. Column-level REVOKE is a no-op against a table-level grant\n\nSupabase grants table-level `SELECT`\n\nto `anon`\n\nand\n`authenticated`\n\n. If you then write ```\nREVOKE SELECT (secret_column) ON users FROM\nauthenticated\n```\n\n, you have subtracted nothing, because the table-level grant already permits\nevery column including that one. It applies cleanly. It reports success. It does nothing.\n\nI shipped that mistake and caught it only by checking\n`has_column_privilege()`\n\nafterwards. To actually restrict a column you must revoke the\ntable grant and then grant back the specific columns you want. Never assume a REVOKE landed\n— ask the database whether the privilege is still there.\n\n### 2. Verify RLS findings by assuming the role, not by reading the policy\n\nThe technique that made this audit tractable: wrap the check in a `DO`\n\nblock,\n`set local role anon;`\n\n, attempt the read, then `raise exception`\n\nto roll the\nwhole thing back and carry the result out in the error message. It is safe to run against\nproduction.\n\nDoing this on CoinPay Portal showed that most of the RLS findings were latent. The app\nauthenticates with its own JWT and talks to the database as `service_role`\n\n, so\n`auth.uid()`\n\nis never populated, and every policy keyed on it denies *everyone*.\nThe policies were wrong, they needed fixing, and a future refactor would have detonated them —\nbut they were not currently leaking. That same check isolated the one finding that genuinely was.\nHad I skipped it I would have announced “11 vulnerabilities fixed” and been wrong in\nboth directions at once: overstating the exposure of ten, and burying the one that mattered.\n\n### 3. Revoking EXECUTE can cause a silent write outage — check pg_trigger first\n\nThis is the expensive one, and the reason I am writing this post rather than just closing the advisories.\n\nIn the sweep that locked down those 48 functions, we revoked `EXECUTE`\n\non\n`calculate_message_expiration`\n\n. Nothing called it directly. What called it was a\n`BEFORE INSERT`\n\ntrigger on the messages table — and that trigger was the only one\non the table not marked `SECURITY DEFINER`\n\n, so it executed with the privileges of\nwhoever was inserting. Permission denied. The trigger raised. The raise took the entire INSERT down\nwith it.\n\n**Message sending on QryptChat was dead for two days** and nothing alerted. It\nsurfaced as an application 500, and I found it by noticing the newest row in the table was 08-13\nwhen it was the 16th. The fix was to make the trigger function `SECURITY DEFINER`\n\n,\nmatching its sibling, rather than granting the function back.\n\nThe general rule: before any EXECUTE revoke, join `pg_trigger`\n\nagainst the functions\nyou are about to revoke and check `prosecdef`\n\non each trigger's function. A hardening\nsweep that takes production down teaches your team that hardening sweeps take production down, and\nthat lesson is much harder to unlearn than the CVE was to fix.\n\n### 4. A missing RPC leaves no trace in your database logs\n\nWe merged the CoinPay Portal remediation PR before applying its migration — about a twenty minute gap. In that window, production was calling a database function that did not exist yet, on every single payment creation. It fails closed with a 500.\n\nHere is the part worth knowing: **PostgREST rejects a call to an unknown function before it\never reaches Postgres**, so it produces *no* entry in `postgres_logs`\n\n. Quiet\ndatabase logs are not evidence that a schema/code mismatch is harmless. Nothing hit that path in\nthose twenty minutes, and that was luck, not design. On a repo like this the migration is not\noptional and not deferrable: merge and apply together, then\n`notify pgrst, 'reload schema';`\n\nso the API's cache picks the function up.\n\n## What we deliberately did not fix\n\nAn audit report is a set of claims, not a set of orders. A few items on the CoinPay Portal list were closed without a code change, and I think saying so publicly is more useful than a clean scoreboard:\n\n**Arbitrary payee wallet addresses.** Flagged as a vulnerability; it is the product. Paying an invoice to a third party is a real flow. We hardened it instead — the platform's own wallet can't be targeted, and the authorizing actor is recorded on every invoice — rather than requiring the payer to own the destination.**A public-lookup function reported as an IDOR.** It returns public profile columns and is a lookup by design. Noted in the code so nobody “fixes” it again in six months.**A dependency CVE with no upstream fix.** The vulnerable package was newest published; there was nowhere to upgrade to. It turned out to be reachable only through four declared wallet-adapter dependencies that*nothing in the codebase imported*— they were referenced by one line of build config. Deleting them removed the CVE, and about a dozen transitive packages with it.\n\nThat last one is my favourite finding of the whole audit, and it isn't even a vulnerability. The fastest way to fix a dependency you can't patch is frequently to discover you were never using it.\n\n## Was it worth it?\n\nWe took a two-day messaging outage, I spent a week doing nothing but remediation, and the test suite needed 21 tests rewritten because they had been quietly relying on a service client that never got constructed.\n\nAgainst that: an unauthenticated data-destruction endpoint, a table of payment amounts open to the world, and every user's phone number one query away from every other user — all found by someone reading our code on purpose, before they were found by someone reading our code on purpose.\n\nThe thing I did not anticipate is how much of the value was in the second-order lessons. Nobody files an advisory titled “your REVOKE statements are decorative.” You only learn that by doing the remediation with a paranoid person looking over your shoulder, and then checking the catalogs instead of believing the migration output.\n\nWelcome aboard, Eduardo. Please give me a week before you start on the next repo.\n\nCounts in this post were pulled from the GitHub security advisory API for\n`profullstack/coinpayportal`\n\nand `profullstack/qryptchat-web`\n\non 2026-08-16\n(46 and 17 advisories respectively), and from the remediation list enumerated in each repo's\numbrella advisory. The advisories are private to the repositories. CoinPay Portal is at\n[coinpayportal.com](https://coinpayportal.com/) and QryptChat at\n[qrypt.chat](https://qrypt.chat/).", "url": "https://wpnews.pro/news/we-hired-a-security-engineer-and-got-back-123-findings", "canonical_source": "https://dev.profullstack.com/~anthony/blog/004-post.html", "published_at": "2026-08-16 04:46:12+00:00", "updated_at": "2026-08-16 05:10:23.699982+00:00", "lang": "en", "topics": ["ai-tools"], "entities": ["Profullstack", "Eduardo Camarillo", "CoinPay Portal", "QryptChat", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/we-hired-a-security-engineer-and-got-back-123-findings", "markdown": "https://wpnews.pro/news/we-hired-a-security-engineer-and-got-back-123-findings.md", "text": "https://wpnews.pro/news/we-hired-a-security-engineer-and-got-back-123-findings.txt", "jsonld": "https://wpnews.pro/news/we-hired-a-security-engineer-and-got-back-123-findings.jsonld"}}