checker-blacklist/checker/disconnect.go
Pierre-Olivier Mercier c2cc88e1df Add Disconnect.me tracking-protection blocklist source
Downloads and caches the Disconnect.me services.json feed (24h TTL),
matching domains against the Advertising, Analytics, Social, Content,
and Disconnect categories. Severity is warn (privacy classification,
not malware). Reuses the shared feedCache infrastructure.
2026-05-15 21:36:24 +08:00

162 lines
4.6 KiB
Go

package checker
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
const disconnectFeedURL = "https://s3.amazonaws.com/lists.disconnect.me/services.json"
func init() {
Register(&disconnectSource{
cache: newFeedCache(24*time.Hour, disconnectFetch(disconnectFeedURL)),
})
}
type disconnectSource struct{ cache *feedCache }
func (*disconnectSource) ID() string { return "disconnect" }
func (*disconnectSource) Name() string { return "Disconnect.me" }
func (*disconnectSource) Options() SourceOptions {
return SourceOptions{
User: []sdk.CheckerOptionField{
{
Id: "enable_disconnect",
Type: "bool",
Label: "Use the Disconnect.me tracking-protection list",
Description: "Check the domain against the Disconnect.me blocklist used by Firefox Enhanced Tracking Protection, Brave, and uBlock Origin.",
Default: true,
},
},
}
}
func (s *disconnectSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
if !sdk.GetBoolOption(opts, "enable_disconnect", true) {
return disabledResult(s.ID(), s.Name())
}
if registered == "" {
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}}
}
matches, size, fetched, err := s.cache.lookup(ctx, registered)
res := SourceResult{
SourceID: s.ID(), SourceName: s.Name(), Enabled: true,
Reference: "https://disconnect.me/trackerprotection",
Details: mustJSON(map[string]any{"feed_size": size, "fetched_at": fetched}),
}
if err != nil {
res.Error = err.Error()
}
seenCategory := map[string]bool{}
for _, m := range matches {
parts := strings.SplitN(m, "|", 2)
category, company := parts[0], ""
if len(parts) == 2 {
company = parts[1]
}
if !seenCategory[category] {
seenCategory[category] = true
res.Reasons = append(res.Reasons, category)
}
extra := map[string]string{}
if company != "" {
extra["company"] = company
}
res.Evidence = append(res.Evidence, Evidence{
Label: "Category",
Value: category,
Status: strings.ToLower(category),
Extra: extra,
})
}
return []SourceResult{res}
}
func (*disconnectSource) Evaluate(r SourceResult) (bool, string) {
return evidenceEval(r, SeverityWarn)
}
func (*disconnectSource) Diagnose(res SourceResult) Diagnosis {
return Diagnosis{
Severity: SeverityWarn,
Title: "Listed in Disconnect.me tracking-protection blocklist",
Detail: fmt.Sprintf(
"Category: %s. This domain appears in the Disconnect.me list used by Firefox Enhanced Tracking Protection, Brave, and uBlock Origin. Browsers and privacy tools will block third-party requests to this domain, which may affect analytics, ad delivery, or embedded widgets. The list is maintained by Disconnect.me; contact them if you believe the classification is incorrect.",
joinNonEmpty(res.Reasons, ", "),
),
Fix: "https://disconnect.me/contact",
FixIsURL: true,
}
}
// disconnectJSON mirrors the structure of services.json.
type disconnectJSON struct {
Categories map[string][]map[string]map[string][]string `json:"categories"`
}
func disconnectFetch(feedURL string) func(context.Context) ([]string, map[string][]string, error) {
return func(ctx context.Context) ([]string, map[string][]string, error) {
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, feedURL, nil)
if err != nil {
return nil, nil, err
}
req.Header.Set("User-Agent", "happydomain-checker-blacklist/1.0")
resp, err := sharedHTTPClient.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("disconnect HTTP %d", resp.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, nil, fmt.Errorf("disconnect read: %w", err)
}
var data disconnectJSON
if err := json.Unmarshal(raw, &data); err != nil {
return nil, nil, fmt.Errorf("disconnect parse: %w", err)
}
byHost := make(map[string][]string, 4096)
for category, entities := range data.Categories {
for _, entity := range entities {
for company, sites := range entity {
for _, domains := range sites {
for _, d := range domains {
d = strings.ToLower(strings.TrimSuffix(d, "."))
if d == "" {
continue
}
entry := category + "|" + company
byHost[d] = append(byHost[d], entry)
}
}
}
}
}
urls := make([]string, 0, len(byHost))
for d := range byHost {
urls = append(urls, d)
}
return urls, byHost, nil
}
}