errno

Search Console says "Possible phishing detected on user login" on a site with no login

· in Web platform and delivery · tested on Hugo static site on Cloudflare Pages, Google Search Console, Chrome Safe Browsing

Symptom

Search Console, Security Issues, on a static site of mine:

Security issues
1 issue detected

Possible phishing detected on user login
The browser will show pop-up warnings when users enter saved login
credentials into some pages on your site

Sample URLs: N/A

The site is a generated static blog. No accounts, no login page, no registration, no comments, no newsletter signup, no <input type="password"> anywhere in the output, and none of those ever existed. There is nothing on it that a user could enter login credentials into, which makes the message read like either a false positive or — the reading that ruins your evening — evidence that someone is serving a login page from my domain that I have never seen.

The Sample URLs: N/A is the part that makes it hard: the usual triage loop is “open the flagged URL, look at what Google looked at”, and there is no flagged URL to open.

What it is not

This is not a Safe Browsing blocklisting. Those are different issue types (“Deceptive pages”, “Harmful downloads”), and they come with visible consequences: the red Deceptive site ahead interstitial in Chrome, warnings on links from Search, and usually sample URLs. None of that was happening. The site loaded normally in a clean profile and still ranked.

“Possible phishing detected on user login” is the Chrome password-reuse warning signal — Search Console Help documents it under Password Reuse Warning in Chrome. The mechanism is client-side: when a user types a password that Chrome has saved for some other site into a page, Chrome consults its local Safe Browsing model, and if that model scores the page as phishy it shows the “Change your password” pop-up. The signal that reaches Search Console is that ping, not a crawl result. That is exactly why there are no sample URLs — Google is not telling you which page was scored, because the report did not come from fetching your pages, it came from a browser on someone’s machine.

So the warning is about a client-side score, not about a verdict on your content. That does not make it harmless, but it does change the question from “what did Google find on my site” to “is there anything on my site that can accept credentials at all”.

What I measured

Three checks. For a static site they are cheap and, together, conclusive.

1. Confirm there is no blocklisting. The Transparency Report has a status endpoint the site status page itself uses:

curl -s "https://transparencyreport.google.com/transparencyreport/api/v3/safebrowsing/status?site=example.com"

The response begins with the XSSI guard )]}' and then a JSON array:

)]}'

[["sb.ssr",1,false,false,false,false,false,<timestamp>,"example.com",false]]

The second element is the status code, and it is worth calibrating it against a host whose state you already know rather than guessing. 1 is what the UI renders as “No unsafe content found”; 3 is “No available data”, which is what you get for a host with no history at all. The flagged property returned 1. No blocklisting, no interstitial — consistent with what the browser was showing me.

2. Confirm the served HTML is the HTML I built. A compromise on a static site means the served bytes differ from the build output. So diff them. The trap is that on Cloudflare they always differ a little: the edge injects an inline script that defines window.__CF$cv$params and appends /cdn-cgi/challenge-platform/scripts/jsd/main.js inside a 1x1 hidden iframe. That one block is the only legitimate delta. Anything else — a stray <script src>, an extra form, a hidden div — is an injection.

Reading a whole-page diff for that is unpleasant, so I dumped the opcodes instead and looked only at the inserted regions:

import difflib, pathlib, urllib.request

local = pathlib.Path("public/index.html").read_text()
served = urllib.request.urlopen("https://example.com/").read().decode()

sm = difflib.SequenceMatcher(None, local, served, autojunk=False)
for tag, i1, i2, j1, j2 in sm.get_opcodes():
    if tag == "equal":
        continue
    print(tag, (i1, i2), (j1, j2))
    print("  local :", local[i1:i2][:160].replace("\n", " "))
    print("  served:", served[j1:j2][:160].replace("\n", " "))

Run that over the handful of template-distinct pages (home, a post, the list page, a tag page). Every insertion on my site was the Cloudflare snippet; nothing was replaced or deleted.

3. Inventory the surface. The real question is whether any page can accept input that looks like credentials, so count it across the whole site instead of the pages I happen to remember:

curl -s https://example.com/sitemap.xml \
  | grep -o '<loc>[^<]*</loc>' | sed 's/<\/*loc>//g' \
  | while read -r url; do
      html=$(curl -s "$url")
      printf '%s forms=%s inputs=%s pw=%s\n' "$url" \
        "$(printf '%s' "$html" | grep -c '<form')" \
        "$(printf '%s' "$html" | grep -c '<input')" \
        "$(printf '%s' "$html" | grep -c 'type=.password')"
      printf '%s' "$html" \
        | grep -oE '(src|href|action)="https?://[^/"]+' \
        | grep -oE 'https?://[^/"]+' >> /tmp/origins.txt
    done
sort -u /tmp/origins.txt

Result: 22 URLs, zero password inputs, exactly one <form> element, and a single third-party origin (the AdSense loader). Nothing that submits anywhere, nothing that could carry a credential off the page.

That single <form> is the interesting part. The posts list had a filter box:

<form class="filter" role="search" onsubmit="return false">

It filters an already-rendered list of posts in JavaScript, has no action, and its submit handler exists purely to stop the browser from navigating. It never submitted anything anywhere. The <form> element was earning nothing — it was there because a text input with a label looks like a form when you write it.

The fix

Two changes, both deployed and verified.

Serve zero form elements. The filter is a div now, with the same label, the same input and the same live result count:

<div class="filter" role="search">
  <label for="q">Filter posts</label>
  <input type="search" id="q" autocomplete="off" placeholder="type to filter">
  <span class="count" aria-live="polite"></span>
</div>

Nothing was lost. role="search" keeps the landmark semantics a screen reader needs, autocomplete="off" keeps the browser from offering saved values, and the hidden attribute I use to hide filtered-out entries works exactly the same on a div container as it did inside a form. The onsubmit="return false" hack is gone because there is no longer a submit event to cancel.

Make credential submission impossible even from markup I did not write. On Cloudflare Pages, one line in static/_headers:

/*
  Content-Security-Policy: form-action 'none'; base-uri 'none'; object-src 'none'

form-action 'none' means the browser will refuse to submit any form from any page of the site — including a form injected later by a compromised dependency or a rogue build. base-uri 'none' stops a <base> tag from re-pointing relative URLs, and object-src 'none' removes the plugin surface.

Note what is not in there: no script-src. A script policy is the standard way to accidentally break an ad loader or an analytics snippet, and it needs real testing against every third-party origin. form-action 'none' costs nothing on a site with no forms and closes the exact hole the warning is about.

After deploying, verify in the browser — not in the config:

document.forms.length          // 0

Then check the response headers for the CSP line, confirm the third-party loader still initialises (it does; no script-src directive was added), and press Enter in the filter box to confirm it no longer tries to navigate.

Why it happens and what to remember

The remediation path for this issue type is Security Issues → Request Review, with a factual description of what you verified and what you changed. That is the only mechanism available, because you cannot see the offending URL and therefore cannot “fix the page”. The review request is where you put the evidence: no login exists, the served HTML matches the build, the site serves zero forms, and submission is blocked by policy.

Worth knowing before you assume a hack: a plausible contributor to a client-side phishing score is writing about authentication. Pages that discuss sign-in flows, tokens, cookies and credential handling contain exactly the vocabulary a phishing classifier keys on, and on a young domain with no reputation there is little else for the model to weigh against it. Combine that with a page containing a labelled text input inside a <form>, and you have something that pattern-matches a login screen without being one.

The transferable lesson is smaller than the scare: know which Search Console security issue types are blocklistings and which are client-side signals, because the triage is completely different. And on a static site, “zero forms plus form-action 'none'” is a free invariant — nothing I build needs a form, so anything that tries to submit is by definition not mine.

search-console safe-browsing cloudflare security