Skip to content
CommonLayer
Documentation menu
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.

FindingStatus
HIGH-1 · single maintainer could fund rewards aloneFixed: the proposer never counts as an approver; one reward per contribution
HIGH-2 · service accounts could satisfy two-person rulesFixed: 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 ledgerFixed: invoice payments are linked to their payment intent and charge
HIGH-4 · ambiguous payout errors could lead to double paymentFixed: stable idempotency key per obligation; explicit unknown state until resolved
HIGH-5 · token scopes ignored on ownership checksFixed: ownership paths go through the self-scope permission check
HIGH-6 · OAuth state not bound to the browserFixed: state is bound to a short-lived cookie
MEDIUM-1 · release-shaped tags of other versionsFixed
MEDIUM-2 · stale evaluation on resumeFixed
MEDIUM-3 · submission rate limit spent before authorizationFixed (REST and gRPC)
MEDIUM-4 · refunds not reservedFixed
MEDIUM-5 · unverified GitHub repository idFixed for new links; links verified earlier are not re-checked
MEDIUM-6 · settlement read eligibility before lockingFixed
MEDIUM-7 · embargoed ids in the public scan-gate messageFixed
LOW-1 to LOW-6Fixed

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), trigger community_reward_approvals_check and table community_rewards in crates/commonlayer-database/migrations/0010_funding.sql, and crates/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.
  • Attack:
    1. Maintainer M creates a sock-puppet account S, and S submits a contribution.
    2. M approves the contribution.
    3. M proposes a $100 reward for S and approves it, then repeats.
    4. Each round posts flexible_pool → obligations with 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 for colin's single approved contribution were proposed and self-approved. Both reached status: funded with required_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 the required_approvals >= 2 condition 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.

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_admin or maintainer and give them full-scoped tokens.
    • decide_reward and decide_exception only compare user ids, and require_user() returns the service account's id.
    • For service accounts, approver_role derives ProjectAdmin from the account's sa_role.
  • Attack (reward, demonstrated):
    1. pat (nginx project admin) created a project_admin service account and a maintainer service account, each with a full token.
    2. pat proposed a $150 reward, which needs two approvals including one from an admin.
    3. 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: full covers project.security.exceptions.decide, and decider != requester holds.
  • 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 in Actor::can, or require require_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.

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_event for invoice.paid, refund.*, charge.dispute.*, and refund), 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_paid falls back to /invoice because payment_intent is null in subscription mode, and invoice.paid renewals use payment_ref = invoice id (in_…).
    • Refund and dispute events look the contribution up by payment_intent or charge (pi_… / ch_…) in repo::by_payment_ref.
    • So for every recurring contribution, apply_refund and apply_dispute answer not_found. The webhook ends failed and is retried forever.
  • Consequences:
    • A chargeback or dashboard refund on a sponsorship takes the money out of the Stripe balance, but the project's available/reserve and 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_refund for such a contribution calls Stripe with payment_intent=in_…, which Stripe rejects. Contributors cannot use the refund window for recurring payments.
  • Evidence: normalize_event maps refund.created and charge.dispute.created to payment_ref = /payment_intent || /charge. tests/it/stripe.rs asserts payment_ref == "in_1" / "in_2" for subscription payments. No test covers a refund or dispute of an in_ payment.
  • Fix:
    • Store the PaymentIntent (or charge) id for invoice payments. invoice.payment_intent (or payments.data[].payment.payment_intent in newer API versions) is available on invoice.paid, and the checkout session's invoice can be expanded.
    • Alternatively, keep a second lookup column provider_charge_ref and resolve refunds and disputes by either.
    • Add an alert when a refund or dispute webhook cannot find its contribution.

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, any Err from create_payout goes to payout_failed. That includes payments.provider_unavailable for a network timeout, a connection reset after sending, an unreadable response, HTTP 5xx and 429.
    • payout_failed marks the payout failed and the obligation pending.
    • The next run creates a new payout row with the idempotency key payout:{new_run_id}:{obligation}. Stripe therefore sees a different Idempotency-Key and 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.
  • Fix:
    • Classify provider errors. Mark the payout failed only on a definitive rejection (4xx other than 409/429). For unavailable or timeout errors, leave the payout submitted and retry with the same key, or look the transfer up by metadata[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.

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() == owner without actor.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 a read token. 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:
    1. uma created a token with scopes: ["read"].
    2. The control call POST /organizations with that token answered 403.
    3. With the same token, POST /api/v1/funding/contributions/{id}/refund returned {"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 and UserSelfRead for reads, as notifications/endpoints.rs already does.
    • Better: add an Actor::is_self(user, permission) helper and forbid raw user_id() comparisons with a lint or test.
    • Refunds and subscription cancellation should require funding:write (and probably an interactive session).

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 start or checked at callback.
    • The callback is a cross-site GET, so the CSRF middleware cannot help.
    • PKCE does not help either, because the verifier is server-side.
  • Attack:
    1. The attacker starts /api/v1/auth/oauth/github/start and authorizes with their own GitHub account.
    2. They stop at the redirect and send the resulting …/callback?code=…&state=… link to a victim.
    3. Opening the link logs the victim into the attacker's account. If the victim had a session, it is replaced.
    4. Anything the victim then enters lands in an account the attacker can read: private security reports (POST /security/reports), billing data, organization contributions.
  • Fix: at start, set a short-lived __Host- cookie with a random value whose hash is stored with the state (SameSite=Lax works for the top-level GET callback). 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), trigger artifact_tags_guard in 0006_artifacts.sql.
  • How it happens:
    • validate_tag accepts any [A-Za-z0-9_][A-Za-z0-9_.-]*, so a submission for 1.27.0-r1 may request the tag 1.28.0-r1.
    • It is inserted with is_immutable = (tag == immutable), which is false here.
    • When the real 1.28.0-r1 is promoted later, the existing tag row is mutable, so step_promote moves it with UPDATE and leaves is_immutable = false.
    • The release tag stays mutable forever. Any later submission requesting 1.28.0-r1 can 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_promote calls gw.put_tags on the registry before the per-tag database check, and the early clash check only covers existing immutable rows. Two concurrent promotions of the same version/revision with 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.

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 a failed submission), crates/commonlayer-artifacts/src/service/pipeline.rs (process_submission, enter_step), crates/commonlayer-artifacts/src/domain/pipeline.rs (next_step, can_move_to, where Failed → Mirroring is allowed).
  • How it happens:
    • Resubmitting a digest whose submission is failed resumes after the last succeeded step.
    • If Evaluate had 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.
  • 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-run Evaluate when 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 from Scan onward when resuming a failed submission.

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 in crates/commonlayer-grpc/src/services/artifacts.rs.
  • How it happens:
    • The submissions_per_project bucket (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, NGINX and %6Eginx reach the same project (both returned 200 for GET /projects/{slug}/funding) but are separate buckets.
  • Evidence: as uma (no roles), 62 POST /api/v1/projects/review-probe-x/submissions with 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_refund checks 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 available balance.
    • When the webhooks arrive, the waterfall takes the excess from reserve, the flexible_pool and 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_pending account, 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), constraint UNIQUE (provider, external_id) in 0005_projects.sql.
  • How it happens:
    • add_repository stores the caller-supplied external_id (any digits) unchecked.
    • Verification only proves write access to owner/name: it reads commonlayer.yaml through raw.githubusercontent.
    • The workload middleware trusts external_id alone (find_by_repository("github", repository_id)) and never compares the token's repository with the link's owner/name.
  • Attack:
    • The admin of any project (anyone can propose one) links github.com/attacker/x with external_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 protection the module comment describes ("owner/name alone 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. In resolve_project, also require ci.repository to equal the link's owner/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_cycle reads occupancy and eligibility decisions through the MaintainerDirectory port before it takes advisory_xact_lock("finance", project).
    • An eligibility override committed in that window reaches apply_eligibility. It sees cycle.status != settled and 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_eligibility record 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_public serves, for any digest of a public project in any state (including rejected submissions), the latest verification with every check message.
    • The scan.gate failure message lists up to 10 blocking findings as canonical_id (severity) in package version.
    • blocking_findings does 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 checks in 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 provider redirect_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_run reads self.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's payout_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_ref into payouts at 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 processed and 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 (entries empty), no transaction is posted, but refunded_minor or disputed_minor is still increased.
    • A second event for the same refund id with a different event id (Stripe sends both refund.created and refund.updated with status succeeded) increments it again.
    • close_dispute then fails with funding.dispute_not_opened forever.
  • 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_secure for non-development environments. A production configuration with cookie_name = "cl_session" (copied from .env.example) and cookie_secure = true is 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::new refuses only production), crates/commonlayer-funding/src/http/mod.rs (dev_complete and dev_fail have 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.mode simulated, that money then flows through settlement and payout runs.
  • Fix: allow the simulator only in development and test, 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, sorted FOR UPDATE of balance rows), the ledger_apply_entry non-negative internal balances check, the ledger_balances_guard depth check and append-only installs. No bypass found short of O1.
  • Payment webhook signature: adapters/signature.rs uses v1 only, the last t signed, 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 scheduled and paid obligations 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.rs checks RS256 only, kid required, iss/aud/exp/nbf/iat, bounded JWKS refresh. The workload actor only replaces anonymous actors and holds only project.artifacts.submit on one project. get_submission checks the stored submission's project.
  • SafeClient: commonlayer-net resolves 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.rs caps package, unpacked size, entry count and file size, rejects .. and absolute paths, and works in memory only. The YAML parser is serde_norway.
  • cosign: adapters/cosign.rs builds an argument vector with a cleared environment and timeout. Every record must name the requested digest. glob_to_regex anchors with ^/$ and escapes metacharacters. Signature checks are not waivable.
  • Registry tokens: scope parsing and the grant filter, 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.ts safeNext (control characters, backslashes, origin check) and the backend safe_redirect.
  • Markdown: raw HTML dropped, rehype-sanitize default schema, Mermaid securityLevel: strict, JSON-LD escaped.
  • API token restriction: TokenRestriction::permits and the NEVER_BY_TOKEN set are correct where require/can is used. The defect is in the callers that skip them (HIGH-5).
  • Dynamic SQL: the search QueryBuilder binds every value. The AssertSqlSafe(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 by project_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 by sa_owner_project_id).
  • Maintenance: submit_contribution, review_contribution, record_payout_status, evaluation.
  • Security: participant_report, submit_report, lock_for and decide_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_rata and domain/settlement.rs beyond reading the largest-remainder approach.
  • Running the crate test suites.