On this page
Backend security review, November 2026
An independent, read-only review of the backend carried out on 2026-11-10 against the
main branch before the hardening work. It was asked to construct concrete attacks on
money, sessions, authorization, the promotion gate and untrusted input rather than rely
on the existing tests. Four findings were reproduced against a running development
backend; the others were traced through the code.
Status of the findings
All high and medium findings were fixed in the same release cycle, each with a
regression test that fails without the fix. The tests are named in
docs/status/WP-70a.md and
docs/status/WP-70b.md.
| Finding | Status |
|---|---|
| HIGH-1 · single maintainer could fund rewards alone | Fixed: the proposer never counts as an approver; one reward per contribution |
| HIGH-2 · service accounts could satisfy two-person rules | Fixed: approval decisions require a human principal; the most sensitive require an interactive session; service accounts never hold approval permissions |
| HIGH-3 · recurring Stripe refunds and disputes never reached the ledger | Fixed: invoice payments are linked to their payment intent and charge |
| HIGH-4 · ambiguous payout errors could lead to double payment | Fixed: stable idempotency key per obligation; explicit unknown state until resolved |
| HIGH-5 · token scopes ignored on ownership checks | Fixed: ownership paths go through the self-scope permission check |
| HIGH-6 · OAuth state not bound to the browser | Fixed: state is bound to a short-lived cookie |
| MEDIUM-1 · release-shaped tags of other versions | Fixed |
| MEDIUM-2 · stale evaluation on resume | Fixed |
| MEDIUM-3 · submission rate limit spent before authorization | Fixed (REST and gRPC) |
| MEDIUM-4 · refunds not reserved | Fixed |
| MEDIUM-5 · unverified GitHub repository id | Fixed for new links; links verified earlier are not re-checked |
| MEDIUM-6 · settlement read eligibility before locking | Fixed |
| MEDIUM-7 · embargoed ids in the public scan-gate message | Fixed |
| LOW-1 to LOW-6 | Fixed |
Not covered by this review
Registry retention, the scanner, SBOM and VEX parsers, the organizations domain, gRPC parity for write operations and the web application's content security policy were not reviewed. They remain to be covered by a later review.
Original report
The report below is reproduced as written by the reviewer. Account names refer to the development seed data.
CommonLayer security review (main, 2026-11-10)
Scope: read-only review of /home/user/commonlayer on main, as the tree stood during the review. Other engineers were editing the tree at the same time, so references give file paths and function names. Threat-model open items O1–O13 are not repeated here. Where a finding is close to one of them, the overlap is stated.
Evidence notes: "Demonstrated" means it was reproduced against the running development backend (http://localhost:8080, seeded accounts). The demonstration scripts were throwaway and are not part of the repository. State created by the experiments was cleaned up where the API allows it: rewards cancelled, service accounts deactivated. The 1 USD test contribution by uma stays refunded. The read-only token clt_691e83d1… belongs to uma and expires after one day. "Traced" means the code path was followed end to end but not executed.
Summary: 0 critical, 6 high, 7 medium, 6 low (19 findings). 15 are confirmed: 4 demonstrated (HIGH-1, HIGH-2, HIGH-5, MEDIUM-3) and 11 fully traced. 4 are plausible: MEDIUM-4, MEDIUM-6, MEDIUM-7 and LOW-2. Each is marked in place. The section headings below group them by severity.
Confirmed findings
HIGH-1 · One maintainer can pay any account from the flexible pool alone, as many times as they like
Demonstrated.
- Files and functions:
crates/commonlayer-funding/src/domain/reward.rs(check_approver,completes),crates/commonlayer-funding/src/service/rewards.rs(propose_reward,decide_reward), triggercommunity_reward_approvals_checkand tablecommunity_rewardsincrates/commonlayer-database/migrations/0010_funding.sql, andcrates/commonlayer-maintenance/src/service/contributions.rs(submit_contribution,review_contribution). - How it happens:
- With
required_approvals = 1(amount ≤reward_single_approval_max_minor, $100), the proposer may approve their own proposal. The unit test even asserts "one approval: the proposer may approve". - Nothing limits how many rewards one approved contribution can carry. There is no unique constraint and no aggregate cap.
- Any signed-in user can submit a contribution. Any maintainer who is not the contributor can approve it.
- With
- Attack:
- Maintainer M creates a sock-puppet account S, and S submits a contribution.
- M approves the contribution.
- M proposes a $100 reward for S and approves it, then repeats.
- Each round posts
flexible_pool → obligationswith a pending obligation payable to S, until the pool is empty.
- Threat-model gap: O6 describes this as needing two colluding members. It needs one person, and repeated rewards on one contribution are unbounded.
- Evidence: as
nina(nginx maintainer), two $1 rewards forcolin's single approved contribution were proposed and self-approved. Both reachedstatus: fundedwithrequired_approvals: 1, approvals: 1. Script:nina-reward.sh. Both rewards were cancelled afterwards. - Fix:
- Never let the proposer count as an approver. In
check_approver, drop therequired_approvals >= 2condition and mirror the change in the trigger. - Allow at most one funded reward per contribution: a partial unique index on
community_rewards(contribution_id) WHERE status IN ('proposed','funded','paid'). Alternatively, cap the total per contribution. - Consider a per-recipient monthly cap.
- Never let the proposer count as an approver. In
HIGH-2 · Every four-eyes rule inside a project can be met by one project admin through project service accounts
Demonstrated (rewards); traced (security exceptions).
- Files and functions:
crates/commonlayer-auth/src/service/project_service_accounts.rs(create_project_service_account,create_project_service_account_token),crates/commonlayer-funding/src/service/rewards.rs(decide_reward,approver_role),crates/commonlayer-security/src/service/triage.rs(decide_exception),crates/commonlayer-core/src/authz/actor.rs(Actor::can,require_user). - Why it works:
- A project admin can create service accounts with role
project_adminormaintainerand give themfull-scoped tokens. decide_rewardanddecide_exceptiononly compare user ids, andrequire_user()returns the service account's id.- For service accounts,
approver_rolederivesProjectAdminfrom the account'ssa_role.
- A project admin can create service accounts with role
- Attack (reward, demonstrated):
pat(nginx project admin) created aproject_adminservice account and amaintainerservice account, each with afulltoken.patproposed a $150 reward, which needs two approvals including one from an admin.- The two service-account tokens approved it. Result:
status: funded, approvals: 2. One person did it all.
- The same pattern defeats "a maintainer cannot approve their own security exception" (B9). The admin requests the exception and decides it with a service-account token:
fullcoversproject.security.exceptions.decide, anddecider != requesterholds. - Script:
sa-dual.sh. The reward was cancelled and the service accounts deactivated afterwards. - Fix:
- Service accounts should never count for approval-type permissions:
project.rewards.approve,project.security.exceptions.decide,project.contributions.review,platform.*.approve. Add them to a "human-only" set enforced inActor::can, or requirerequire_interactive()in these decisions as payout approval already does. - Optionally record the creator of each service account and treat the account as that person in separation-of-duty checks.
- Service accounts should never count for approval-type permissions:
HIGH-3 · Refunds and disputes on recurring (Stripe) contributions never reach the ledger
Traced.
- Files and functions:
crates/commonlayer-funding/src/adapters/stripe.rs(session_paid,normalize_eventforinvoice.paid,refund.*,charge.dispute.*, andrefund),crates/commonlayer-funding/src/service/webhooks.rs(confirm_payment,apply_refund,apply_dispute,close_dispute),crates/commonlayer-funding/src/service/contributions.rs(request_refund). - The mismatch:
- A subscription's payments are stored under the invoice id:
session_paidfalls back to/invoicebecausepayment_intentis null in subscription mode, andinvoice.paidrenewals usepayment_ref = invoice id(in_…). - Refund and dispute events look the contribution up by
payment_intentorcharge(pi_…/ch_…) inrepo::by_payment_ref. - So for every recurring contribution,
apply_refundandapply_disputeanswernot_found. The webhook endsfailedand is retried forever.
- A subscription's payments are stored under the invoice id:
- Consequences:
- A chargeback or dashboard refund on a sponsorship takes the money out of the Stripe balance, but the project's
available/reserveand later the obligations and payouts still count it. The platform pays maintainers with money it no longer holds. - Card-fraud chargebacks typically hit recurring payments.
request_refundfor such a contribution calls Stripe withpayment_intent=in_…, which Stripe rejects. Contributors cannot use the refund window for recurring payments.
- A chargeback or dashboard refund on a sponsorship takes the money out of the Stripe balance, but the project's
- Evidence:
normalize_eventmapsrefund.createdandcharge.dispute.createdtopayment_ref = /payment_intent || /charge.tests/it/stripe.rsassertspayment_ref == "in_1"/"in_2"for subscription payments. No test covers a refund or dispute of anin_payment. - Fix:
- Store the PaymentIntent (or charge) id for invoice payments.
invoice.payment_intent(orpayments.data[].payment.payment_intentin newer API versions) is available oninvoice.paid, and the checkout session's invoice can be expanded. - Alternatively, keep a second lookup column
provider_charge_refand resolve refunds and disputes by either. - Add an alert when a refund or dispute webhook cannot find its contribution.
- Store the PaymentIntent (or charge) id for invoice payments.
HIGH-4 · An ambiguous payout error is treated as a failure, so the obligation can be paid twice
Traced.
- Files and functions:
crates/commonlayer-funding/src/service/payouts.rs(execute_payout_run,payout_failed,plan_payout_run),crates/commonlayer-funding/src/adapters/stripe.rs(send,create_payout). - How it happens:
- In
execute_payout_run, anyErrfromcreate_payoutgoes topayout_failed. That includespayments.provider_unavailablefor a network timeout, a connection reset after sending, an unreadable response, HTTP 5xx and 429. payout_failedmarks the payoutfailedand the obligationpending.- The next run creates a new payout row with the idempotency key
payout:{new_run_id}:{obligation}. Stripe therefore sees a differentIdempotency-Keyand creates a second transfer. - A transfer that actually succeeded at Stripe while the client timed out is paid again. The ledger posts only once, so reconciliation shows a provider/ledger mismatch only after the money has left.
- In
- Fix:
- Classify provider errors. Mark the payout
failedonly on a definitive rejection (4xx other than 409/429). For unavailable or timeout errors, leave the payoutsubmittedand retry with the same key, or look the transfer up bymetadata[payout_id]before retrying. - Derive the provider idempotency key from the obligation and an attempt counter that only increments after a confirmed failure, not from the run id.
- Classify provider errors. Mark the payout
HIGH-5 · API token scopes and project restrictions are bypassed on every ownership-based path
Demonstrated (refund).
- Files and functions:
crates/commonlayer-funding/src/service/contributions.rs:owns,contribution,request_refund,cancel_subscription.crates/commonlayer-security/src/service/reports.rs:participant_report, which serves the reporter's private reports and the actions open to them.crates/commonlayer-maintenance/src/service/eligibility.rs:may_see_appeals.
- Why it works: these paths decide by
actor.user_id() == ownerwithoutactor.can(...), so the token's scopes and its project or organization restriction never apply. - Consequence: a token meant to be harmless can move money and read private reports. Examples are a CI token restricted to project X with only
artifacts:submit, or areadtoken. It can:- refund every contribution its owner made inside the refund window;
- cancel the owner's sponsorships;
- read the bodies of the owner's private vulnerability reports on any project.
- Evidence:
umacreated a token withscopes: ["read"].- The control call
POST /organizationswith that token answered 403. - With the same token,
POST /api/v1/funding/contributions/{id}/refundreturned{"provider_state":"succeeded","status":"refunded"}.
- Script:
token-scope.sh.
- Fix:
- Make ownership checks go through the matrix:
actor.can(Permission::UserSelfManage, Scope::User(me))for writes andUserSelfReadfor reads, asnotifications/endpoints.rsalready does. - Better: add an
Actor::is_self(user, permission)helper and forbid rawuser_id()comparisons with a lint or test. - Refunds and subscription cancellation should require
funding:write(and probably an interactive session).
- Make ownership checks go through the matrix:
HIGH-6 · OAuth/OIDC state is not bound to the browser: login CSRF and forced login into the attacker's account
Traced.
- Files and functions:
crates/commonlayer-auth/src/service/oauth.rs(oauth_start,oauth_callback_inner),crates/commonlayer-auth/src/http/handlers.rs(oauth_start,oauth_callback). - Root cause:
- The state is stored server-side, single use, but no cookie or other browser binding is set at
startor checked atcallback. - The callback is a cross-site
GET, so the CSRF middleware cannot help. - PKCE does not help either, because the verifier is server-side.
- The state is stored server-side, single use, but no cookie or other browser binding is set at
- Attack:
- The attacker starts
/api/v1/auth/oauth/github/startand authorizes with their own GitHub account. - They stop at the redirect and send the resulting
…/callback?code=…&state=…link to a victim. - Opening the link logs the victim into the attacker's account. If the victim had a session, it is replaced.
- Anything the victim then enters lands in an account the attacker can read: private security reports (
POST /security/reports), billing data, organization contributions.
- The attacker starts
- Fix: at
start, set a short-lived__Host-cookie with a random value whose hash is stored with the state (SameSite=Laxworks for the top-levelGETcallback). At callback, require the cookie and compare in constant time, and clear the cookie.
MEDIUM-1 · Requested tags can take the <version>-r<revision> form and break tag immutability
Traced.
- Files and functions:
crates/commonlayer-artifacts/src/domain/naming.rs(validate_tag),crates/commonlayer-artifacts/src/service/pipeline.rs(step_promote,release_tags), triggerartifact_tags_guardin0006_artifacts.sql. - How it happens:
validate_tagaccepts any[A-Za-z0-9_][A-Za-z0-9_.-]*, so a submission for 1.27.0-r1 may request the tag1.28.0-r1.- It is inserted with
is_immutable = (tag == immutable), which isfalsehere. - When the real 1.28.0-r1 is promoted later, the existing tag row is mutable, so
step_promotemoves it withUPDATEand leavesis_immutable = false. - The release tag stays mutable forever. Any later submission requesting
1.28.0-r1can move it again.
- Consequence: consumers who pin the documented immutable tag can be moved to a different digest. Before the release, the tag points at older content.
- Related race:
step_promotecallsgw.put_tagson the registry before the per-tag database check, and the early clash check only covers existing immutable rows. Two concurrent promotions of the sameversion/revisionwith different digests can both pass the check. The registry tag then ends on the loser's digest while the database records the winner's. - Fix:
- Reject requested tags that match the
^.+-r[0-9]+$shape unless the tag equals the submission's own immutable tag. - When inserting or updating the submission's own immutable tag, force
is_immutable = true, and treat an existing mutable row with that name as a conflict. - Take a row lock or advisory lock on
(artifact_id, version, revision)before touching the registry.
- Reject requested tags that match the
MEDIUM-2 · Resuming a failed submission promotes on a stale evaluation
Traced.
- Files and functions:
crates/commonlayer-artifacts/src/service/submissions.rs(submit, the resume of afailedsubmission),crates/commonlayer-artifacts/src/service/pipeline.rs(process_submission,enter_step),crates/commonlayer-artifacts/src/domain/pipeline.rs(next_step,can_move_to, whereFailed → Mirroringis allowed). - How it happens:
- Resubmitting a digest whose submission is
failedresumes after the last succeeded step. - If
Evaluatehad succeeded (for example, Mirror failed after its last retry), the later run goes Mirror → Reverify → Promote. - Signature identity, scan gate and SLA are not re-checked, however long ago the evaluation ran.
- Resubmitting a digest whose submission is
- Consequence: a digest evaluated before a critical CVE was published, or before the project rotated a compromised signing identity in its manifest, can be promoted weeks later.
- Fix: on resume, or at
step_promote, re-runEvaluatewhen the evaluation is older than a short bound (for example the scan freshness window) or the manifest or policy version changed. Simplest: reset the steps fromScanonward when resuming afailedsubmission.
MEDIUM-3 · Any signed-in user can block a project's CI submissions for an hour
Demonstrated.
- Files and functions:
crates/commonlayer-app/src/workload.rs(ci_workload_and_limits) and the gRPC equivalent incrates/commonlayer-grpc/src/services/artifacts.rs. - How it happens:
- The
submissions_per_projectbucket (60/hour) is keyed by the raw path slug. - The bucket is consumed before authorization and before body validation, for any authenticated actor.
- An unrelated user can exhaust
nginx's bucket with 60 junk requests. The project's CI, including the GitHub Actions workload, then gets 429 and cannot ship releases or security fixes. - In the other direction,
NGINXand%6Eginxreach the same project (both returned 200 forGET /projects/{slug}/funding) but are separate buckets.
- The
- Evidence: as
uma(no roles), 62POST /api/v1/projects/review-probe-x/submissionswith body{}returned 422 ×60, then 429 ×2. A probe slug was used to avoid blocking a real project on the shared backend. - Fix: rate-limit after authorization, keyed by the resolved project id plus the principal, or count only requests from actors holding
project.artifacts.submit. Keep a separate per-principal bucket for denied requests.
MEDIUM-4 · Refund availability is checked without reserving funds
Plausible (race/sequence, not executed).
- Files and functions:
crates/commonlayer-funding/src/service/contributions.rs(request_refund),crates/commonlayer-funding/src/service/webhooks.rs(apply_refund,withdrawal_entries). - How it happens:
request_refundchecks that each target account "still holds its share" against current balances, then asks the provider.- The ledger only changes when the provider's webhook arrives. With Stripe that is asynchronous.
- Several refund requests, by different contributors to the same project, or by one contributor with concurrent requests up to Stripe's own cap, all pass the check against the same
availablebalance. - When the webhooks arrive, the waterfall takes the excess from
reserve, theflexible_pooland finally the infrastructure treasury, which the design intends to protect ("only up to what each target account still holds").
- Fix: record a pending refund hold (an internal
refund_pendingaccount, or a row summed into the check) inside the same transaction as the check, under the finance advisory lock. Release it when the provider confirms or fails the refund.
MEDIUM-5 · A repository's numeric GitHub id is accepted without verification and can be squatted
Traced.
- Files and functions:
crates/commonlayer-projects/src/service/repositories.rs(add_repository,verify_repository),crates/commonlayer-projects/src/ports.rs(find_by_repository),crates/commonlayer-app/src/workload.rs(resolve_project), constraintUNIQUE (provider, external_id)in0005_projects.sql. - How it happens:
add_repositorystores the caller-suppliedexternal_id(any digits) unchecked.- Verification only proves write access to
owner/name: it readscommonlayer.yamlthrough raw.githubusercontent. - The workload middleware trusts
external_idalone (find_by_repository("github", repository_id)) and never compares the token'srepositorywith the link'sowner/name.
- Attack:
- The admin of any project (anyone can propose one) links
github.com/attacker/xwithexternal_id = <numeric id of a popular upstream repo>and verifies it. - The real project can no longer register its own numeric id ("already linked").
- Actions OIDC tokens from the real repository now resolve to the attacker's project, so the victim's CI submissions are refused.
- The admin of any project (anyone can propose one) links
- The protection the module comment describes ("
owner/namealone is never trusted") is not delivered: the numeric id is never tied to the verified name. - Fix: resolve the numeric id from GitHub at verification time (
GET /repos/{owner}/{name}→id) and store only that value. Inresolve_project, also requireci.repositoryto equal the link'sowner/name, case-insensitively.
MEDIUM-6 · Settlement can apply stale eligibility decisions
Plausible (race, traced).
- Files and functions:
crates/commonlayer-funding/src/service/cycles.rs(settle_cycle),crates/commonlayer-funding/src/service/obligations.rs(apply_eligibility). - How it happens:
settle_cyclereads occupancy and eligibility decisions through theMaintainerDirectoryport before it takesadvisory_xact_lock("finance", project).- An eligibility override committed in that window reaches
apply_eligibility. It seescycle.status != settledand returns, assuming "settlement will read the decision itself". - Settlement then commits with the old decision. A maintainer just ruled ineligible is paid, or an eligible one is forfeited, and no event re-applies the change.
- Fix: read the decisions after taking the lock, inside the transaction. Alternatively, have
apply_eligibilityrecord a pending decision that settlement consumes, or have settlement re-check a decision version stamp before commit.
MEDIUM-7 · The public digest page may expose embargoed vulnerability ids
Plausible.
- Files and functions:
crates/commonlayer-artifacts/src/service/catalog.rs(digest_public,digest_row_public),crates/commonlayer-artifacts/src/domain/policy.rs(check_scan_gate),crates/commonlayer-security/src/service/posture.rs(blocking_findings). - How it happens:
digest_publicserves, for any digest of a public project in any state (including rejected submissions), the latest verification with every check message.- The
scan.gatefailure message lists up to 10 blocking findings ascanonical_id (severity) in package version. blocking_findingsdoes not filter embargoed findings (correct for gating, wrong for display).
- Consequence: if an embargoed vulnerability has occurrences on a digest (for example after a private report linked to a known id), a rejected build reveals it publicly.
- Fix: keep the gate input unchanged, but redact embargoed ids from the stored message (for example "1 embargoed finding"). Alternatively, filter
checksin the public projection.
LOW-1 · Guest checkouts share one idempotency namespace
Traced.
- Files and functions:
crates/commonlayer-httpkit/src/idempotency.rs(principal_scope→"anonymous"),crates/commonlayer-funding/src/service/contributions.rs(start_checkout,db_key = "checkout:guest:{key}"). - Consequence: two guests using the same
Idempotency-Key, with the same body within the TTL, receive each other's stored response, including the providerredirect_url. After the TTL, the database key returns another guest's contribution id. - Fix: for anonymous callers, scope by a server-issued random value (for example a guest cookie), or derive the database key from a server-generated id.
LOW-2 · Payout destinations are read at execution time and their changes are not audited
Plausible; not reachable through a route today.
- Files and functions:
crates/commonlayer-funding/src/service/payouts.rs(execute_payout_runreadsself.payees.payee(...)per payout),crates/commonlayer-maintenance/src/service/profiles.rs(record_payout_status). - What is wrong:
- The approved run fixes payees and amounts, but not the provider destination.
record_payout_status(platform.payments.reconcile, usable with a token) can change any maintainer'spayout_destination_ref, and its audit entry omits the reference.
- Consequence: once the onboarding flow wires this setter, one admin can redirect an approved run's money after the second approval.
- Fix: snapshot
destination_refintopayoutsat planning, show it to the approver, and pay only to the snapshot. Audit destination changes, with a hash or the last 4 characters.
LOW-3 · A reused bank-transfer reference silently confirms nothing
Traced.
- Files and functions:
crates/commonlayer-funding/src/service/contributions.rs(confirm_bank_transfer),crates/commonlayer-funding/src/service/webhooks.rs(payment_contribution). - How it happens:
payment_ref = "bank:{bank_reference}"is looked up first.- Confirming contribution B with a reference already used for A matches A, which is already settled, and returns
Ok. - The stored event is marked
processedand B stays unconfirmed. The administrator gets B's unchanged view and no error.
- Fix: include the contribution id in the bank payment reference, or refuse a reference already bound to another contribution.
LOW-4 · Refund or dispute events that post nothing are not idempotent
Traced.
- Files and functions:
crates/commonlayer-funding/src/service/webhooks.rs(apply_refund,apply_dispute). - How it happens:
- Idempotency depends on the ledger key
contribution:{id}:refund:{ref}. - When the waterfall covers nothing (
entriesempty), no transaction is posted, butrefunded_minorordisputed_minoris still increased. - A second event for the same refund id with a different event id (Stripe sends both
refund.createdandrefund.updatedwith statussucceeded) increments it again. close_disputethen fails withfunding.dispute_not_openedforever.
- Idempotency depends on the ledger key
- Fix: record processed refund and dispute refs in their own table (unique), independent of whether a ledger transaction was posted.
LOW-5 · Production can run without the __Host- prefix
Traced.
- File and function:
crates/commonlayer-config/src/sections/auth.rs(AuthSettings::validate). - What is wrong: validation only checks
cookie_securefor non-development environments. A production configuration withcookie_name = "cl_session"(copied from.env.example) andcookie_secure = trueis accepted. - Consequence: a sibling subdomain can then plant or overwrite session and CSRF cookies (cookie tossing, session fixation).
- Fix: require the
__Host-prefix for both cookies outside development and test.
LOW-6 · The simulator is allowed in staging, where its dev routes are unauthenticated
Traced.
- Files and functions:
crates/commonlayer-funding/src/adapters/simulated.rs(SimulatedProvider::newrefuses onlyproduction),crates/commonlayer-funding/src/http/mod.rs(dev_completeanddev_failhave no actor),crates/commonlayer-funding/src/service/webhooks.rs(simulate_checkout). - Consequence: in a staging deployment with the simulator, anyone who sees a checkout session id can complete it, confirming contributions without payment. With
payouts.modesimulated, that money then flows through settlement and payout runs. - Fix: allow the simulator only in
developmentandtest, or require the contribution's owner on the dev routes.
Areas checked and found sound (one line each)
- Ledger posting and triggers:
ledger::post(validation, key check before and after locking, sortedFOR UPDATEof balance rows), theledger_apply_entrynon-negative internal balances check, theledger_balances_guarddepth check and append-only installs. No bypass found short of O1. - Payment webhook signature:
adapters/signature.rsusesv1only, the lasttsigned, the tolerance checked after the MAC, constant-time comparison. Raw-body verification precedes parsing. The provider path segment must match the configured provider. Duplicates use(provider, event_id). - Webhook amounts and event types: amounts come from the provider event, the request never sets them, and there is no event-type confusion between Stripe types.
- Payout run approval: interactive session required, approver ≠ initiator, dry runs never executable, gates re-checked at execution, obligations locked when scheduled. Forfeiture of
scheduledandpaidobligations is refused. - Reward cancellation: only while the obligation is
pending, under row locks. - GitHub webhook HMAC:
commonlayer-github/src/domain/signature.rs. - GitHub Actions OIDC verifier:
adapters/oidc.rschecks RS256 only,kidrequired, iss/aud/exp/nbf/iat, bounded JWKS refresh. The workload actor only replaces anonymous actors and holds onlyproject.artifacts.submiton one project.get_submissionchecks the stored submission's project. - SafeClient:
commonlayer-netresolves then pins, rejects mixed DNS answers, checks literal IPs, follows no redirects, ignores proxies, caps size. It is used for webhooks, manifests, OAuth/OIDC and feeds. The manifest fetch URL builder validates owner, name, ref and path. - Chart unpacking:
domain/chart.rscaps package, unpacked size, entry count and file size, rejects..and absolute paths, and works in memory only. The YAML parser isserde_norway. - cosign:
adapters/cosign.rsbuilds an argument vector with a cleared environment and timeout. Every record must name the requested digest.glob_to_regexanchors with^/$and escapes metacharacters. Signature checks are not waivable. - Registry tokens: scope parsing and the
grantfilter, constant-time promoter comparison, events bearer secret compared in constant time. - CSRF middleware: header, cookie and session tokens compared in constant time, Origin/Referer allowlist. Path variants
//api/…,/%61pi/…and/api%2F…were tested and are not routed. - Web redirects:
features/auth/navigation.tssafeNext(control characters, backslashes, origin check) and the backendsafe_redirect. - Markdown: raw HTML dropped,
rehype-sanitizedefault schema, MermaidsecurityLevel: strict, JSON-LD escaped. - API token restriction:
TokenRestriction::permitsand theNEVER_BY_TOKENset are correct whererequire/canis used. The defect is in the callers that skip them (HIGH-5). - Dynamic SQL: the search
QueryBuilderbinds every value. TheAssertSqlSafe(format!)uses that were sampled interpolate constants only.
Authorization sample (handler → service, scope from the stored object)
About 40 non-public operations were traced:
- Funding:
checkout,checkout_as_organization,contribution,request_refund,cancel_subscription, the organization contributions, subscriptions, summary and receipt,activate_policy(policy filtered byproject_id),propose_reward,decide_reward(scope from the stored reward),cancel_reward,plan_payout_run,approve_payout_run,request_payout_execution,reverse_payout,adjust_ledger,webhooks,retry_webhook,confirm_bank_transfer. - Artifacts:
submit,get_submission,list_submissions. - Auth:
oauth_start,oauth_callback,create_token,revoke_token,request_email_change, project service account create, deactivate and token (account filtered bysa_owner_project_id). - Maintenance:
submit_contribution,review_contribution,record_payout_status,evaluation. - Security:
participant_report,submit_report,lock_foranddecide_exception(scope from the stored finding). - Projects:
add_repository,remove_repository,verify_repository(repository filtered by project).
No slug-versus-body-id IDOR was found in this sample. The authorization defects found are the ownership-path token bypass (HIGH-5) and the service-account separation of duty (HIGH-2). The gRPC funding service is read-only and calls the same *_public / my_contributions methods. gRPC authentication builds the actor through the same AuthService::authenticate.
Not covered, or only partly
- Registry retention ("never delete protected digests") and the registry events ingestion beyond authentication.
- SBOM, VEX, Trivy and Grype report parsers and the security subprocess adapter (
commonlayer-security/src/adapters/process.rs). - Notifications webhook sending at send time.
- Organizations role APIs and the last-admin trigger.
- Password reset flow details (reset tokens issued before an email change remain valid; this was noted but not analysed).
- Lockout and rate-limit internals.
- Audit and log redaction coverage.
- The web CSP builder and the dev-only pages.
- gRPC parity for the maintenance, security and artifacts mutations.
- Rounding in
split_pro_rataanddomain/settlement.rsbeyond reading the largest-remainder approach. - Running the crate test suites.