javascriptsecurityapireverse-engineeringwriteup

Comick.dev Leaks User Emails Through Its Comment API

Comick.dev's comment API was leaking user emails for replies to shallow comments. I found the pattern, proved it, and reported it.

/4 min read

I've been deep in the Comick third-party scene for a while now, building tools and scripts around the site's API. If you've used Comick.dev (or remember the old Comick.io reader, RIP), you know it's the go-to for tracking manga, manhwa, and manhua. I spend a lot of time in its API responses.

So when I noticed an email field showing up inside reply_to_user.traits in comment data, it stuck out immediately. That field has no business being in a public API response.

This has already been reported to meotimdihia (Comick's developer) and patched. This post is a writeup of how the leak worked and how I validated the pattern.

What Was Happening

When you fetch comments for any title on Comick, replies to other users include a reply_to_user object that tells the frontend who the reply was directed at. Normally it just has a username. But for some replies, the full traits object from the identity service was being sent along, email and all:

{
  "reply_to_user": {
    "traits": {
      "email": "someuser@gmail.com",
      "username": "SomeUser"
    }
  }
}

The frontend never renders the email. It's only there because the backend didn't strip it before serializing. But anyone with DevTools open (or anyone curling the endpoint) could see it.

The weird part was that it didn't happen on every reply. Some reply_to_user objects had the email, others just had the username. It wasn't random though. There was a clear pattern.

The Depth Rule

Comick's comments are nested. Top-level comments are depth 0, replies to those are depth 1, replies to replies are depth 2, and so on. The API returns them as a tree with children in other_comments.

After going through enough responses, the rule became obvious:

  • Reply to a comment at depth 0 or 1reply_to_user includes the email
  • Reply to a comment at depth 2+reply_to_user only has the username

One extra condition: the reply_to_user had to actually be the author of the parent comment (the one comment_id points to). If you're replying in a thread but @'ing a different user, no email either way.

Both conditions had to be true for the email to show up.

Why It Worked That Way

My best guess is that the backend uses a richer query for shallow comments (depth 0 and 1) that JOINs against the identity service and pulls the full traits object, email included. For deeper comments, it falls back to a lighter lookup that only resolves the username. The backend never strips the email from the rich response before sending it to the client, it just serializes whatever the query returned.

Two code paths, two different levels of user detail, and neither one was scrubbing PII before it hit the wire.

Validating the Pattern

I didn't want to just eyeball this, so I wrote a Node script to test the theory against actual data. Save a comment API response as JSON, feed it to the script, and it checks every single reply.

The script flattens the nested comment tree into a flat array with depth info:

function flattenComments(comments, depth = 0) {
  const results = [];
  for (const comment of comments) {
    results.push({
      id: comment.id,
      authorUsername: comment.identities.traits.username,
      commentId: comment.comment_id,
      depth,
      replyToUser: comment.reply_to_user ?? null,
    });
    if (comment.other_comments?.length) {
      results.push(...flattenComments(comment.other_comments, depth + 1));
    }
  }
  return results;
}

Then for every reply, it checks: is there an email in reply_to_user.traits? Is this a direct reply to the parent comment's author? Is that parent at depth 0 or 1? If the email is present, both conditions should be true. If it's absent, at least one should be false. Any mismatch means the theory is wrong.

I ran it against three different API dumps from different title pages. The theory held across every single reply. Zero exceptions. Here's what the output looks like:

[OK] Comment 11618719: "RNF" -> "Dexie" | direct reply=YES, parent depth=0 (<=1), email exposed=YES
[OK] Comment 11618998: "TheMad" -> "RNF" | direct reply=YES, parent depth=1 (<=1), email exposed=YES
[OK] Comment 11619028: "RNF" -> "TheMad" | parent depth=2 (>1, too deep), email exposed=NO
[OK] Comment 11619162: "TheMad" -> "RNF" | direct reply=NO (parent author is "TheMad"), email exposed=NO

What a Thread Looked Like

Here's a real thread from the data (emails redacted) that shows the cutoff:

[Depth 0] Dexie: "If karma had face..."
  └─ [Depth 1] RNF: "What happened?"
     ↳ reply_to_user: { email: "d****@gmail.com", username: "Dexie" }  ← leaked
     └─ [Depth 2] TheMad: "They are the reason why comick is now like this"
        ↳ reply_to_user: { email: "r****@gmail.com", username: "RNF" }  ← leaked
        └─ [Depth 3] RNF: "Oh, fuck em"
           ↳ reply_to_user: { username: "TheMad" }  ← no email
        └─ [Depth 3] TheMad: "You can read more about it..."
           ↳ reply_to_user: { username: "RNF" }  ← no email

Same users, same thread. The only difference is how deep the parent comment is.

The Fix

The fix was straightforward, just strip the email field from reply_to_user.traits across all code paths before it gets sent to the client. Doesn't matter whether the backend did a rich query or a light one, email should never be in the response.

I reported this to meotimdihia and it's been patched. The comment API no longer exposes emails in reply_to_user.