Yield.xyz Pentest
Cantina Security Report
Organization
- @yieldxyz
Engagement Type
Cantina Reviews
Period
-
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
Production CI/CD Takeover via GitHub Actions
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 subjectrepo: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 branchcantina/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-identityRun output:
{ "UserId": "AROAU2ZIQY4MO5MQEDQTY:GitHubActions", "Account": "332408080152", "Arn": "arn:aws:sts::332408080152:assumed-role/github_actions_role/GitHubActions"}Impact Details
-
Arbitrary prod deployments:
github_actions_roleincludes: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.
-
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. -
Staging ≠Prod isolation: The staging workflows (
.github/workflows/staging-deployment.yml) call the same modules, which assume the prod role (before anyif inputs.environment == 'staging'logic runs). Compromising staging gives prod access automatically.
Recommended Fixes
-
Tighten IAM trust policies
- Remove
repo:stakekit/stakekit-monorepo:*. - Create separate roles per environment with explicit
subvalues:"token.actions.githubusercontent.com:sub" = "repo:stakekit/stakekit-monorepo:environment:production" - Staging workflows should assume
arn:aws:iam::<staging-account>:role/github_actions_role.
- Remove
-
GitHub environment protection
- Require
environment: productionwith manual approvals/deployment protection rules before any job can assume prod credentials. - Only allow release/tag workflows to target prod.
- Require
-
Secrets handling
- Use ECS
secretsblocks referencing Secrets Manager ARNs instead of embedding secret JSON intoenvironment. - Keep Terraform state free of plaintext secrets; rely on runtime injection.
- Use ECS
-
Monitoring & controls
- CloudTrail alerts on
sts:AssumeRoleforgithub_actions_role. - Branch protection to prevent arbitrary workflow edits targeting prod.
- CloudTrail alerts on
- Account:
Cross-Tenant Transaction Mutation (Yield API)
Summary
- Endpoints:
POST /v1/transactions/:transactionId/submit,PUT /v1/transactions/:transactionId/submit-hash - Issue: The endpoints accept any
transactionIdand mutate the underlyingTransactionrow without checking whether the caller’s API key/project owns that transaction. Ownership is stored on the parentStake(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 validtransactionIdyields 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
- Enumerate victim actions:
GET /v1/actions?address=0xVictim...(allowed for any API key). - Extract
transactionIdfrom the response. - Mutate the transaction:
or
POST /v1/transactions/<victim-transaction-id>/submit{ "signedTransaction": "0xf8..."}PUT /v1/transactions/<victim-transaction-id>/submit-hash{ "hash": "0xdeadbeef..."} - 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.
Recommended Fix
- Extract
projectIdfromrequest[contextProperty].keyin the controller and pass it through the service. - Update
TransactionsPersistenceServiceto 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.
- Endpoints:
OTP Login Code Brute-Force
Summary
- Endpoint:
POST /v1/auth/login/request-codefollowed byPOST /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.
Recommended Fixes
- Rate limiting: Apply IP/email throttles (e.g., 5 OTP requests per hour per email, 10 per IP) using Redis or an external service.
- CAPTCHA or proof-of-work: Require hCaptcha/reCAPTCHA or a lightweight challenge after multiple attempts.
- Backoff / lockout: Track failed verifications and temporarily lock the account or require manual review after N wrong codes.
- Code hardening: Increase code length/entropy (e.g., 8 digits or alphanumeric) and reduce validity duration (60–120 seconds).
- Endpoint:
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:
- https://166.117.135.58/v1/yields/ethereum-renzo-ezeth-staking
- https://166.117.91.1/v1/yields/ethereum-renzo-ezeth-staking
- https://3.231.97.148/v1/yields/ethereum-renzo-ezeth-staking -> points at
*.staging.yield.xyz - https://54.146.60.5/v1/yields/ethereum-renzo-ezeth-staking
- https://34.193.21.31/v1/yields/ethereum-renzo-ezeth-staking
- https://52.205.13.89/ -> points at
*.stg.yield.xyz - https://44.197.97.1/ -> points at
*.stg.yield.xyz
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.itthat 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.
Missing Rate Limit on Login Code Request
Description
The
POST /v1/auth/login/request-codeendpoint 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
- 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]"}'-
Observe all requests succeed with no throttling
-
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
Arbitrary Account Lockout due to improper invalidation logic
Description
An attacker can prevent users from logging in by repeatedly calling
POST /v1/auth/login/request-codewith 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.tscontains logic which will invalidate the previous code tied to an email *on every request to therequest-coderoute: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
-
Victim requests login code:
-
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- Victim tries to use code "123456" -> fails (already invalidated)
- Victim receives new code "789012" -> fails (attacker invalidated it)
- 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
-
Potential Email Verification bypass due to PRNG usage
Description
Email confirmation tokens use
Math.random()(via NestJS'srandomStringGenerator()) 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
hashused to generate the email confirmation token is implemented like the following inusers.service.ts:#createRandomHex(): string { return crypto .createHash('sha256') .update(randomStringGenerator()) // Uses Math.random() .digest('hex');}The
randomStringGenerator()function from NestJS relies on the insecureuid: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);}- This produces only ~11 base36 characters = ~54 bits of entropy
- 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
Race Condition in Project Creation Bypasses Trial Limits
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); // UseTimeline:
- 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
-
Create a trial team with 0 existing projects
-
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"}-
Add both to a group tab in Burp Suite
-
Click "Send group in parallel (single-packet attack)"
-
Observe both requests return 201 Created
-
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
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
-
Create a trial team with 0 existing projects
-
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"}-
Add both to a group tab in Burp Suite
-
Click "Send group in parallel (single-packet attack)"
-
Observe both requests return 201 Created
-
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
Partial API key leak due to exposed Prometheus Metrics at /v1/metrics
Description
The production endpoint
GET /v1/metricsis 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
- Send an unauthenticated request:
$ curl https://api.stakek.it/v1/metrics- 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"} 1968Recommendation
Restrict
/v1/metricsso 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.Non-Atomic Team/User Creation Enables Super Admin Dashboard DoS via Orphan Teams
Description
The POST
/v1/teamsendpoint creates aTeamand then, in separate, non-transactional operations, creates the initial admin user and triggers email/Slack side effects inteams.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
Teamrow 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
- 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}- 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.
- 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
CreateTeamDtovalidation rejects malformed or partial bodies that can cause downstream failures.
- Trigger a failure in one of the post-team-creation steps during POST
Low Risk2 findings
ECS Execution Role Over-Permission
Summary
- Role:
stakekit-ecs-task-execution-role(Terraformaws_iam_role.ecs_task_execution) - Issue: Execution role attaches AWS-managed
AmazonS3FullAccessplus 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.
Recommended Fixes
- Per-service execution roles: Instead of one global execution role, create scoped roles per ECS service with only the permissions they need.
- 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.). - Scope Secrets Manager: Limit
secretsmanager:GetSecretValueto the minimal set required (or mount those secrets via ECSsecretsso the execution role doesn’t need direct access). - Monitoring: Add CloudTrail alerts for
s3:*actions initiated by ECS task roles to detect abuse while remediation is in progress.
- Role:
Missing CSP may allow XSS
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
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.jsandcipher-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/chainswith 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/)Package Version Fixed-In Severity Vulnerability ID Description form-data 4.0.2 >=4.0.4 Critical GHSA-fjxv-7rqg-78g4 Unsafe random boundary generation axios 1.9.0 >=1.12.0 High GHSA-4hjh-wcwx-xvwj DoS via unbounded response size next 14.2.26 >=14.2.32 Moderate GHSA-4342-x723-ch2f SSRF via middleware redirect next 14.2.26 >=14.2.31 Moderate GHSA-g5qg-72qw-gw5v Cache key confusion (image optimization) next 14.2.26 >=14.2.31 Moderate GHSA-xv57-4mr9-wg8v Content injection (image optimization) glob 10.3.10 >=10.5.0 High GHSA-5j98-mcp5-4vw2 Command injection via CLI validator 13.15.0 >=13.15.20 Moderate GHSA-9965-vmph-33xx URL validation bypass Backend Monorepo (
stakekit-monorepo/)Critical Severity Vulnerabilities
Package Version Fixed-In Vulnerability ID Affected Paths Description elliptic 6.5.4 >=6.6.1 GHSA-vjh7-7g9h-fjfh 825 paths Private key extraction via ECDSA signature on malformed input pbkdf2 3.1.2 >=3.1.3 GHSA-v62p-rq8g-8h59 9,512 paths Returns static keys when given Uint8Array input pbkdf2 3.1.2 >=3.1.3 GHSA-h7cp-r72f-jxh6 9,512 paths Returns uninitialized/zero-filled memory for invalid algorithms sha.js 2.4.11 >=2.4.12 GHSA-95m3-7q98-8xr5 74,387 paths Hash manipulation via missing type checks cipher-base 1.0.4 >=1.0.5 GHSA-cpq7-6gpm-g9rc 72,026 paths Hash rewind via missing type checks vm2 3.9.19 No fix available GHSA-cchq-frgv-rjh5 2 paths Sandbox escape (package deprecated) vm2 3.9.19 No fix available GHSA-g644-9gfx-q4q4 2 paths Sandbox escape (package deprecated) High Severity Vulnerabilities
Package Version Fixed-In Vulnerability ID Description typeorm 0.3.20 >=0.3.26 GHSA-q2pj-6v73-8rgj SQL injection via repository.save/update axios 1.7.2 >=1.7.4 GHSA-8hc4-vh64-cxmj SSRF via malformed URLs axios 1.7.2 >=1.12.0 GHSA-4hjh-wcwx-xvwj DoS via unbounded response size axios (multiple) 0.24.0-0.28.1 >=1.8.2 GHSA-jr5f-v2jv-69x6 SSRF + credential leakage via absolute URLs multer 1.4.4-lts.1 >=2.0.2 GHSA-fjgf-rc76-4x9p DoS via malformed request multer 1.4.4-lts.1 >=2.0.1 GHSA-g5hg-p3ph-g8qg DoS via unhandled exception multer 1.4.4-lts.1 >=2.0.0 GHSA-4pg4-qvpc-4q3h DoS via crafted multipart data secp256k1 3.8.0, 4.0.3, 5.0.0 >=3.8.1, >=4.0.4, >=5.0.1 GHSA-584q-6j8j-r5pm Private key extraction over ECDH body-parser 1.20.2 >=1.20.3 GHSA-qwcr-r2fm-qrc7 DoS when URL encoding enabled path-to-regexp 0.1.7, 3.2.0 >=0.1.10, >=3.3.0 GHSA-9wv6-86v2-598j ReDoS via backtracking regex ws 7.4.6, 3.3.3 >=7.5.10, >=5.2.4 GHSA-3h5v-q93c-6h6q DoS via HTTP header flood ip 1.1.9, 2.0.1 No fix available GHSA-2p57-rm9w-gvfp SSRF via isPublic bypass base-x 2.0.6, 4.0.0, 5.0.0 >=2.0.11, >=4.0.1, >=5.0.1 GHSA-xq7p-g2vc-g82p Homograph attack via unicode validation bypass bigint-buffer 1.1.5 No fix available GHSA-3gc7-fjrx-p6mg Buffer overflow via toBigIntLE() cross-spawn 6.0.5, 7.0.3 >=6.0.6, >=7.0.5 GHSA-3xgq-45jj-v275 ReDoS vulnerability valibot 0.36.0 >=1.2.0 GHSA-vqpr-j7v3-hqw9 ReDoS in emoji regex tar-fs 2.1.2, 3.0.9 >=2.1.4, >=3.1.1 GHSA-vj76-c3g6-qr5v Symlink bypass + path traversal node-forge 1.3.1 >=1.3.2 GHSA-554w-wpv2-vw27 ASN.1 unbounded recursion DoS @nestjs/common 10.3.10 >=10.4.16 GHSA-cj7v-w2c7-cp7c RCE via Content-Type header Impact Analysis
Cryptographic Vulnerabilities: The
elliptic,pbkdf2,sha.js, andcipher-basepackages 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
- 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- Patch TypeORM SQL injection vulnerability:
cd stakekit-monorepo/apps/apipnpm update typeorm@^0.3.26- 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- 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.16High Priority
- 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- Update multer to fix DoS vulnerabilities:
cd stakekit-monorepo/apps/apipnpm update multer@^2.0.2- Update Next.js in admin dashboard:
cd admin-dashboardpnpm update next@^14.2.32 @next/third-parties@latest- Update secp256k1 cryptographic library:
cd stakekit-monorepopnpm update secp256k1@latest --recursiveMedium Priority (Within 1 Week)
- Update remaining high-severity packages:
cd stakekit-monorepopnpm update body-parser@^1.20.3 path-to-regexp@latest ws@latest base-x@latest --recursive- Address packages with no fix available:
- vm2: Find alternative to
inline-cssor disable email HTML rendering - ip: Implement IP validation workaround in geolocation guard
- bigint-buffer: Find alternative library for Solana buffer operations
- vm2: Find alternative to
Long-Term Recommendations
- Implement automated dependency monitoring:
# Enable GitHub Dependabot# Set up Snyk or similar tool for continuous monitoringpnpm audit --fix-
Establish dependency update policy:
- Weekly automated dependency scans
- Monthly security patch reviews
- Quarterly major version updates
- Pin critical dependencies to specific versions
-
Add pre-commit hooks to prevent vulnerable dependencies:
# Add to .husky/pre-commitpnpm audit --audit-level=high-
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
-
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
-
Consider migrating from deprecated packages:
- Replace
vm2completely (no security support) - Evaluate alternatives for packages with slow security response
- Consider using maintained alternatives for
ippackage
- Replace
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.