Yield.xyz

Yield.xyz Pentest

Cantina Security Report

Organization

@yieldxyz

Engagement Type

Cantina Reviews

Period

-

Researchers


Findings

Medium Risk

11 findings

9 fixed

2 acknowledged

Low Risk

2 findings

2 fixed

0 acknowledged

Informational

1 findings

0 fixed

1 acknowledged


Medium Risk11 findings

  1. Production CI/CD Takeover via GitHub Actions

    State

    Severity

    Severity: Medium

    ≈

    Likelihood: Low

    ×

    Impact: High

    Submitted by

    mikey96


    Summary

    • Account: stakekit-production (332408080152)
    • IAM Role: github_actions_role
    • Issue: Any workflow in stakekit/stakekit-monorepo (including feature branches) can assume the production GitHub Actions role because the trust policy includes the wildcard subject repo:stakekit/stakekit-monorepo:*. That role has broad permissions (ECS deploy, Lambda update, EventBridge/SNS/CloudFront, ECR push, iam:PassRole, etc.).
    • Impact: Anyone with repo write access can deploy arbitrary images, run Terragrunt/Terraform, and read secrets by describing ECS task definitions - no approvals or release gating.

    Proof of Access

    Workflow (.github/workflows/test-prod-role-access.yml) added on branch cantina/mikey96:

    name: Test Prod Role Access
    on:  workflow_dispatch:  push:    branches:      - cantina/mikey96
    permissions:  id-token: write  contents: read
    jobs:  prove-prod-access:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: aws-actions/configure-aws-credentials@v4        with:          role-to-assume: arn:aws:iam::332408080152:role/github_actions_role          aws-region: us-east-1          audience: sts.amazonaws.com      - run: aws sts get-caller-identity

    Run output:

    {  "UserId": "AROAU2ZIQY4MO5MQEDQTY:GitHubActions",  "Account": "332408080152",  "Arn": "arn:aws:sts::332408080152:assumed-role/github_actions_role/GitHubActions"}

    Impact Details

    1. Arbitrary prod deployments: github_actions_role includes:

      actions = [  "ecs:DeregisterTaskDefinition","ecs:RegisterTaskDefinition",  "ecs:UpdateService","ecs:RunTask","ecs:DescribeTaskDefinition",  "lambda:UpdateFunctionCode","lambda:InvokeFunction",  "events:PutTargets","cloudfront:CreateInvalidation",  "sns:*","iam:PassRole", ...]resources = ["*"]

      Any branch workflow can push images to prod ECR repos and update ECS services/Lambdas/cron jobs.

    2. Secrets disclosure: Terraform inlines Secrets Manager payloads into ECS task definitions. Example (stakekit-api):

      environment = concat(  local.secret_value["td_environment"],  [    { name = "REDIS_HOST", value = data.terraform_remote_state.redis.outputs.endpoint[0].address },    {      name = "STAKEKIT_STAKING_SERVICE_TYPEORM_DATASOURCE_OPTIONS",      value = jsonencode(        merge(          local.secret_value["STAKEKIT_STAKING_SERVICE_TYPEORM_DATASOURCE_OPTIONS"],          { "url": "${local.secret_value["STAKEKIT_STAKING_SERVICE_TYPEORM_URL"]}${local.db_endpoint}/${local.secret_value["db_name"]}" }        )      )    }  ])

      Using the prod role:

      aws ecs list-task-definitions --family-prefix production-stakekit-apiaws ecs describe-task-definition --task-definition production-stakekit-api:<REV> \  --query 'taskDefinition.containerDefinitions[0].environment'

      returns DB URLs, Redis endpoints, SQS URLs, and everything from td_environment.

    3. Staging ≠ Prod isolation: The staging workflows (.github/workflows/staging-deployment.yml) call the same modules, which assume the prod role (before any if inputs.environment == 'staging' logic runs). Compromising staging gives prod access automatically.

    1. Tighten IAM trust policies

      • Remove repo:stakekit/stakekit-monorepo:*.
      • Create separate roles per environment with explicit sub values:
        "token.actions.githubusercontent.com:sub" = "repo:stakekit/stakekit-monorepo:environment:production"
      • Staging workflows should assume arn:aws:iam::<staging-account>:role/github_actions_role.
    2. GitHub environment protection

      • Require environment: production with manual approvals/deployment protection rules before any job can assume prod credentials.
      • Only allow release/tag workflows to target prod.
    3. Secrets handling

      • Use ECS secrets blocks referencing Secrets Manager ARNs instead of embedding secret JSON into environment.
      • Keep Terraform state free of plaintext secrets; rely on runtime injection.
    4. Monitoring & controls

      • CloudTrail alerts on sts:AssumeRole for github_actions_role.
      • Branch protection to prevent arbitrary workflow edits targeting prod.
  2. Cross-Tenant Transaction Mutation (Yield API)

    State

    Severity

    Severity: Medium

    ≈

    Likelihood: Low

    ×

    Impact: Medium

    Submitted by

    mikey96


    Summary

    • Endpoints: POST /v1/transactions/:transactionId/submit, PUT /v1/transactions/:transactionId/submit-hash
    • Issue: The endpoints accept any transactionId and mutate the underlying Transaction row without checking whether the caller’s API key/project owns that transaction. Ownership is stored on the parent Stake (stake.projectId), but the code never filters by it.
    • Impact: Any tenant who discovers a transaction ID (e.g., via GET /v1/actions) can overwrite the signed payload, broadcast it on-chain, or mark actions as completed/failed for another project.

    Technical Details

    Controller and Service Skip Project Scoping

    // apps/yield-api/src/transactions/transactions.controller.ts@Post(':transactionId/submit')async submitTransaction(  @Param('transactionId') transactionId: string,  @Body() submitTransactionDto: SubmitTransactionDto,) {  return this.transactionsService.submitTransaction(transactionId, submitTransactionDto);}
    // apps/yield-api/src/transactions/transactions.service.tsconst transaction = await this.transactionsPersistenceService.updateTransactionSigned(  transactionId,  submitTransactionDto.signedTransaction,);const action = await this.actionPersistenceService.findActionById(transaction.stakeId);const submission = await submit(...);  // broadcasts signed txawait this.transactionsPersistenceService.updateTransactionHash(transactionId, submission.transactionHash);

    Persistence Layer Looks Up by ID Only

    // apps/yield-api/src/transactions/transactions.persistence.service.tsasync findTransactionById(id: string): Promise<Transaction | null> {  return this.transactionRepository.findOne({ where: { id } });}

    No join on stake.projectId, so any valid transactionId yields a row.

    Stake Tracks Project Ownership (Unused)

    // libs/api-entities/src/entities/stake.entity.ts@ManyToOne(() => Project, { nullable: true })@JoinColumn({ name: 'project_id' })public project!: Project | null;
    @Column({ name: 'project_id', type: 'varchar', nullable: true })public projectId!: string | null;

    Proof of Concept

    1. Enumerate victim actions: GET /v1/actions?address=0xVictim... (allowed for any API key).
    2. Extract transactionId from the response.
    3. Mutate the transaction:
      POST /v1/transactions/<victim-transaction-id>/submit{  "signedTransaction": "0xf8..."}
      or
      PUT /v1/transactions/<victim-transaction-id>/submit-hash{  "hash": "0xdeadbeef..."}
    4. The victim’s transaction now reflects the attacker’s signed payload/hash, even though the attacker’s API key belongs to another project.

    Impact

    • Operational sabotage: Attackers can mark actions as broadcasted/failed, corrupting customer dashboards and automation.
    • Tenant isolation broken: One partner can mutate another’s transactions, violating customer data separation.
    • Extract projectId from request[contextProperty].key in the controller and pass it through the service.
    • Update TransactionsPersistenceService to filter by { id, stake: { projectId } }.
    • Return HTTP 403 when the transaction doesn’t belong to the caller’s project.
    • Optionally keep cross-tenant reads (GET /v1/actions) if desired, but block all writes unless the original key/project owns the stake/transaction.
  3. OTP Login Code Brute-Force

    State

    Severity

    Severity: Medium

    ≈

    Likelihood: High

    ×

    Impact: High

    Submitted by

    mikey96


    Summary

    • Endpoint: POST /v1/auth/login/request-code followed by POST /v1/auth/login/verify-code (apps/api service).
    • Issue: Anyone can repeatedly request or guess 6-digit OTP codes for any email address without rate limiting, CAPTCHA, or account lockouts. The verify endpoint returns success once the code matches, so attackers can brute-force codes offline.
    • Impact: Attackers can spam login emails to users (denial of service) or enumerate the 6-digit code space to take over accounts that rely on email OTP. Since responses are indistinguishable when the email doesn’t exist, it also enables timing-based enumeration.

    Evidence

    @Post('login/request-code')public async requestLoginCode(@Body() dto: AuthRequestLoginCodeDto) {  return this.service.requestLoginCode(dto);}
    if (user && user.emailVerified) {  await this.loginCodeService.invalidateUserCodes(user.id);  const { plainCode } = await this.loginCodeService.create(user.id);  await this.mailService.sendLoginCode(user, plainCode);}return {  success: true,  message: 'If the email exists, a login code has been sent.',};

    There is no rate-limit guard, IP throttling, or CAPTCHA anywhere in this flow.

    Impact

    • Account takeover: A 6-digit code (1,000,000 possibilities) can be brute-forced quickly with scripting, especially since the verify endpoint has no backoff or lockout logic.
    • User harassment: Attackers can flood a victim’s inbox with OTP emails, effectively preventing them from using the service.
    1. Rate limiting: Apply IP/email throttles (e.g., 5 OTP requests per hour per email, 10 per IP) using Redis or an external service.
    2. CAPTCHA or proof-of-work: Require hCaptcha/reCAPTCHA or a lightweight challenge after multiple attempts.
    3. Backoff / lockout: Track failed verifications and temporarily lock the account or require manual review after N wrong codes.
    4. Code hardening: Increase code length/entropy (e.g., 8 digits or alphanumeric) and reduce validity duration (60–120 seconds).
  4. Direct IP exposure via Historical DNS data

    State

    Acknowledged

    Severity

    Severity: Medium

    ≈

    Likelihood: High

    ×

    Impact: Medium

    Submitted by

    dreyand


    Description

    Multiple StakeKit API service origins are publicly accessible via direct IP addresses and present SSL certificates for stakek.it. Requests sent to these IPs reach the backend directly, completely bypassing any WAF, Bot Management, rate limits, and security controls. This enables attackers to interact with application endpoints without any edge-layer protections, significantly increasing the risk of exploitation.

    Affected Origins:

    Recommendations:

    • Remediate by eliminating all public access to backend origins, enforcing allowed IP-only ingress, and cleaning up certificate and DNS exposures.

    1. Restrict Origin Access

    • Allow inbound traffic only from allowed IP ranges on your firewall, security group, or load balancer.
    • Prefer solutions like Cloudflare Tunnel to remove any public IP exposure for backend services.

    2. Enforce Authenticated Origin Pulls (mTLS)

    • Require mutual TLS (mTLS) between trusted origins and your backend.

    3. Replace Exposed Certificates

    • Remove or replace any SSL certificates for stakek.it that have been exposed on public IPs.

    4. Eliminate Public DNS Records

    • Ensure no public A/AAAA records resolve to backend origin IPs.
    • Use internal-only DNS hostnames for origin servers.
  5. Missing Rate Limit on Login Code Request

    State

    Severity

    Severity: Medium

    ≈

    Likelihood: High

    ×

    Impact: Medium

    Submitted by

    dreyand


    Description

    The POST /v1/auth/login/request-code endpoint has no rate limiting, allowing unlimited email spam to any address. An attacker can exhaust mail service quotas and flood victim inboxes.

    Proof Of Concept

    1. Send repeated requests without delay:
    curl -X POST https://api.stakek.it/v1/auth/login/request-code \  -H "Content-Type: application/json" \  -d '{"email":"[email protected]"}'
    1. Observe all requests succeed with no throttling

    2. Target receives multiple login code emails

    Recommendation

    1. IP-based throttling (immediate fix):

    @Post('login/request-code')@Throttle({ default: { limit: 5, ttl: 60000 } }) // 5 per minute per IP@HttpCode(HttpStatus.OK)public async requestLoginCode(/*...*/) { }

    2. Email-based throttling (application layer):

    • Store last request timestamp per email in cache/database
    • Reject requests if email was used in last 60 seconds
    • Return 429 with "Please wait before requesting another code"

    3. Additional protections:

    • Add CAPTCHA after 3 failed attempts from same IP
    • Implement exponential backoff (1min → 5min → 15min)
    • Monitor for abuse patterns (same email from multiple IPs)
    • Alert on anomalous spike in code requests

    Suggested rate limits:

    • Per IP: 5/minute, 20/hour
    • Per email: 1/60sec, 5/hour
    • Consider requiring existing session for code requests
  6. Arbitrary Account Lockout due to improper invalidation logic

    State

    Severity

    Severity: Medium

    ≈

    Likelihood: High

    ×

    Impact: Medium

    Submitted by

    dreyand


    Description

    An attacker can prevent users from logging in by repeatedly calling POST /v1/auth/login/request-code with the victim's email. Each request invalidates all previous login codes for that user, making legitimate login impossible.

    This is due to the fact that the following code in stakekit-monorepo/apps/api/src/auth/auth.service.ts contains logic which will invalidate the previous code tied to an email *on every request to the request-code route:

    async requestLoginCode(    dto: AuthRequestLoginCodeDto,  ): Promise<AuthRequestLoginCodeResponseDto> {    const user = await this.usersService.findOne({      email: dto.email,    });
        if (user && user.emailVerified) {      await this.loginCodeService.invalidateUserCodes(user.id); // <-- problem
          const { plainCode } = await this.loginCodeService.create(user.id); 
          await this.mailService.sendLoginCode(user, plainCode);    }
        return {      success: true,      message: 'If the email exists, a login code has been sent.',    };  }

    Combined with missing rate limits, this allows complete account-level DoS / lockout.

    Steps To Reproduce

    1. Victim requests login code:

    2. Attacker continuously spams same endpoint:

    while true; do  curl -X POST https://api.stakek.it/v1/auth/login/request-code \    -d '{"email":"[email protected]"}'  sleep 0.1done
    1. Victim tries to use code "123456" -> fails (already invalidated)
    2. Victim receives new code "789012" -> fails (attacker invalidated it)
    3. Loop continues - victim cannot log in

    Recommendation

    1. Implement rate limiting (fixes both spam and lockout):

    • 1 request per email per 60 seconds
    • 5 requests per IP per minute

    2. Change invalidation logic - don't invalidate codes on new request.

    3. Alternative: Time-based protection: Only invalidate if last code is older than 60 (or more) seconds

  7. Potential Email Verification bypass due to PRNG usage

    State

    Severity

    Severity: Medium

    ≈

    Likelihood: Low

    ×

    Impact: Medium

    Submitted by

    dreyand


    Description

    Email confirmation tokens use Math.random() (via NestJS's randomStringGenerator()) instead of a cryptographically secure random number generator. While the output is hashed with SHA256, the effective entropy is only ~54 bits instead of 256 bits, making brute-force attacks theoretically feasible.

    The hash used to generate the email confirmation token is implemented like the following in users.service.ts:

    #createRandomHex(): string {  return crypto    .createHash('sha256')    .update(randomStringGenerator())  // Uses Math.random()    .digest('hex');}

    The randomStringGenerator() function from NestJS relies on the insecure uid:

    var IDX=256, HEX=[], SIZE=256, BUFFER;while (IDX--) HEX[IDX] = (IDX + 256).toString(16).substring(1);
    export function uid(len) {    var i=0, tmp=(len || 11);    if (!BUFFER || ((IDX + tmp) > SIZE*2)) {    for (BUFFER='',IDX=0; i < SIZE; i++) {    BUFFER += HEX[Math.random() * 256 | 0];    }    }
        return BUFFER.substring(IDX, IDX++ + tmp);}
    1. This produces only ~11 base36 characters = ~54 bits of entropy
    2. Confirmation link: https://dashboard.yield.xyz/login?hash=<predictable_hash>

    Impact:

    • Attacker could enumerate possible values
    • Pre-compute SHA256 rainbow table
    • Brute-force email confirmation links
    • Account takeover via unauthorized email confirmation

    Recommendation

    • Replace with cryptographically secure generation:
    #createRandomHex(): string {  // Generate 32 cryptographically secure random bytes (256 bits)  return crypto.randomBytes(32).toString('hex');}

    Apply to both locations:

    • /apps/api/src/users/users.service.ts:230
    • /apps/api/src/teams/teams.service.ts:249
  8. Race Condition in Project Creation Bypasses Trial Limits

    State

    Severity

    Severity: Medium

    ≈

    Likelihood: High

    ×

    Impact: Medium

    Submitted by

    dreyand


    Description

    Trial teams are limited to 1 project, but this limit can be bypassed via race condition. The check for existing projects (line 37-38) and the project creation (line 48-51) are not atomic, allowing simultaneous requests to both pass the validation before either completes.

    Vulnerable code in projects.service.ts:

    if (team.category === KeyCategory.trial) {  const projects = await this.projectsPersistenceService.findByTeamId(teamId);  // TOCTOU    if (projects.length >= MAX_PROJECTS_PER_TRIAL_TEAM) {  // Check    throw new HttpException('Maximum projects per team was reached', 422);  }}
    const project = await this.projectsPersistenceService.create(teamId, createProjectDto);  // Use

    Timeline:

    • T0: Request A checks count → 0 projects ✓
    • T1: Request B checks count → 0 projects ✓ (before A completes)
    • T2: Request A creates project 1
    • T3: Request B creates project 2
    • Result: 2 projects created, limit bypassed

    Steps To Reproduce

    1. Create a trial team with 0 existing projects

    2. Prepare two identical requests in Burp Suite Repeater:

    POST /v1/teams/a64807c9-c824-44a4-b3bb-379f2c8b3727/projects HTTP/2Host: api.stakek.itContent-Type: application/jsonAuthorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
    {"name":"test1","description":"test1"}
    POST /v1/teams/a64807c9-c824-44a4-b3bb-379f2c8b3727/projects HTTP/2Host: api.stakek.itContent-Type: application/jsonAuthorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
    {"name":"test2","description":"test2"}
    1. Add both to a group tab in Burp Suite

    2. Click "Send group in parallel (single-packet attack)"

    3. Observe both requests return 201 Created

    4. Verify 2 projects exist by sending a request to /v1/teams/<uuid>/projects:

    HTTP/2 200 OKDate: Fri, 28 Nov 2025 06:16:17 GMTContent-Type: application/json; charset=utf-8Content-Length: 707X-Powered-By: ExpressAccess-Control-Allow-Origin: *Etag: W/"2c3-fhym83hXaHS7S7Ct867JtDvEWhw"
    [{"id":"402b3547-2a2c-4c0f-9ae5-2c6e825e39a1","createdAt":"2025-11-28T06:04:43.014Z","updatedAt":"2025-11-28T06:04:43.014Z","autoComplaintBansEnabled":false,"deletedAt":null,"description":"test1","name":"test1","revshare":null,"tvlReportEnabled":false,"tvlReportFrequency":null,"hyperlineCustomerId":null,"teamId":"a64807c9-c824-44a4-b3bb-379f2c8b3727"},{"id":"7b60cdc3-4828-44d1-a65a-cc118ee47172","createdAt":"2025-11-28T06:04:43.015Z","updatedAt":"2025-11-28T06:04:43.015Z","autoComplaintBansEnabled":false,"deletedAt":null,"description":"test2","name":"test2","revshare":null,"tvlReportEnabled":false,"tvlReportFrequency":null,"hyperlineCustomerId":null,"teamId":"a64807c9-c824-44a4-b3bb-379f2c8b3727"}]

    Recommendation

    Implement atomic check-and-insert using database constraints or pessimistic locking:

    • Option 1: Database constraint (best)

    ** Option 2: Database transaction with row locking*

    • Option 3: Distributed lock with Redis
  9. Race Condition during API key creation bypasses Trial Limits

    State

    Acknowledged

    PR #3967

    Severity

    Severity: Medium

    ≈

    Likelihood: High

    ×

    Impact: Medium

    Submitted by

    dreyand


    Description

    API keys are limited to 1 project, but this limit can be bypassed via race condition. The check for existing API keys (line 37-38) and the API key creation (line 48-51) are not atomic, allowing simultaneous requests to both pass the validation before either completes.

    Vulnerable code in keys.service.ts:

    public async create(    teamId: string,    projectId: string,    createKeyDto: CreateKeyDto,  ): Promise<Key> {    const team: Team = await this.teamService.findOne({      id: teamId,    });
        if (team.category === KeyCategory.trial) {      const keys: Key[] = await this.keysPersistenceService.find({ // TOCTOU        project: {          id: projectId,        },      });
          if (keys.length >= MAX_KEYS_PER_TRIAL_PROJECT) { // Check         throw new HttpException(          'Maximum trial keys per project was reached',          HttpStatus.UNPROCESSABLE_ENTITY,        );      }    }
        createKeyDto.category = team.category;
        const key: Key = await this.keysPersistenceService.create(      projectId,      createKeyDto,    ); // USE
        return key;  }

    Timeline:

    • T0: Request A checks count → 0 projects ✓
    • T1: Request B checks count → 0 projects ✓ (before A completes)
    • T2: Request A creates project 1
    • T3: Request B creates project 2
    • Result: 2 projects created, limit bypassed

    Steps To Reproduce

    1. Create a trial team with 0 existing projects

    2. Prepare two identical requests in Burp Suite Repeater:

    POST /v1/teams/a64807c9-c824-44a4-b3bb-379f2c8b3727/projects/402b3547-2a2c-4c0f-9ae5-2c6e825e39a1/keys HTTP/2Host: api.stakek.itContent-Type: application/jsonAuthorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
    {"name":"api1","info":"api1"}
    POST /v1/teams/a64807c9-c824-44a4-b3bb-379f2c8b3727/projects/402b3547-2a2c-4c0f-9ae5-2c6e825e39a1/keys HTTP/2Host: api.stakek.itContent-Type: application/jsonAuthorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
    {"name":"api2","info":"api2"}
    1. Add both to a group tab in Burp Suite

    2. Click "Send group in parallel (single-packet attack)"

    3. Observe both requests return 201 Created

    4. Verify 2 projects exist by sending a request to /v1/teams/<uuid>/projects/<uuid>/keys:

    HTTP/2 200 OKDate: Fri, 28 Nov 2025 06:26:21 GMTContent-Type: application/json; charset=utf-8Content-Length: 647X-Powered-By: ExpressAccess-Control-Allow-Origin: *Etag: W/"287-Pm/qQmKoncewuadR5hG+AbvlYEs"
    [{"id":"1fe8ce0c-8f30-4ddf-ade4-6f28df53a639","createdAt":"2025-11-28T06:21:45.191Z","updatedAt":"2025-11-28T06:21:45.191Z","apiKey":"4b8bcd2b-beb7-4bff-b3d3-e072b8930f7d","deletedAt":null,"info":"api1","category":"trial","name":"api1","lastUsedAt":null,"properties":null,"projectId":"402b3547-2a2c-4c0f-9ae5-2c6e825e39a1"},{"id":"323e53a0-c8bd-4be7-b550-0f22f24e6145","createdAt":"2025-11-28T06:21:45.179Z","updatedAt":"2025-11-28T06:21:45.179Z","apiKey":"4fc52f70-aded-4370-9673-8032b892716a","deletedAt":null,"info":"api2","category":"trial","name":"api2","lastUsedAt":null,"properties":null,"projectId":"402b3547-2a2c-4c0f-9ae5-2c6e825e39a1"}]

    Recommendation

    Implement atomic check-and-insert using database constraints or pessimistic locking:

    • Option 1: Database constraint (best)

    ** Option 2: Database transaction with row locking*

    • Option 3: Distributed lock with Redis
  10. Partial API key leak due to exposed Prometheus Metrics at /v1/metrics

    State

    Severity

    Severity: Medium

    ≈

    Likelihood: Medium

    ×

    Impact: Low

    Submitted by

    dreyand


    Description

    The production endpoint GET /v1/metrics is exposed without authentication, returning 68 KB of Prometheus metrics that leak partial API keys (e.g., 7c88cfa1), exact controller/action usage, request volumes per customer, response times, RPC provider/network usage, and error patterns. This information allows adversaries to profile customers, map attack surfaces, and optimize attacks based on live operational data.

    The related code can be found inside app.module.ts:

    export class AppModule implements NestModule {  public configure(consumer: MiddlewareConsumer): void {    consumer      .apply(MetricsMiddleware)      .exclude({ path: 'v1/metrics', method: RequestMethod.ALL })      .forRoutes({ path: 'v1/*', method: RequestMethod.ALL })      .apply(RequestLoggerMiddleware)      .forRoutes({ path: '*', method: RequestMethod.ALL });  }}

    Proof of Concept

    1. Send an unauthenticated request:
    $ curl https://api.stakek.it/v1/metrics
    1. Observe the 200 OK response containing Prometheus metrics such as:
    api_http_request_rate_gauge{...,apiKey="7c88cfa1"} 1   rpc_request_total_counter{rpcProvider="quickNode",rpcNetwork="base"} 1968

    Recommendation

    Restrict /v1/metrics so it is not publicly accessible in production: require an internal monitoring guard/API key, IP allowlisting, or bind the metrics server to an internal interface only. Additionally, remove API key identifiers from metric labels to prevent customer fingerprinting even for authorized scrapers.

  11. Non-Atomic Team/User Creation Enables Super Admin Dashboard DoS via Orphan Teams

    State

    Severity

    Severity: Medium

    ≈

    Likelihood: High

    ×

    Impact: Medium

    Submitted by

    dreyand


    Description

    The POST /v1/teams endpoint creates a Team and then, in separate, non-transactional operations, creates the initial admin user and triggers email/Slack side effects in teams.service.ts:

    public async create(createTeamDto: CreateTeamDto): Promise<Team> {  const team: Team = await this.teamsPersistenceService.create(createTeamDto);
      const user: User = await this.createAdminUser(createTeamDto, team.id);
      await this.mailService.userActivationPending(user);  await this.slackService.sendTeamRegisteredNotification(team, user);
      return team;}

    If any of the subsequent steps (admin user creation, mail, Slack) fails after the Team row is inserted, the team remains persisted without any associated admin user, leading to inconsistent state. This was observed in production where a team (e.g. 59e7be01-e7fd-4517-a673-477c80064477) existed without a user, breaking the super admin dashboard flow which assumes every team has at least one admin user (and even throws when it does not):

    const teamsUserAdmins: User[] = await this.usersService.findTeamsAdminUser(  teams.map((team) => team.id),);
    const teamIdToAdminUsersMap: Map<string, User[]> =  this.buildTeamIdToAdminUsersMap(teamsUserAdmins);
    const teamDtos: TeamDto[] = teams.map((team: Team) => {  const adminUsers: User[] | undefined = teamIdToAdminUsersMap.get(team.id);
      if (adminUsers === undefined) {    throw new NotFoundException(      `Found no admin user for team "${team.id}"`,    );  }
      return new TeamDto(team, adminUsers);});

    Proof of Concept

    1. Trigger a failure in one of the post-team-creation steps during POST /v1/teams, for example:
    POST /v1/teams HTTP/2Host: api.stakek.itPragma: no-cacheCache-Control: no-cacheContent-Type: application/jsonSec-Ch-Ua-Platform: "Linux"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjQ5NmUzODI5LTQyNTItNGVlMi05ZjFjLWU4MjM5MjM3YmMwYiIsImFjY2Vzc0xldmVsIjoiYWRtaW4iLCJpYXQiOjE3NjQyNzkzMjUsImV4cCI6MTc2NDM2NTcyNX0.3iFFYXnm1gagEhi1cUJFX6iw3Z0etB4r0HknhxgRUcoUser-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36Accept: application/json, text/plain, */*Sec-Ch-Ua: "Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"Sec-Ch-Ua-Mobile: ?0Origin: https://dashboard.yield.xyzSec-Fetch-Site: cross-siteSec-Fetch-Mode: corsSec-Fetch-Dest: emptyReferer: https://dashboard.yield.xyz/Accept-Encoding: gzip, deflate, brAccept-Language: en-US,en;q=0.9,sr;q=0.8Priority: u=1, iContent-Length: 404
    
    {"id":"a64807c9-c824-44a4-b3bb-379f2c8b3727","createdAt":"2025-11-26T12:07:24.137Z","updatedAt":"2025-11-26T12:57:43.555Z","activated":true,"deletedAt":null,"contactDetails":{"telegram":""},"category":"trial","name":"Spearbit AAAAAAA","serviceConditionsAcceptedAt":"2025-11-26T12:57:42.985Z","type":"integrator","providerId":null,"revshare":0.9,"oavEnabled":false,"referredBy":null,"referralCode":null}

    Response:

    POST /v1/teams HTTP/2Host: api.stakek.itPragma: no-cacheCache-Control: no-cacheContent-Type: application/jsonSec-Ch-Ua-Platform: "Linux"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjQ5NmUzODI5LTQyNTItNGVlMi05ZjFjLWU4MjM5MjM3YmMwYiIsImFjY2Vzc0xldmVsIjoiYWRtaW4iLCJpYXQiOjE3NjQyNzkzMjUsImV4cCI6MTc2NDM2NTcyNX0.3iFFYXnm1gagEhi1cUJFX6iw3Z0etB4r0HknhxgRUcoUser-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36Accept: application/json, text/plain, */*Sec-Ch-Ua: "Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"Sec-Ch-Ua-Mobile: ?0Origin: https://dashboard.yield.xyzSec-Fetch-Site: cross-siteSec-Fetch-Mode: corsSec-Fetch-Dest: emptyReferer: https://dashboard.yield.xyz/Accept-Encoding: gzip, deflate, brAccept-Language: en-US,en;q=0.9,sr;q=0.8Priority: u=1, iContent-Length: 404
    
    {"id":"a64807c9-c824-44a4-b3bb-379f2c8b3727","createdAt":"2025-11-26T12:07:24.137Z","updatedAt":"2025-11-26T12:57:43.555Z","activated":true,"deletedAt":null,"contactDetails":{"telegram":""},"category":"trial","name":"Spearbit AAAAAAA","serviceConditionsAcceptedAt":"2025-11-26T12:57:42.985Z","type":"integrator","providerId":null,"revshare":0.9,"oavEnabled":false,"referredBy":null,"referralCode":null}
    1. Inspect the database and note that:
    • A new Team row exists (e.g., 59e7be01-e7fd-4517-a673-477c80064477),
    • No corresponding admin User exists for that team.
    1. Access the super admin dashboard and observe errors due to teams that lack an admin user.

    Recommendation

    • Wrap the entire team creation flow (team insert, admin user creation, and any critical side effects that must be atomic from a domain perspective) in a single database transaction so that if user creation or downstream operations fail, the team insert is rolled back and no orphan teams are persisted. Additionally, consider hardening the dashboard/query logic to gracefully handle (or repair) teams without admin users instead of throwing, and ensure that CreateTeamDto validation rejects malformed or partial bodies that can cause downstream failures.

Low Risk2 findings

  1. ECS Execution Role Over-Permission

    State

    Severity

    Severity: Low

    ≈

    Likelihood: Low

    ×

    Impact: High

    Submitted by

    mikey96


    Summary

    • Role: stakekit-ecs-task-execution-role (Terraform aws_iam_role.ecs_task_execution)
    • Issue: Execution role attaches AWS-managed AmazonS3FullAccess plus an inline Secrets Manager policy for Wiz credentials. Every ECS task (API, cron, indexer) uses this shared role, so compromising any container grants read/write to all S3 buckets and direct access to the Wiz secrets.
    • Impact: Attackers with container access can exfiltrate or tamper with every bucket and steal Wiz registry/sensor credentials - far beyond what’s needed for pulling images or shipping logs.

    Evidence

    resource "aws_iam_role" "ecs_task_execution" { ... }
    resource "aws_iam_role_policy_attachment" "task_s3" {  role       = aws_iam_role.ecs_task_execution.name  policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"}
    resource "aws_iam_policy" "execution_secrets" {  policy = jsonencode({    Statement = [{      Effect = "Allow"      Action = "secretsmanager:GetSecretValue"      Resource = [        data.terraform_remote_state.wiz_registry_secrets.outputs.secret_arn,        data.terraform_remote_state.wiz_sensor_client_id_secrets.outputs.secret_arn,        data.terraform_remote_state.wiz_sensor_client_secret_secrets.outputs.secret_arn,      ]    }]  })}

    All ECS services reference execution_role_arn = data.terraform_remote_state.iam.outputs.ecs_task_execution_iam_role_arn, so the same privileges apply everywhere.

    Impact

    • Data exposure: Every application container can read or delete any S3 object (customer exports, logs, Terraform state backups, etc.).
    • Credential theft: Inline secrets policy reveals Wiz registry + sensor credentials; combined with S3 read/write, this can aid lateral movement.
    • Amplified compromise: A single container exploit turns into full-account S3 compromise, even if the app only needed CloudWatch log access.
    1. Per-service execution roles: Instead of one global execution role, create scoped roles per ECS service with only the permissions they need.
    2. Drop AmazonS3FullAccess: If tasks don’t touch S3, remove the policy entirely. Otherwise, craft a custom policy granting access only to specific bucket ARNs (read-only for logs, etc.).
    3. Scope Secrets Manager: Limit secretsmanager:GetSecretValue to the minimal set required (or mount those secrets via ECS secrets so the execution role doesn’t need direct access).
    4. Monitoring: Add CloudTrail alerts for s3:* actions initiated by ECS task roles to detect abuse while remediation is in progress.
  2. Missing CSP may allow XSS

    State

    Fixed

    PR #45

    Severity

    Severity: Low

    ≈

    Likelihood: Low

    ×

    Impact: Low

    Submitted by

    dreyand


    Description

    The admin dashboard is rendered without a Content-Security-Policy (CSP). Multiple external scripts, fonts, and images are loaded, but no restrictions are applied. Without CSP (or Subresource Integrity), any XSS injection or compromised third-party script can execute arbitrary code in the admin context, steal JWTs from localStorage, or alter admin flows.

    Recommendation

    Deploy a restrictive CSP and allow only the domains the application actually uses. Suggested baseline:

    default-src 'self';script-src 'self' https://www.googletagmanager.com https://www.google-analytics.com;style-src 'self' 'unsafe-inline';img-src 'self' data: <required image domains>;font-src 'self';connect-src 'self' https://api.stakek.it;frame-ancestors 'none';base-uri 'self';form-action 'self';

    Add this header in next.config.js (headers()), test in staging, fix violations, and tighten incrementally. Consider removing or limiting Google Tag Manager from the admin panel if not critical.

Informational1 finding

  1. Multiple Vulnerable NPM Dependencies

    State

    Acknowledged

    Severity

    Severity: Informational

    ≈

    Likelihood: Low

    ×

    Impact: High

    Submitted by

    dreyand


    Description

    Multiple outdated and vulnerable dependencies were identified across the admin dashboard and backend monorepo services of the StakeKit platform (Yield.xyz backend). Several of these packages are affected by known critical and high-severity vulnerabilities, including cryptographic flaws, denial-of-service vulnerabilities, SQL injection, and potential remote code execution vectors.

    The most critical findings include vulnerabilities in:

    • elliptic (Critical - Private key extraction via ECDSA signature malleability - GHSA-vjh7-7g9h-fjfh)
    • pbkdf2 (Critical - Returns static/predictable keys - GHSA-v62p-rq8g-8h59)
    • sha.js and cipher-base (Critical - Type confusion leading to hash manipulation)
    • vm2 (Critical - Sandbox escape with no available fix - package deprecated)
    • typeorm (High - SQL injection via crafted requests - GHSA-q2pj-6v73-8rgj)
    • axios (High - Multiple SSRF and DoS vulnerabilities)
    • multer (High - Multiple DoS vulnerabilities)
    • next (Moderate - SSRF and content injection vulnerabilities)

    These vulnerabilities affect cryptographic operations used throughout the blockchain integration layer (libs/chains with 74,000+ affected dependency paths), the main API authentication system, database operations, and the admin dashboard. Given that StakeKit handles staking operations and yield management for major wallets (Ledger, Zerion, Tangem) serving 4M+ users with hundreds of millions in monthly volume, these vulnerabilities represent a significant security risk.

    Vulnerability Summary

    Total Vulnerabilities Identified: 110

    • Critical Severity: 10
    • High Severity: 53
    • Moderate Severity: 25
    • Low Severity: 22

    Admin Dashboard (admin-dashboard/)

    PackageVersionFixed-InSeverityVulnerability IDDescription
    form-data4.0.2>=4.0.4CriticalGHSA-fjxv-7rqg-78g4Unsafe random boundary generation
    axios1.9.0>=1.12.0HighGHSA-4hjh-wcwx-xvwjDoS via unbounded response size
    next14.2.26>=14.2.32ModerateGHSA-4342-x723-ch2fSSRF via middleware redirect
    next14.2.26>=14.2.31ModerateGHSA-g5qg-72qw-gw5vCache key confusion (image optimization)
    next14.2.26>=14.2.31ModerateGHSA-xv57-4mr9-wg8vContent injection (image optimization)
    glob10.3.10>=10.5.0HighGHSA-5j98-mcp5-4vw2Command injection via CLI
    validator13.15.0>=13.15.20ModerateGHSA-9965-vmph-33xxURL validation bypass

    Backend Monorepo (stakekit-monorepo/)

    Critical Severity Vulnerabilities

    PackageVersionFixed-InVulnerability IDAffected PathsDescription
    elliptic6.5.4>=6.6.1GHSA-vjh7-7g9h-fjfh825 pathsPrivate key extraction via ECDSA signature on malformed input
    pbkdf23.1.2>=3.1.3GHSA-v62p-rq8g-8h599,512 pathsReturns static keys when given Uint8Array input
    pbkdf23.1.2>=3.1.3GHSA-h7cp-r72f-jxh69,512 pathsReturns uninitialized/zero-filled memory for invalid algorithms
    sha.js2.4.11>=2.4.12GHSA-95m3-7q98-8xr574,387 pathsHash manipulation via missing type checks
    cipher-base1.0.4>=1.0.5GHSA-cpq7-6gpm-g9rc72,026 pathsHash rewind via missing type checks
    vm23.9.19No fix availableGHSA-cchq-frgv-rjh52 pathsSandbox escape (package deprecated)
    vm23.9.19No fix availableGHSA-g644-9gfx-q4q42 pathsSandbox escape (package deprecated)

    High Severity Vulnerabilities

    PackageVersionFixed-InVulnerability IDDescription
    typeorm0.3.20>=0.3.26GHSA-q2pj-6v73-8rgjSQL injection via repository.save/update
    axios1.7.2>=1.7.4GHSA-8hc4-vh64-cxmjSSRF via malformed URLs
    axios1.7.2>=1.12.0GHSA-4hjh-wcwx-xvwjDoS via unbounded response size
    axios (multiple)0.24.0-0.28.1>=1.8.2GHSA-jr5f-v2jv-69x6SSRF + credential leakage via absolute URLs
    multer1.4.4-lts.1>=2.0.2GHSA-fjgf-rc76-4x9pDoS via malformed request
    multer1.4.4-lts.1>=2.0.1GHSA-g5hg-p3ph-g8qgDoS via unhandled exception
    multer1.4.4-lts.1>=2.0.0GHSA-4pg4-qvpc-4q3hDoS via crafted multipart data
    secp256k13.8.0, 4.0.3, 5.0.0>=3.8.1, >=4.0.4, >=5.0.1GHSA-584q-6j8j-r5pmPrivate key extraction over ECDH
    body-parser1.20.2>=1.20.3GHSA-qwcr-r2fm-qrc7DoS when URL encoding enabled
    path-to-regexp0.1.7, 3.2.0>=0.1.10, >=3.3.0GHSA-9wv6-86v2-598jReDoS via backtracking regex
    ws7.4.6, 3.3.3>=7.5.10, >=5.2.4GHSA-3h5v-q93c-6h6qDoS via HTTP header flood
    ip1.1.9, 2.0.1No fix availableGHSA-2p57-rm9w-gvfpSSRF via isPublic bypass
    base-x2.0.6, 4.0.0, 5.0.0>=2.0.11, >=4.0.1, >=5.0.1GHSA-xq7p-g2vc-g82pHomograph attack via unicode validation bypass
    bigint-buffer1.1.5No fix availableGHSA-3gc7-fjrx-p6mgBuffer overflow via toBigIntLE()
    cross-spawn6.0.5, 7.0.3>=6.0.6, >=7.0.5GHSA-3xgq-45jj-v275ReDoS vulnerability
    valibot0.36.0>=1.2.0GHSA-vqpr-j7v3-hqw9ReDoS in emoji regex
    tar-fs2.1.2, 3.0.9>=2.1.4, >=3.1.1GHSA-vj76-c3g6-qr5vSymlink bypass + path traversal
    node-forge1.3.1>=1.3.2GHSA-554w-wpv2-vw27ASN.1 unbounded recursion DoS
    @nestjs/common10.3.10>=10.4.16GHSA-cj7v-w2c7-cp7cRCE via Content-Type header

    Impact Analysis

    Cryptographic Vulnerabilities: The elliptic, pbkdf2, sha.js, and cipher-base packages are used extensively throughout the blockchain integration layer (libs/chains) affecting 140,000+ dependency paths. These handle:

    • ECDSA signature verification for blockchain transactions
    • Password/key derivation for authentication
    • Hash operations for data integrity
    • Cryptographic operations across 70+ supported blockchain networks

    Exploitation could lead to private key extraction, authentication bypass, and transaction manipulation.

    API Security: TypeORM SQL injection affects the core database layer used for user data, API keys, teams, projects, and transaction records. Combined with axios SSRF vulnerabilities in external API calls (Slack webhooks, Cosmos RPC, blockchain indexers), attackers could access internal services or exfiltrate data.

    File Upload Security: Multer vulnerabilities in the validator CSV upload endpoint (/v1/validator/providers/:id) allow for denial-of-service attacks that could crash the API service.

    Sandbox Escape: vm2 is used in email template processing ([email protected]) and has multiple critical sandbox escape vulnerabilities with no available fix as the package is deprecated.

    Recommendation

    Immediate Actions

    1. Update all cryptographic libraries to address private key extraction and hash manipulation vulnerabilities:
    cd stakekit-monorepopnpm update elliptic@latest pbkdf2@latest sha.js@latest cipher-base@latest --recursive
    1. Patch TypeORM SQL injection vulnerability:
    cd stakekit-monorepo/apps/apipnpm update typeorm@^0.3.26
    1. Remove or replace vm2 dependency:
    # vm2 is deprecated with no fix available# Replace [email protected] or find alternative email template processor# Alternatively, disable HTML email features temporarily
    1. Update @nestjs/common to prevent RCE:
    cd stakekit-monorepo/apps/apipnpm update @nestjs/common@^10.4.16 @nestjs/core@^10.4.16 @nestjs/platform-express@^10.4.16

    High Priority

    1. Update axios across all services to fix SSRF and DoS vulnerabilities:
    # Admin dashboardcd admin-dashboardpnpm update axios@^1.12.0
    # Backend monorepocd stakekit-monorepopnpm update axios@^1.12.0 --recursive
    1. Update multer to fix DoS vulnerabilities:
    cd stakekit-monorepo/apps/apipnpm update multer@^2.0.2
    1. Update Next.js in admin dashboard:
    cd admin-dashboardpnpm update next@^14.2.32 @next/third-parties@latest
    1. Update secp256k1 cryptographic library:
    cd stakekit-monorepopnpm update secp256k1@latest --recursive

    Medium Priority (Within 1 Week)

    1. Update remaining high-severity packages:
    cd stakekit-monorepopnpm update body-parser@^1.20.3 path-to-regexp@latest ws@latest base-x@latest --recursive
    1. Address packages with no fix available:
      • vm2: Find alternative to inline-css or disable email HTML rendering
      • ip: Implement IP validation workaround in geolocation guard
      • bigint-buffer: Find alternative library for Solana buffer operations

    Long-Term Recommendations

    1. Implement automated dependency monitoring:
    # Enable GitHub Dependabot# Set up Snyk or similar tool for continuous monitoringpnpm audit --fix
    1. Establish dependency update policy:

      • Weekly automated dependency scans
      • Monthly security patch reviews
      • Quarterly major version updates
      • Pin critical dependencies to specific versions
    2. Add pre-commit hooks to prevent vulnerable dependencies:

    # Add to .husky/pre-commitpnpm audit --audit-level=high
    1. Create Software Bill of Materials (SBOM):

      • Track all dependencies across repositories
      • Monitor vulnerability databases (NVD, GitHub Advisory)
      • Set up alerts for new CVEs affecting your stack
    2. Security testing:

      • Add integration tests for file upload limits
      • Test SQL injection protection in TypeORM queries
      • Validate cryptographic operations after updates
      • Perform regression testing on authentication flows
    3. Consider migrating from deprecated packages:

      • Replace vm2 completely (no security support)
      • Evaluate alternatives for packages with slow security response
      • Consider using maintained alternatives for ip package

    Priority should be given to cryptographic vulnerabilities (elliptic, pbkdf2, sha.js) and SQL injection (typeorm) as these have the highest potential impact on the platform handling millions in user funds.