DEV Community

Santosh Kumar Puppala
Santosh Kumar Puppala

Posted on

The parent check passed, so the forks' secrets shipped anyway — cleartext CI/CD secrets in OneDev (GHSA-p3rv-f672-8x57)

TL;DR

  • What: GET /~api/projects/{id}/forks in OneDev (self-hosted Git + CI/CD + issue tracker) authorized the parent project, then returned the raw Collection<Project> entity of its forks — and Jackson serialized every field, including each fork's cleartext CI/CD job secrets.
  • Impact: Anyone who can read the parent harvests the live deploy tokens / registry / cloud credentials of every fork — including private forks they have no access to. If the parent is a public project with anonymous access, it's unauthenticated. Missing authorization leading to information exposure (CWE-862 → CWE-200).
  • Fixed in: OneDev 16.0.0 (commit 1e902fdd). Advisory GHSA-p3rv-f672-8x57, published 7 August 2026, rated Moderate, credited to me as reporter. CVE requested and pending.

Why you should care

CI/CD job secrets are the crown jewels of a build system. They're not passwords a human types — they're the deploy tokens, container-registry logins, and cloud credentials your pipeline uses to push to production. A read-only information leak sounds unexciting until you notice what is being read: live credentials that grant write access somewhere else. That's how "just an info disclosure" turns into a foothold for real downstream compromise.

This one is also a good teaching case because nothing in the code looks wrong at a glance. There is an authorization check. It's just checking the wrong object. And the leak rides in on a serialization default that the endpoint author never thought about.

A note on severity

The published advisory rates this Moderate, and that is the number to quote. My own scoring was higher — 8.6, using CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N — and the whole difference sits in one metric: scope.

I set S:C because the data disclosed is credentials for other systems. The confidentiality loss doesn't stop at OneDev; a leaked deploy token spends just as well against your registry or your cloud account. Scored strictly against the vulnerable component alone, S:U and a Moderate rating are perfectly defensible.

I'm flagging the disagreement rather than quietly picking whichever number flatters the writeup. Reasonable people score credential disclosure differently, and the maintainer's rating is the one on the public record.

The setup

OneDev is a Java application (Apache Shiro for auth, JAX-RS for the REST layer, Jackson for JSON). Projects can be forked, and each project — parent or fork — carries its own buildSetting, which holds a list of jobSecrets. A JobSecret has a name and a value, and that value is the actual credential the CI job uses at runtime.

Forks are their own projects with their own permissions. I can be allowed to read a parent project while having zero access to a private fork of it. The REST API reflects that: GET /~api/projects/{forkId} on a fork I can't see returns 401/403. So far, correct.

The interesting endpoint is the one that lists a project's forks.

The bug

Here's the handler, verified at the shipped tag v15.1.6:

// ProjectResource.java:153-161
@GET
@Path("/{projectId}/forks")
public Collection<Project> getForks(@PathParam("projectId") Long projectId) {
    Project project = projectService.load(projectId);
    if (!SecurityUtils.canAccessProject(project))     // <-- checks the PARENT
        throw new UnauthorizedException();
    return project.getForks();                        // <-- raw Collection<Project> entity
}
Enter fullscreen mode Exit fullscreen mode

Two decisions combine badly:

  1. The authorization check is on project — the parent you named in the URL. There is no per-fork check. Once you can read the parent, the method trusts you with whatever it returns.
  2. It returns the raw Collection<Project> entity — not a DTO. Every other project endpoint returns a ProjectData DTO with ~13 hand-picked fields and no buildSetting. This one hands back the persistence objects directly.

Now bring in Jackson's global configuration:

// ObjectMapperProvider.java:128-129
mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);
Enter fullscreen mode Exit fullscreen mode

FIELD visibility set to ANY means every private field serializes unless it's explicitly ignored. So look at what's on Project:

// Project.java:423-425
@Lob
@Column
private ProjectBuildSetting buildSetting;   // no @JsonIgnore, not transient
Enter fullscreen mode Exit fullscreen mode

buildSetting has no @JsonIgnore and isn't transient, so it serializes. It contains jobSecrets, and:

// JobSecret.java
private String name;
private String value;   // plain String — NOT encrypted at rest

@Secret
public String getValue() { return value; }   // @Secret is a UI display hint on the getter
Enter fullscreen mode Exit fullscreen mode

The @Secret annotation is a UI convenience — it tells the web frontend to render a masked field. It lives on the getter, and under FIELD visibility Jackson reads the private value field directly and ignores the getter entirely. So the annotation that looks like it protects the secret does nothing here. The value is stored in cleartext and serialized in cleartext.

Five steps, start to finish:

The "aha"

The endpoint authorized the object you asked for, but leaked the objects it returned — and the one field everyone assumed was protected was only cosmetically masked.

Proof of concept (benign)

Confirmed live on 1dev/server:15.1.6. The whole thing is a marker, not a weapon — the "secret" is a string I set to supersecret123.

Setup: a parent project parent-proj (id 1) and a private fork fork-a (id 2, forkedFromId=1) that has one job secret, DEPLOY_TOKEN = supersecret123, set through the admin-only settings endpoint.

Low-privilege case — attacker granted Code Reader on the parent only, no access to the fork:

# Control: direct access to the fork is correctly denied
$ curl -su attacker:pw http://localhost:6610/~api/projects/2
HTTP/1.1 403 Forbidden

# The leak: list the parent's forks
$ curl -su attacker:pw http://localhost:6610/~api/projects/1/forks
HTTP/1.1 200 OK
[ { "id": 2, "name": "fork-a",
    "buildSetting": { "jobSecrets": [
      { "name": "DEPLOY_TOKEN", "value": "supersecret123" } ] } } ]
Enter fullscreen mode Exit fullscreen mode

Unauthenticated case — parent is public with anonymous access enabled:

# Control: the fork itself, no auth
$ curl -s http://localhost:6610/~api/projects/2
HTTP/1.1 401 Unauthorized

# The leak, no credentials at all
$ curl -s http://localhost:6610/~api/projects/1/forks
HTTP/1.1 200 OK
[ ... "value": "supersecret123" ... ]
Enter fullscreen mode Exit fullscreen mode

The asymmetry is the proof: the fork is denied directly (401/403), yet the same caller pulls the fork's live secret out through the parent's /forks. That value never appears in any DTO-based endpoint — it only escapes here.

The fix

The patch is commit 1e902fdd — "fix: Job secrets defined in forked projects may leak (OD-2822)" — shipped in OneDev 16.0.0. One file, three lines:

-    public Collection<Project> getForks(@PathParam("projectId") Long projectId) {
+    public Collection<ProjectData> getForks(@PathParam("projectId") Long projectId) {
         Project project = projectService.load(projectId);
         if (!SecurityUtils.canAccessProject(project))
             throw new UnauthorizedException();
-        return project.getForks();
+        return project.getForks().stream().map(ProjectData::from).collect(Collectors.toList());
     }
Enter fullscreen mode Exit fullscreen mode

Note what did not change: the authorization check is still on the parent, and there is still no per-fork check. The fix is entirely on the serialization side — the return type goes from Collection<Project> to Collection<ProjectData>, so the endpoint hands back the same DTO its sibling endpoints already used. ProjectData has no buildSetting field, so there is nothing for Jackson to reach into regardless of who is asking.

That is a legitimate fix and arguably the better one. The parent check was never the problem; the problem was that a method with a correct-looking guard returned objects the guard had never evaluated. Narrowing the projection removes the leak at its source rather than adding a second check that a future refactor could drift away from.

Belt-and-suspenders hardening worth doing in any similar codebase: add @JsonIgnore to Project.buildSetting, and encrypt JobSecret.value at rest so a stray serialization is inert rather than catastrophic.

Takeaways

  • Authorize the data you return, not just the object you were handed. A check on the URL's target says nothing about the child records that ride along in the response. Every returned collection needs its own scoping.
  • Never serialize persistence entities from an API. DTOs aren't ceremony — they're an allowlist. The moment one endpoint returns a raw entity while its siblings return DTOs, that endpoint is leaking whatever fields got added to the entity since. Field-level Jackson visibility (Visibility.ANY) makes "add a field to the model" silently mean "expose a field in the API."
  • Getter-level annotations don't protect field-level serialization. @Secret on a getter looks like protection and isn't, once the serializer is configured to read fields. If a value must never leave the process in cleartext, encrypt it at rest — don't rely on a display hint.

Disclosure timeline

  • 2026-06-13 — Found and confirmed with a live Docker PoC on 1dev/server:15.1.6 (both the unauthenticated and low-privilege cases).
  • 2026-06-25 — Reported to the maintainer via coordinated disclosure, distinguishing it from the recent fork/project authorization CVEs (CVE-2026-11438/39/40/41), which are write-side authz fixed in 15.0.6 and did not cover this /forks read.
  • 2026-06-26 — Maintainer confirmed, asked for a 30-day window, and committed to publishing an advisory.
  • 16.0.0 — Fixed (commit 1e902fdd): getForks returns a DTO. The 15.x line remains affected.
  • 2026-08-07 — Advisory GHSA-p3rv-f672-8x57 published, credited to me as reporter. CVE requested and pending.

Credit / CTA

If you run OneDev, upgrade to 16.0.0+ and rotate any job secrets on projects that had public or widely-readable parents. If you write APIs, grep your codebase for handlers that return entities instead of DTOs — that's where the next one of these lives.

Found something similar or want to compare notes on multi-tenant authz bugs? I'm always up for it.


Santosh Kumar Puppala — AI/ML Platform Architect and independent security researcher. GitHub: @Santoshkumarpuppala

Top comments (0)