unblock

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 24, 2026 License: MIT Imports: 12 Imported by: 0

README

unblock

A Go HTTP client that looks like a browser at the network layer, and knows when it has been blocked.

c := unblock.New(unblock.Options{})
resp, err := c.Get(ctx, "https://example.com")
if b := resp.Blocked(); b != nil {
    fmt.Println(b.Kind, b.Hint) // CF_CHALLENGE / CF_BLOCK_1020 / WAF_GENERIC / ...
}

Three things are handled, and one deliberately is not.

1. TLS/JA3 and HTTP/2 fingerprints

net/http, requests, and httpx all share a recognisable fingerprint. On an HTTPS connection the client sends a TLS ClientHello carrying cipher suites, extension order, elliptic curves, and ALPN — WAFs hash that into JA3/JA4. On top of it sits the HTTP/2 fingerprint: SETTINGS values, WINDOW_UPDATE, pseudo-header order.

unblock uses bogdanfinn/tls-client (ClientHello via uTLS, h2 via fhttp) so the fingerprint matches a real Chrome, Safari, or Firefox:

c := unblock.New(unblock.Options{
    Profile: "chrome_133", // empty = newest supported Chrome profile
})

Conceptually the same as curl_cffi in Python, without a separate process.

2. Automatic HTTP/1.1 fallback

Some servers reject HTTP/2 negotiation with RST_STREAM (stream ID 1; INTERNAL_ERROR) even though TLS succeeded. unblock detects that error shape and retries once over HTTP/1.1 with the same TLS fingerprint.

The first attempt only gets half the time budget, so the retry is not starved by a hanging first try. Response.Attempts and Response.HTTP1 report which path was used.

resp, _ := c.Get(ctx, url)
fmt.Println(resp.Attempts) // 1, or 2 when the fallback was used

Force HTTP/1.1 always with Options.ForceHTTP1.

3. HTTP/3 protocol racing

Rules that block the HTTP/2 fingerprint usually do not apply to HTTP/3, which has its own fingerprint surface. ProtocolRacing starts QUIC and TCP in parallel and keeps whichever connects first, then reuses it per host:

c := unblock.New(unblock.Options{ProtocolRacing: true})

Measured on a Cloudflare-protected page where every recent profile was blocked over HTTP/2 (403), protocol racing returned 200 over HTTP/3 — with chrome_133 and with chrome_150. It is the single strongest lever here: stronger than changing profiles, and stronger than forcing HTTP/1.1.

Needs UDP egress. If QUIC is blocked, the client falls back to TCP; Response.Proto reports what actually answered (HTTP/3.0, HTTP/2.0, HTTP/1.1).

On Linux, quic-go warns about a small UDP receive buffer. Raise it for stable throughput:

sysctl -w net.core.rmem_max=7500000 net.core.wmem_max=7500000

4. Block classification

Four different situations hide behind HTTP 403, and each needs a different action. Classify tells them apart from headers and body, with no extra requests:

Kind Marker What to do
CF_CHALLENGE cf-mitigated: challenge, _cf_chl_opt, Just a moment needs a browser that executes JS
CF_BLOCK_1010 Error 1010 browser signature banned: use a TLS profile, not a spoofed UA
CF_BLOCK_1009 Error 1009 country block: needs a local proxy
CF_BLOCK_1015 Error 1015 rate limited: add a cooldown
CF_BLOCK_1020 Error 1020 firewall rule rejected the ASN/datacenter
WAF_GENERIC x-sucuri-id, x-datadome, x-akamai-request-id, x-iinfo third-party WAF

Note that a block is a statement about this request: same URL, same profile, different protocol can answer 200. Before concluding a host is unreachable, try ProtocolRacing (HTTP/3) and ForceHTTP1.

b := unblock.Classify(resp)
if b != nil {
    log.Printf("blocked: %s (ray %s) — %s", b.Kind, b.RayID, b.Hint)
}
if b.Challenge() {
    // the only category a browser can resolve
}

A plain 403 from the origin (a normal "forbidden" page, a login wall) is intentionally not treated as a block — that is not an anti-bot concern.

Per-host cooldown

Cloudflare blocks are sticky: hammering the same host within a short window tends to escalate the block. Cooldown buys distance:

cd := unblock.NewCooldown(6 * time.Hour)
host := unblock.HostOf(url)

if !cd.Allow(host) {
    return fmt.Errorf("host %s is cooling down for %s", host, cd.Remaining(host))
}
resp, err := c.Get(ctx, url)
if b := resp.Blocked(); b != nil {
    cd.Mark(host)
}

What this does NOT do

It does not execute JavaScript. A managed Cloudflare challenge (Turnstile, the "Checking your browser" page that computes a token) still cannot be bypassed — fingerprints only win against rules that are purely TLS/h2 based.

The package tells you that this is what happened (CF_CHALLENGE) instead of pretending to solve it. For that you need a real browser: FlareSolverr, go-rod, or the end user's own browser.

Also out of scope: IP reputation and behaviour. A datacenter IP is often blocked even with a perfect fingerprint — use Options.ProxyURL to leave that IP behind.

Install

go get github.com/nyelonong/unblock

Requires Go 1.27+ (tracking tls-client).

API

func New(opts Options) *Client
func (c *Client) Get(ctx context.Context, url string) (*Response, error)
func (c *Client) Head(ctx context.Context, url string) (*Response, error)
func (c *Client) Do(ctx context.Context, method, url string) (*Response, error)

func Classify(r *Response) *Block
func HostOf(rawURL string) string
func NewCooldown(window time.Duration) *Cooldown

Options: Profile, ProxyURL, Timeout, MaxBody, Headers, ForceHTTP1, ProtocolRacing.

Response: StatusCode, Header, Body, FinalURL, Proto, Attempts, HTTP1, plus Get(header) and Blocked().

Test

go test ./...

Covers a real httptest server, body limits, custom headers, ForceHTTP1, 9 block-classification variants, and cooldown behaviour.

License

MIT — see LICENSE.

Documentation

Overview

Package unblock is an HTTP client for Go that looks like a browser at the network layer (Chrome-like TLS ClientHello and HTTP/2 fingerprint) and knows when it has been blocked.

Three things are handled:

  1. TLS/JA3 and HTTP/2 fingerprints — via bogdanfinn/tls-client, so WAF rules based on fingerprints do not reject the request.
  2. Automatic HTTP/1.1 fallback when a server rejects HTTP/2 (RST_STREAM), reusing the same TLS fingerprint.
  3. Block classification — Cloudflare challenges, errors 1009/1010/1015/1020, and third-party WAFs are told apart from one another, because each needs a different follow-up action.

This package does NOT execute JavaScript. A Cloudflare challenge that requires JS cannot be bypassed here; Classify only reports that this is what happened.

Index

Examples

Constants

View Source
const DefaultCooldownWindow = 6 * time.Hour

DefaultCooldownWindow is used when NewCooldown receives a zero duration.

View Source
const DefaultMaxBody = 5 << 20

DefaultMaxBody caps how many body bytes are read (5 MiB).

View Source
const DefaultTimeout = 20 * time.Second

DefaultTimeout is the per-attempt deadline when Options.Timeout is zero.

Variables

View Source
var ErrNoBody = errors.New("unblock: empty body")

ErrNoBody is returned when a response carries no usable payload.

Functions

func HostOf

func HostOf(raw string) string

HostOf returns the host (without port) of a URL, or the input unchanged when it cannot be parsed.

Types

type Block

type Block struct {
	// Kind is the category: CF_CHALLENGE, CF_BLOCK_1009, CF_BLOCK_1010,
	// CF_BLOCK_1015, CF_BLOCK_1020, CF_BLOCK_GENERIC, WAF_GENERIC.
	Kind string `json:"kind"`
	// Code is the Cloudflare error code when present (e.g. "1020").
	Code string `json:"code,omitempty"`
	// RayID helps when contacting WAF support or the site owner.
	RayID string `json:"ray_id,omitempty"`
	// Server is the value of the Server header.
	Server string `json:"server,omitempty"`
	// Hint suggests the next action.
	Hint string `json:"hint,omitempty"`
}

Block describes a single blocking event.

func Classify

func Classify(r *Response) *Block

Classify inspects a response and reports whether it is a WAF/anti-bot block. It returns nil when the response is not a block.

Four different situations hide behind HTTP 403, and each needs a different response — never treat them as one case.

Example

Tell block types apart.

package main

import (
	"fmt"
	"net/http"

	"github.com/nyelonong/unblock"
)

func main() {
	h := http.Header{}
	h.Set("Server", "cloudflare")
	h.Set("Cf-Ray", "8a1b2c3d-JKT")

	resp := &unblock.Response{
		StatusCode: 403,
		Header:     h,
		Body:       []byte("<title>Attention Required!</title><h2>Error 1020</h2>"),
	}

	b := unblock.Classify(resp)
	fmt.Println(b.Kind, b.Code, b.Challenge())
	fmt.Println(b.Hint)

}
Output:
CF_BLOCK_1020 1020 false
firewall rule rejected the ASN/datacenter: needs a residential proxy

func (*Block) Challenge

func (b *Block) Challenge() bool

Challenge reports whether this block can be resolved by a browser that executes JavaScript (FlareSolverr, go-rod, or the end user's browser).

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a fetcher with a browser fingerprint.

func New

func New(opts Options) *Client

New builds a Client. An unusable configuration does not make New fail: the client falls back to plain net/http so callers never have to handle an error on the construction path.

func (*Client) Do

func (c *Client) Do(ctx context.Context, method, url string) (*Response, error)

Do performs the request and returns the full body.

When the first attempt fails because of an HTTP/2 problem (RST_STREAM, INTERNAL_ERROR), the request is retried once over HTTP/1.1 with the same TLS fingerprint. Half of the time budget is reserved for that second attempt.

func (*Client) Get

func (c *Client) Get(ctx context.Context, url string) (*Response, error)

Get fetches a URL. Redirects are followed.

Example

Fetch a page with a browser fingerprint.

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/nyelonong/unblock"
)

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte("<html>hello</html>"))
	}))
	defer srv.Close()

	c := unblock.New(unblock.Options{Profile: "chrome_133", Timeout: 5 * time.Second})

	resp, err := c.Get(context.Background(), srv.URL)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(resp.StatusCode, len(resp.Body) > 0, resp.Blocked() == nil)

}
Output:
200 true true

func (*Client) Head

func (c *Client) Head(ctx context.Context, url string) (*Response, error)

Head sends a HEAD request. Some servers reject HEAD; callers that need a body should fall back to Get themselves.

type Cooldown

type Cooldown struct {
	// contains filtered or unexported fields
}

Cooldown records hosts that just blocked us.

Cloudflare blocks are sticky: repeating a request to the same host within a short window tends to escalate the block (challenge becomes block). A cooldown buys distance before that host is attempted again.

Example

Use a per-host cooldown.

package main

import (
	"fmt"
	"time"

	"github.com/nyelonong/unblock"
)

func main() {
	cd := unblock.NewCooldown(time.Hour)
	host := unblock.HostOf("https://example.com/page")

	fmt.Println(cd.Allow(host))
	cd.Mark(host)
	fmt.Println(cd.Allow(host))

}
Output:
true
false

func NewCooldown

func NewCooldown(window time.Duration) *Cooldown

NewCooldown creates a Cooldown with the given window. Zero means DefaultCooldownWindow.

func (*Cooldown) Allow

func (c *Cooldown) Allow(host string) bool

Allow reports whether the host may be attempted now.

func (*Cooldown) Mark

func (c *Cooldown) Mark(host string)

Mark parks the host for the cooldown window.

func (*Cooldown) Remaining

func (c *Cooldown) Remaining(host string) time.Duration

Remaining returns how long the host stays parked (0 when it is allowed).

func (*Cooldown) Reset

func (c *Cooldown) Reset(host string)

Reset clears the cooldown marker for a host.

type Options

type Options struct {
	// Profile names a TLS preset, e.g. "chrome_133" or "firefox_135".
	// Empty means the package default Chrome profile (the newest supported one).
	// See profiles.MappedTLSClients for the full list.
	Profile string

	// ProxyURL is optional: "http://127.0.0.1:8080" or "socks5://10.0.0.2:1080".
	ProxyURL string

	// Timeout per attempt. Zero means DefaultTimeout.
	Timeout time.Duration

	// MaxBody caps the number of body bytes read. Zero means DefaultMaxBody.
	MaxBody int64

	// Headers are added to every request. Values here override whatever the TLS
	// profile sets (user-agent, accept, and so on).
	Headers map[string]string

	// ForceHTTP1 disables HTTP/2 negotiation entirely. Useful for servers that
	// tarpit or consistently reject h2.
	ForceHTTP1 bool

	// ProtocolRacing races HTTP/3 (QUIC) and HTTP/2 (TCP) and uses whichever
	// connects first, similar to Chrome's Happy Eyeballs. The client then reuses
	// the winning protocol per host.
	//
	// This is the strongest lever against anti-bot rules that target the HTTP/2
	// fingerprint: measured on a Cloudflare-protected page where every recent
	// profile was blocked over HTTP/2, HTTP/3 answered 200 with both
	// chrome_133 and chrome_150. Requires UDP egress; if QUIC is blocked the
	// client falls back to TCP.
	ProtocolRacing bool
}

Options configures a Client.

type Response

type Response struct {
	StatusCode int
	Header     http.Header
	Body       []byte
	FinalURL   string
	// Proto is the negotiated protocol, e.g. "HTTP/2.0" or "HTTP/3.0".
	Proto string

	// Attempts is 1 for the first try, 2 when the HTTP/1.1 fallback was used.
	Attempts int
	// HTTP1 is true when the response came from the HTTP/1.1 fallback path.
	HTTP1 bool
}

Response is a fully-read response, truncated at MaxBody.

func (*Response) Blocked

func (r *Response) Blocked() *Block

Blocked returns block details, or nil when the response is not a block.

func (*Response) Get

func (r *Response) Get(name string) string

Get returns the value of a response header.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL