Ten Architectural Weaknesses

The ten structural problems in the Phenom estate, ranked by risk, each with what it lets an attacker do, how reachable it actually is, and the change that fixes it.

Ten weaknesses, ordered by risk. Each one keeps three things separate on purpose: what it lets an attacker do, how reachable it actually is (stated apart from severity), and the change that fixes it.

Summary

ID Weakness Severity Root cause Component
W-1 MCP tool server has no authentication and holds the Hasura admin secret Critical RC-3 chat-mcp-server
W-2 One Cognito group membership mints Hasura superuser High RC-2, RC-3 Cognito, Hasura
W-3 CI has one identity, trusted on any ref, with account-wide secret reads High RC-4, RC-7 phenom-infra
W-4 Read scope is unbounded by default, and the default regenerates High RC-3 phenom-backend
W-5 Every password reset in the estate lands on one page in the public web origin High RC-1, RC-4 www
W-6 The public upload portal sits outside the edge, and so does the identity surface High RC-4, RC-5 DNS and edge
W-7 Credential material has no lifecycle owner High RC-9 PhenomApp and estate-wide
W-8 The gate named “Security Gate” does not read production Medium RC-7 phenom-infra CI
W-9 Nothing observes a security decision, and nothing could attribute one Medium RC-8 phenom-infra
W-10 Environment is a value inside a request, not a deployment Medium RC-4 nest-api

W-1

The MCP tool server has no authentication and holds the Hasura admin secret, behind a transport control it does not participate in

Critical on impact and defence in depth. Root cause: RC-3.

What is wrong. In phenom-infra/modules/chat-mcp-server/app/src/index.ts, the entire middleware stack at lines 63-64 is app.use(express.json()). Line 75 registers app.post("/mcp", ...) directly on the app with no guard in between. app/src/backends/hasura.ts:124 sends x-hasura-admin-secret on every call, and main.tf:103 supplies that value from Secrets Manager. Nine chat-moderation tools are registered, three of them destructive. All four facts verified at source.

How reachable it is. Not reachable from the public internet without transiting the mutual-TLS listener, and unsettled beyond that point. Stated precisely, because an earlier draft got this wrong in Phenom’s disfavour:

  • What bounds it. Every route to the target group passes aws_lb_listener.https, which requires a client certificate against a codified trust store. A direct request to the raw ALB with no client certificate does not reach the service.
  • What remains open. The reachable rule matches chat.thephenom.app, a proxied name, so the open questions are what the Cloudflare Access policy on that hostname enforces (see Q-2) and whether Cloudflare’s authenticated origin pull is the only holder of a trust-store certificate. Neither is answerable from source.
  • What the severity rests on instead. Impact and defence in depth. An unauthenticated tool server holding the credential that reads and writes both databases, with three destructive tools, has zero application-layer control of its own. The whole of its protection is a transport control enforced by a different team’s configuration on a different layer, and the service cannot tell whether that control is in place.

The estate built the mutual-TLS origin lock deliberately, documented it, and told itself not to revert it, and then routed an unauthenticated admin-secret-holding service behind it without adding a single application check. The control is a mechanism; the assumption that it is always there is a convention.

The fix. Add authentication middleware before the /mcp route registration at index.ts:75: a bearer check against a dedicated secret at minimum, a Cognito verifier reusing nest-api’s implementation preferably. Then give the server a named Hasura role scoped to exactly what the nine tools need, so the admin secret leaves this component entirely. The first change is hours; the second belongs with W-7.

Do not treat the origin lock as the fix. It is one Cloudflare configuration change and one listener-rule addition away from not covering this path, and security-origin-routing.tf:65 already routes three further hostnames to the same target group.

Remediation status, 2026-08-18: resolved. PR #261 (commit 2a6a125, closing issue #260) deletes this deployment rather than authenticating it, on evidence of thirty consecutive days of zero requests, and adds a fail-closed bearer check to the module so re-enabling it cannot reintroduce this weakness. Targeted applies ran against both environments before the merge, destroying nine resources in production and eight in development, and the post-merge plan in each reports No changes. Your infrastructure matches the configuration. No target group, ECS service, listener rule or log group matching the tool server remains. The Hasura admin secret in index.ts and the Worker’s permissive default are untouched, so both would need addressing before the module is ever re-enabled. Full evidence in MGR-C02.

The transport bound described above was re-probed on 2026-08-18 and still holds. A direct TLS connection to the production load balancer is dropped at the Request CERT step, so the listener still requires a trust-store client certificate.

W-2

One Cognito group membership mints Hasura superuser, and the artifact an operator edits says otherwise

High. Root causes: RC-2 and RC-3.

What is wrong. The production pre-token Lambda puts Hasura’s reserved superuser role into the allowed-roles claim for anyone in either of two Cognito groups. One of those two is the operations dashboard’s gate group, which cognito.tf:251 describes as “a pure label: no role_arn, no precedence”. A sibling comment in the same file asserts that only members of the other group get the superuser role, which is the opposite of what the file beside it does.

Concrete impact. An administrator adds a contractor to nest-access so they can see the operations dashboard, reading the comment that says the group is a pure label. The contractor’s next login mints a token whose allowed-roles contains admin, and a single GraphQL request selecting x-hasura-role: admin reads Matrix message bodies out of synapse.event_json. On the worse branch, it additionally reads every row of all 36 tracked application tables.

How reachable it is. Exploitable as deployed for any holder of the group, conditional on the group being non-empty. How many people hold nest-access is in no file, and it is the difference between High and Critical. Elevation is opt-in, which bounds it: the default role is user for everyone and a caller must explicitly request the elevated role, so this is not an accidental exposure.

The fix, none of which waits on the open question. Change index.js:121-122 to grant admin on the dedicated administrative group only, and decide separately what role the dashboard actually needs. Pin audience and issuer in the jwt_secret object at secrets.tf:29-34, which is one Terraform object and no application change. Then correct the comment at cognito.tf:251-254, because leaving four disagreeing documents in place regenerates the mistake: the operator adding somebody to a group reads the file that is wrong.

Sequencing trap. The pressure that produced this mapping is real. Staff need the superuser role because the moderator role cannot do the work, which is MGR-H10. Fixing the mapping without giving the moderator role its update permission moves the problem rather than removing it.

W-3

CI has one identity, trusted on any ref, whose secret reads are account-wide

High. Root causes: RC-4 and RC-7.

What is wrong. In phenom-infra/modules/ci-cd/main.tf, the OIDC trust condition at line 21 matches the repository followed by a wildcard, so any ref, branch, pull request or environment in that repository satisfies it. The role that trust admits holds read and write on the production infrastructure state bucket. The inline policy at line 103 grants secret-value reads and function-code updates on an unrestricted resource.

Concrete impact. Anybody who can push a branch to the repository can add a workflow that assumes the role. The environment protection on the production apply job constrains which job runs; it constrains nothing at the identity layer.

The fix, in one pull request. Constrain the sub condition at line 21 to specific refs and environments. In the same change, narrow the statement closing at line 103 to an explicit secret ARN list, because the checkov:skip justification at line 29 is about the writes and a future reviewer will read it as covering the reads. One Terraform condition plus one resource list. This closes two High findings and breaks the chain from a developer host to the whole AWS account.

W-4

Read scope is unbounded by default, and the default regenerates

High. Root cause: RC-3.

What is wrong. 47 of 112 Hasura permission blocks carry filter: {}. 41 of 63 select blocks are unscoped and 0 of 22 insert blocks are. Verified at source.

Concrete impact. Any authenticated user issues one GraphQL query against users and receives the email address, first name and last name of every account in the system, because public_users.yaml:48-67 grants role user those columns at :56-58 with filter: {} at :66. No exploit is required. It is the documented behaviour of the permission block, whose own comment at :67 describes it as basic profile information.

The fix, and the order is load-bearing.

  1. Land a CI assertion first. Fail any select permission with an empty filter on a non-public table, over hasura/metadata/databases/**/tables/*.yaml. Each of the 47 is individually a one-line change, but the 47 will regenerate as tables are added, because nothing requires a predicate and the Hasura default is {}. The assertion prevents the 48th; the 47 edits do not.
  2. Write down row ownership.
  3. Ship the mobile client before the server change. Removing the over-grant breaks five PhenomApp queries that depend on the over-fetch.
  4. Then replace the filters.

Remediation status, 2026-08-22: one column closed, the pattern untouched. The users.email over-grant called out in the impact paragraph above is fixed on phenom-backend develop and verified 8/8 against staging Hasura. It is held out of production behind PR #627 until PhenomApp 2.0.0 clears store review, which is step 3 of the order above being honoured rather than skipped. Mechanism and evidence in MGR-H11.

None of that closes W-4. This weakness is about the default, not about one column. Step 1, the CI assertion, is still not implemented, so the 47 blocks can still become 48. The count in step 3 is also low: the shipped client selects email in nine operations, not five.

Two dependencies neither prior roadmap recorded. One Low finding declares that its own Low rating depends on this High remaining unfixed, so closing this re-escalates it. Both dependencies were found by targeted grep rather than exhaustive reading, so treat the pair as a floor and expect more.

W-5

Every password reset in the estate terminates on one page in the public web origin

High. Root causes: RC-1 and RC-4.

What is wrong. All three Cognito pools send recovery mail whose click-through lands on one page in the public production origin. environments/development/locals.tf:31 and environments/production/locals.tf:34 set the identical production reset URL, and no configuration of this artifact produces a non-production link.

That page, www/web/reset-password/index.html, holds the complete recovery credential set in one DOM: the readonly email at :104, the six-digit code at :109, the new password at :115, its confirmation at :121. All four verified at line.

Line 74 of the same page loads a third-party analytics script with no integrity attribute and no crossorigin. There is no _headers file and no _middleware.js anywhere under www, and a content search for content-security-policy matches only two c2pa.wasm binaries, not a policy. The tag is on 20 pages under web/.

The identity being recovered on that page is the sole identity root behind Hasura, Matrix, the operations console, Cloudflare Access and the mobile client, and one group membership on it is W-2.

Concrete impact. A third party’s unpinned script, executing in the www.thephenom.app origin with no script-src bounding what may load and no connect-src bounding where it may send, reads the six-digit code and the new password out of the form on the page where every identity in the estate is recovered, including the staging pool that CI records was repopulated with the real users.

The fix, cheapest first.

  1. Add a web/_headers file to www scoped to /reset-password* with a script-src that excludes the third-party host and a bounded connect-src. One new file, no code change.
  2. Delete the analytics tag from that page. Analytics on a password form has no product justification.
  3. Give development its own reset host at environments/development/locals.tf:31. One line plus a CI parity assertion.
  4. Separately and independently: measure the DMARC and DKIM posture before recommending anything about it. The sending domain publishes SPF with a soft fail; the other two were never queried.

One reset interface was not reviewed. PhenomApp/app/app.tsx:152 registers a ResetPassword route. WSTG-ATHN-09 requires that all interfaces on the same site be reviewed. The estate has at least three and this one was not.

W-6

The public unauthenticated upload portal sits outside the edge, and so does the identity surface

High. Root causes: RC-4 and RC-5.

What is wrong. Five of 32 resolving hostnames reach an AWS origin with Cloudflare out of the request path, and drop.thephenom.app is one of them.

Concrete impact. The estate’s entire rate-limiting position is one cloudflare_ruleset in the http_ratelimit phase, and its expression matches http.host eq "chat.thephenom.app" conjoined with two Matrix login and register path prefixes. Verified at source. A request to the upload portal is not counted, and the rule would not have counted it anyway. It reaches an App Runner service that accepts anonymous file uploads from members of the public and stores media, full-precision coordinates, email and name, with neither the edge nor any rate limit in front of it.

How reachable it is. Not a vulnerability in itself. It is the removal of a compensating control, and the size of the loss depends on what the edge was enforcing, which is console-side and in no repository. Magnitude explicitly open.

The fix. Bring the five names into the zone as proxied records and declare all of them in Terraform, so the control follows the record. Where a platform requires a direct origin, put the control at the origin instead of assuming the edge. Then close the general case by replacing the two “instructions to a human” at environments/development/outputs.tf:58-60 and :68-70 with cloudflare_dns_record resources, so a new hostname cannot enter the estate ungoverned.

W-7

Credential material has no lifecycle owner

High, carrying five High findings. Root cause: RC-9.

What is wrong. PhenomApp/.claude/settings.local.json is tracked at HEAD carrying a live Cloudflare Access service-token pair and two private keys. git check-ignore -v exits 1 on that path: no rule covers it, despite PhenomApp/.gitignore:141-143 carrying *.key and signing.key under a comment naming a previous incident. The gate existed and did not hold.

The fix.

  1. git rm --cached PhenomApp/.claude/settings.local.json, add the path to .gitignore.
  2. Rotate the token in Cloudflare.
  3. Revoke every certificate issued against the leaked signing request.

Minutes of work plus one vendor conversation.

Then the class-level change. Replace the filename blocklist with a content allowlist that rejects any staged file containing a private key body or a high-entropy literal regardless of its name, because the *.key rule did not cover this path and never would have.

And write the rotation procedure. Roughly a dozen findings end in “rotate the credential” and there is no SECURITY.md in any first-party repository, no disclosure policy, no compromise runbook and no on-call definition.

This one has an open question that changes its severity. See Q-1: one read-only Cloudflare Zero Trust lookup decides whether this is High or Critical.

W-8

The gate named “Security Gate” does not read production

Medium as a control gap, and it amplifies every configuration weakness above. Root cause: RC-7.

What is wrong. In prod-infra-ci.yml, the checkov step is scoped to directory: modules/. Production lives in environments/. Verified as to the scope. 42 files sit outside the gate that is named for them.

The fix. Change directory: modules/ to cover environments/ in all three workflows (prod-infra-ci.yml, dev-infra-ci.yml, chat-ci.yml). Expect a large first-run failure list, and land it behind an expanded .checkov.baseline so new findings on the same checks still fail the build. That is the adoption pattern the estate already uses correctly. Pin the action to a commit SHA in the same change.

W-9

Nothing observes a security decision, and nothing could attribute one

Medium as filed, structural in effect. Root cause: RC-8.

What is wrong. Every figure verified: zero across eight detection resource classes, zero ALB access logging, zero Cognito threat protection, eight alarms into three topics with zero declared subscriptions, and 18 of 22 log-group resources resolving to 7 days with none at 90, against a README that states “90 days minimum” four times and marks it Implemented.

The fix, and the ordering is not optional.

Detection is worth building after W-1 and W-7, not before, and the reason is specific: you cannot alert on unusual use of the credential every service uses for everything, and a static shared secret carries no caller identity, so even a complete access log cannot attribute a request.

  1. Do W-1 and the named-service-principal half of W-7 first.
  2. Turn on WAF logging, because the WAF’s own promotion criterion is an observation window with no data source.
  3. Subscribe the three SNS topics, since eight alarms including the content-safety alarm currently deliver into nothing that any file declares.

W-10

Environment is a value inside a request, not a deployment

Medium. Root cause: RC-4.

What is wrong. phenom-backend/admin_sandbox/nest-api/src/index.ts:208-211 defines PROD_SURFACE_HOSTS as a set currently containing exactly nest.thephenom.app, and :213-227 defines effectiveEnv, which on a match swaps in the production Cognito pool, the production client-id allowlist, the production Hasura URL, the production Hasura admin secret and the production S3 bucket. Verified, read in full.

The fix. Two Worker scripts with two secret bindings, deployed separately, so a non-production deployment cannot hold a production secret at all. That removes the comparison rather than hardening it.

Set workers_dev = false in the same change, after moving the two Hasura event-trigger webhooks onto a zone hostname, in that order, because reversing it breaks the scheduled workflow. Those two webhooks are the reason the ordering matters and their target is committed in public_phenom_comments.yaml:111 and public_phenom_likes.yaml:76, both naming a *.workers.dev hostname. Verified. That off-zone hostname is not an accident, it is the designed production integration path, and the subdomain it sits on is an individual’s personal Workers subdomain rather than an organisation one.