Changelog
[Unreleased]
Section titled “[Unreleased]”- A browser command no longer waits for its full timeout after Chrome closes the DevTools connection. The command fails at once, and a browser pool that is dropped lets go of its Chrome.
- A stop of the SSRF check turns request interception off again on a browser that crawlberg
launched, and on a
browser.endpointbrowser after the last refusal is delivered. Before, interception stayed on with nothing to answer, and every other tab of that browser waited without end. A browser that crawlberg kills when it is done, and a check that runs in the default context of the browser, keep interception on, as before. - A popup that a page opens while its SSRF check ends is closed, and the page stays usable for the next check. Before, the browser session failed, or the close of the popup’s tab closed the page with it.
- Known limit: a script that calls
window.opendirectly at the moment the SSRF check of its page ends gets no answer after the popup is closed, and that page runs no further script (measured on Chrome 155 and on headless shell 153). No request of the page or of the popup gets past the check, the next check of the page still starts and ends, and other tabs are not affected. Awindow.openfrom a timer or an event handler does not block its page.
[1.10.2] - 2026-10-06
Section titled “[1.10.2] - 2026-10-06”Changed
Section titled “Changed”- Update
html-to-markdown-rsto 3.17.2.
[1.10.1] - 2026-10-05
Section titled “[1.10.1] - 2026-10-05”- Restore crates.io package verification and WASM builds by compiling SSRF helpers only on the targets and feature sets that use them.
- Restore Swift artifact builds for iOS by holding
libcat 0.2.189 until the releasedsysinfodependency supports the Mach API visibility change inlibc0.2.190.
[1.10.0] - 2026-10-05
Section titled “[1.10.0] - 2026-10-05”Upgrading
Section titled “Upgrading”- Hostname requests through an upstream proxy or external
browser.endpointnow fail closed under IP-based SSRF denial. With the defaultdeny_private = true, add anExactorSuffixallowlist entry for each hostname whose remote DNS you trust, or setdeny_private = false. A CIDR allowlist cannot verify a DNS answer produced outside Crawlberg. A customdenylistalways wins: remote hostname resolution remains refused even for an allowlisted hostname or when private networks are enabled. Literal IP URLs and direct connections retain their existing address checks. Customcrawlberg-browservalidators now inherit the same fail-closed remote-DNS default and must overridevalidate_remote_resolutionto opt out deliberately. (#110)
SsrfPolicy.denylistadds deployment-specific CIDR ranges to the built-in SSRF denials. Configured ranges override allowlists anddeny_private = false, apply to DNS answers and embedded IPv4 addresses, and are available through JSON config andCrawlConfigBuilder::ssrf_denylist_cidr. Hostname requests are refused when an upstream proxy or remote browser would perform the connection’s DNS lookup, because that lookup cannot be bound to the addresses Crawlberg checked. (#110)
- Generated Python representations and Rust-side Ruby/Elixir binding diagnostics redact proxy credentials, authentication values, custom headers, cookie values, document headers, browser endpoints, interaction text and scripts, and Chrome arguments while preserving their serialized values. (#386)
[1.9.0] - 2026-10-03
Section titled “[1.9.0] - 2026-10-03”Upgrading
Section titled “Upgrading”-
In browser mode,
max_redirectsnow counts the navigations a page starts. A meta refresh or a script navigation counts as one redirect, and past the limit the page stays where it is. A crawl with a lowmax_redirectsthat relied on a page’s script to move it to the real page, such as a challenge page, needs a higher limit. Incrawlberg-browser,NativeBrowserConfiggainsmax_redirectsandRenderedPagegainsredirects, so a struct literal of either needs the new field;NativeBrowserConfig::default()sets no limit. (#117, #193, #115) -
The bypass provider holds its secrets in a type that never prints. In
crawlberg-bypass, the auth scheme’s token, user name and header or query value, and each fixed query value, are now aSecret. ItsDebugandDisplayprint***, so a struct that derivesDebugover it cannot show the key. Build one with.into()from a string, and read it withexpose(). (#386) -
The config check refuses a proxy password that is not percent-encoded. A
#,/or?in a proxy user name or password ends the address early, sohttp://user:4242#rest@proxy:8080was read as the hostuseron port 4242. Such an address now failsCrawlConfig::validate, and the error asks to percent-encode the credential or to set it inusernameandpassword. The error never shows the address. An address with no credentials and an@in its path, query or fragment is refused too, and its error says that a proxy address takes no path, query or fragment. -
The config check refuses proxy credentials set in two places. A proxy URL that holds a user name or password, together with
usernameorpassword, now failsCrawlConfig::validate. Set the credentials in the URL or in the two fields, not both. -
A proxy with only
usernameset now sends its credentials. The HTTP client sendsProxy-Authorizationwith the user name and an empty password. Before, it sent credentials only when bothusernameandpasswordwere set. -
A proxy that a
ProxyProviderreturns gets the same check as the configured proxy. A provider proxy that the check refuses is not used: the request goes direct, and an ERROR line names the target host and the reason, never the proxy URL. Its credentials reach the proxy asProxy-Authorization. The provider is asked once for each request, redirect hops included, so a rotating provider no longer sends one proxy’s credentials to another, and a refused proxy logs one ERROR line for each request. Each provider proxy has its own connection pool. -
A native browser render goes through the proxy that a
ProxyProviderpicks. Before, a render ignored the provider and connected directly. Now the render asks the provider once for the page’s host, and the page load and every request the page makes go through that proxy with its credentials. If the provider returns no proxy, the render goes direct, as an HTTP fetch does.browser.proxy, if set, still wins over the provider for renders. (#248) -
A Chrome render with a
ProxyProviderfails instead of going direct. The Chrome backend cannot render through a provider, so a Chrome render with a provider and nobrowser.proxynow fails with an error that names the fix: use the native backend, or setbrowser.proxy. HTTP fetches with the provider are not affected. (#248) -
crawlberg-browser: the native backend takes a proxy with its credentials apart.NativeBrowserConfiggainsproxy, anUpstreamProxy: an address that holds no user name or password, and optionalProxyCredentials.UpstreamProxy::newrefuses an address that holds credentials, and an address with an@after the host, which an unencoded#,/or?in a password leaves behind. Its error never shows the address. The same type replaces the proxy URL string in the browser context, the HTTP clients, the module loader and the JS runtime constructors. -
crawlberg-browser:NativeBrowserConfig.proxy_urlis deprecated. Setproxyinstead.proxy_urlstill works, and a user name and password in the URL become the proxy credentials. It gets the same checks asproxy, so an unencoded#,/or?in a password, which leaves an@after the host, fails the render. A path, a query or a fragment with no@is accepted, as in v1.8.0. An unusableproxy_urlfails the render even whenproxyis set. When both are set, they must name the same proxy: a different address or other credentials fail the render with an error that names both fields. -
The config check refuses a SOCKS proxy where no client can use it. A
socks5://orsocks5h://address inproxynow failsCrawlConfig::validatewith “SOCKS proxies are not supported”. Crawlberg’s HTTP clients are built without SOCKS support, so every HTTP fetch through such a proxy failed at connect time. The same holds forbrowser.proxywith the native backend. Chrome speaks SOCKS, so with the Chrome backendbrowser.proxystill takessocks4://andsocks5://. Chrome has nosocks5hscheme. Use anhttporhttpsproxy everywhere else. -
With the Chrome backend, the config check refuses a crawl-wide proxy with credentials. A Chrome render uses
proxywhenbrowser.proxyis not set, and Chrome cannot use a proxy with a username or password. Such a config now failsCrawlConfig::validateinstead of every browser render. The HTTP client still takes the proxy. To keep it, setbrowser.proxyto a proxy that needs no credentials, use the native backend, or setbrowser.modetonever. A build without the Chrome backend is not affected. -
The config check also checks
browser.proxy. Abrowser.proxywith a scheme the browser cannot use, such asgopher://, now fails the config check instead of the render. (#249) -
In browser mode, a page with an error status is now the error HTTP mode returns. A scrape of such a page returned the rendered HTML with status 200. It now returns the same error that HTTP mode returns for the same status. The statuses are 401, 403, 404, 408, 410, 429, 500, 502, 503 and 504. A 403, 429 or 503 page is a WAF error when its headers or its body name a WAF, as in HTTP mode, so it escalates instead of being retried. A page with another status, such as 501, 505 or 599, stays a page, as in HTTP mode. Code that expects a page from every browser-mode scrape must handle these errors. A crawl in browser mode now keeps the same pages as one in HTTP mode. Under
soft_http_errorsa 404 or 403 page, and a 404 at the end of a redirect, is a page that keeps its status and has an empty body, as in HTTP mode. The Chromiumoxide backend reports the status and the response headers of the document the page shows, so a WAF block is found from the headers of a 403, 429 or 503 page as well as from its body. When Chrome shows its own error page in place of such a response, such as for a download or an empty body, only the headers are checked, because Chrome never rendered the body. (#143) -
The native browser backend reports an empty body for a 204, 205 or 304. It reported an empty HTML skeleton for these statuses. It now reports an empty body, as HTTP mode does. If your code reads the body of such a page, expect an empty string. (#121)
-
A page that navigated to a 204, 205 or 304 before its load event could time out in browser mode. Chrome commits no document and fires no load event for those responses, so the Chromiumoxide backend could wait until
browser.timeout. It now keeps and returns the page that was already loading. (#436) -
interactnow follows at mostmax_redirectsredirects. The default is 10. The Chromiumoxide backend followed every redirect a chain offered, and the native backend followed up to 20. For a longer chain,interactreturns the URL of the redirect at the limit. On the Chromiumoxide backend it also returns empty HTML and a failed result for each action. If aninteractcall must follow a longer chain, raisemax_redirects. (#116, #115) -
A Chromiumoxide browser fetch or
interactsession fails when Chrome reports no main frame. The redirect limit counts only the redirects of the page’s main frame, so crawlberg must know that frame. If Chrome reports no main frame, or the read of it fails, the call returns a browser error that says the redirect limit cannot be applied. (#90) -
ScrapeResult,CrawlPageResultandInteractionResultgainedssrf_refused_urls. The field is left out when it is empty, so an older crawlberg still reads a result with no refused request. A scrape or page result that lists one is rejected by an older reader, because both types refuse unknown fields. -
BrowserConfiggained two fields and rejects unknown ones.chrome_pathandchrome_argsare always serialised, andBrowserConfigrejects unknown fields, so a browser configuration serialised by this version is rejected by every older crawlberg, even when both are unset. The break is one-directional: an older configuration still loads here, because both fields have defaults. (#79, #80) -
BrowserPoolConfig.chrome_argsnow refuses entries that the pool used to launch with. The pool applies the rules ofBrowserConfig.chrome_args, so these entries now fail: an entry without a leading--(disable-gpu), a flag name with an uppercase letter, a flag named twice (--enable-featuresgiven two times), and--headless,--remote-debugging-portor--user-data-dirin any form,--headless=newincluded.BrowserPool::newstill accepts the config: the refusal comes when the pool launches Chrome, as an error fromwarmandacquire_pagethat namesBrowserPoolConfig.chrome_args. Write each flag once, as--flagor--flag=valuewith a lowercase name, and join several--enable-featuresvalues with commas. (#79, #80) -
BrowserProfile::chrome_args()is deprecated. It returns a single--user-data-dir=<path>flag, andchrome_argsnow refuses--user-data-dirin any form, so the flag only helps a caller that starts Chrome itself. Nothing in crawlberg calls it: a profile reaches Chrome throughCrawlConfig.browser_profileandsave_browser_profile, which set the launch’s--user-data-dirdirectly. ReadBrowserProfile.user_data_dirinstead, or setCrawlConfig.browser_profileand let crawlberg apply it. (#254) -
The regenerated bindings add required
BrowserConfigconstructor arguments. Code that constructs aBrowserConfigby hand must pass the new settings:chrome_pathandchrome_argsto Swift’sinitand the Java record constructor, andchromeArgsto Dart’s constructor. The Java builder and the other bindings give both settings defaults. (#79, #80) -
crawlberg_browser::net::ssrf::DEFAULT_DENY_NET_CIDRSgrows from 13 to 14 entries, adding240.0.0.0/4. Code that pattern-matches or hardcodes the array’s length breaks; code that iterates it does not. -
An IPv6 allowlist entry no longer admits an address that carries a denied IPv4 address. The IPv4-compatible (
::/96), IPv4-translated, 6to4 (2002::/16), Teredo (2001:0::/32), ISATAP and local-use NAT64 (64:ff9b:1::/48) forms are now checked as the IPv4 address they carry, so an allowlist entry for such an address has to name that IPv4 range instead of the IPv6 one. IPv4-mapped and64:ff9b::/96addresses already behaved this way. -
The browser crate’s fallback validator names the denial reason.
DefaultSsrfValidatormessages now end with the reason the core policy reports (loopback,private_network,link_local,unspecified,multicast,unique_local). Code that compares the whole message must allow for the new suffix. -
A URL’s
user:pass@no longer appears in any URL crawlberg returns. crawlberg takes the userinfo off a URL when a call starts, and sends it only as anAuthorization: Basicheader to that URL’s host. Every URL in a result, a stream event or a plugin callback is the URL without the userinfo:final_url, page and link URLs, map entries, and the URL that pairs eachbatch_scrapeandbatch_crawlresult. If you match batch results against your own input URLs, remove the userinfo from your input first. A URL that carries userinfo is now a configuration error whenauthis also set; use one of the two. -
authandcustom_headersnow go only to the seed URL’s host. A subdomain, a linked document on another host or a redirect target on another host gets neither. If a crawl needs a header on another host, start a separate call with that host as its seed. -
metadata.canonical_urlis now an absolute URL. It was the canonical link’shrefas the page wrote it, so<link rel="canonical" href="/en/page">gave/en/page. It is now resolved against the page’s base URL and normalized as the links list is, so it giveshttps://example.com/en/page, andhttps://Example.comgiveshttps://example.com/. If your code joins a relative canonical URL to the page URL, remove that step. If it compares the value with a literal, compare with the normalized form. (#101) -
metadata.hreflangs[].urlis now an absolute URL. It was each alternate-language link’shrefas the page wrote it, so<link rel="alternate" hreflang="de" href="/de/">gave/de/. It is now resolved against the page’s base URL and normalized as the canonical URL is, so it giveshttps://example.com/de/. If your code joins a relative hreflang address to the page URL, remove that step. (#126) -
A 503 or 429 behind Akamai, Imperva or F5 is retried again instead of escalating. The three fingerprints in
rules/waf_fingerprints.tomlwhose only signal is the CDN’s ownserverheader (AkamaiGHost,Incapsula,BIG-IP) now decide a 403 only. An overloaded or redeploying origin behind one of those CDNs is therefore retried perretry_codesas it was before challenge statuses were fingerprinted, instead of being classified as a WAF block and escalated to the bypass or browser tier. A real block from those vendors is still caught on a 403, and no other fingerprint changes. A custom corpus can scope any fingerprint the same way with an optionalstatusesarray of the codes it may decide; an empty array is rejected. (#197) -
CrawlError::WafBlockedhas asourcefield. Rust code that builds the variant by hand must passsource: None, or callCrawlError::waf_blocked(vendor, message)instead.CrawlError::waf_blocked_with_sourceattaches an underlying error. A match on the variant with..does not change. The Swift and Kotlin Android bindings give the WAF block case asourcevalue, as their other error cases already have: Swift code that matches.wafBlocked(vendor:message:)must bind the third value, and Kotlin code that buildsCrawlError.WafBlockedmust passsource. The other bindings do not change. (#133) -
CrawlConfiggainedpath_patterns_match_url, which older versions reject. The field is always serialised, andCrawlConfigalready carries#[serde(deny_unknown_fields)], so a config serialised by this version is rejected by every older crawlberg, even when the value isfalse. The break is one-directional: an older config still loads here, because the field defaults tofalse.What this affects:
- A config serialised on one crawlberg and read by another. Upgrade the readers before, or with, the writers.
- Any binding that round-trips a config through JSON across the FFI boundary
(
cberg_crawl_config_to_json,cberg_crawl_config_from_json), where the core and the binding can be at different versions.
-
metadata.og_url,og_image,og_video,og_audioandtwitter_imageare now absolutehttporhttpsURLs, or absent. Each field was the meta tag’scontentas the page wrote it, so<meta property="og:image" content="/img/hero.png">gave/img/hero.png, and ajavascript:orfile:address came back unchanged. Each field now resolves against the page’s base URL, as the canonical URL does, and is absent when the result is not anhttporhttpsaddress. If your code joins a relative address to the page URL, remove that step. (#312) -
A struct literal of a type that gained a field needs the new field. This holds for
ScrapeResult,CrawlPageResultandInteractionResult(ssrf_refused_urls),BrowserConfig(chrome_path,chrome_args),CrawlConfig(path_patterns_match_url) and, incrawlberg-browser,NativeBrowserConfig(proxy). Each of these types implementsDefault, so ending the literal with..Default::default()is enough. -
Three more public structs gained a field, so a struct literal of each needs it. In
crawlberg-browser,NativeBrowserConfiggainsorigin_headers: set it toNone, or end the literal with..Default::default(). Incrawlberg,BrowserPoolConfiggainschrome_path: set it toNoneto find Chrome as before, or end the literal with..Default::default(). Incrawlberg-browser,NativeCookiegainshost_onlyand has no default: set it totruefor a cookie that goes only to itsdomain, and tofalsefor one that also goes to subdomains. -
crawlberg-browser:PageErrorgainedInvalidConfig. A render returns it when its configuration cannot be used, such as an unusable proxy, and then fetches nothing. AmatchonPageErrorwith no wildcard arm needs an arm for it.
-
Choose the Chrome binary and add Chrome flags.
BrowserConfig.chrome_pathnames the one Chrome or Chromium executable a browser-mode fetch launches; a missing or non-executable path is an error that names it, never a fallback to another Chrome.BrowserConfig.chrome_argsadds Chrome flags, each written as--flagor--flag=valuewith a lowercase flag name, and a flag that names one of crawlberg’s defaults replaces that default. The RustBrowserPoolConfigapplies the same checks to its ownchrome_argswhen it launches Chrome. Both settings reach every Chrome that crawlberg launches, and both are ignored with a warning, and not checked, whenbrowser.endpointis set or the native backend is in use. Flags such as--proxy-serverand--host-resolver-rulesroute around the SSRF policy, so setchrome_argsonly from trusted configuration. TheBrowserConfigdebug output and the warning give the number of flags, not their values, because a flag value can carry a credential. (#79, #80) -
CrawlConfig.path_patterns_match_urlmatchesinclude_paths/exclude_pathsagainst the full URL,scheme://host[:port]/path?query, so a pattern can scope by host. The matched text leaves out any userinfo and the fragment, and the host is in punycode. It defaults tofalseand takes precedence overpath_patterns_match_query. (#78)
-
A Chrome that crawlberg kills could leave a child process behind on macOS. crawlberg stops every process of a Chrome it launched before it kills them, so that none of them can start a child that the kill does not see. On macOS, a stop sent to a process that is starting a program does not always hold: the process can run again and start a child after crawlberg last read the process table. crawlberg now reads every process again after it reads the table. If a process runs, crawlberg stops it again and reads the table once more. A process that does not take its stop in two waits of one second no longer holds the kill: crawlberg logs a warning and kills the processes it found. It also logs a warning when the processes keep changing for eight reads of the table. A process that runs after the last read is still not seen. This applies to the Chromiumoxide backend. (#585)
-
The native browser fetched a blank stylesheet or script address as the page itself. An empty address, or one made only of URL-parser whitespace, is now skipped before resolution, so it produces no duplicate request or network event. (#270)
-
Native-browser subresources ignored the document’s base address. A relative stylesheet, classic script or module script under
<base href="/assets/">was fetched relative to the page address instead. These subresources now resolve against the first<base href>, falling back to the page address when that base does not parse. (#265) -
Relative links in the markdown resolved against a
<base>that was not one. The markdown found the page’s base with a second parser. That parser read markup inside<title>,<script>and<style>as tags and counted a<base>inside<svg>or<template>, so<title><base href="/x/"></title>moved every relative link under/x/. It now uses the first<base href>in tree order, the same base as the links list, and leaves markup inside raw text as written. -
An
interactsession waited forever when its Chrome died. With the Chromiumoxide backend, the CDP client stops reading a broken connection to Chrome, but it keeps every command that waits on that connection. So the session waited without end for the reply to the close of its page. The session now ends its CDP client when the connection breaks, every command that waits fails at once, andinteractreturns an error. (#577) -
A browser pool kept a crashed Chrome, and a scrape waited on a broken browser connection. The pool and the one-shot scrape had the same CDP client defect as
interact(#577): after the connection to Chrome broke, every command that waited on it waited without end. The pool launches a new Chrome only when that client has ended, so after a crash every page request failed andshutdownwaited forever. Now both end the CDP client at the first connection error, and log that error as a warning. A page request that finds the old Chrome gone gets a new Chrome, and a scrape ends with an error that says the browser’s connection closed. (#581) -
A browser command that got no answer waited without end. With the Chromiumoxide backend, the CDP client fails a command after its 30-second request timeout only when something else wakes the client, and on a connection with no other traffic nothing did. So a command Chrome never answered held a crawl or an
interactsession forever. crawlberg now wakes the client each second, and such a command fails with a timeout within twice the request timeout. (#586) -
A browser read of the committed document ignored
browser.timeout.browser.timeoutbounded only the navigation. The reads after it, of the HTML, the committed document and the screenshot, run in the page’s renderer, so a page that keeps the renderer’s main thread busy held each read up to the CDP client’s fixed 30-second timeout, and a render can make several reads. In scrape, crawl andinteract, the reads of the final document now share onebrowser.timeoutbudget, and a read past it fails as a browser timeout that names the budget. For such a page, REST callers now get 504TIMEOUTinstead of 500BROWSER_ERROR, and Python callers getBrowserTimeoutErrorinstead ofBrowserError. (#567) -
A stalled module script cost 10 seconds for every module script after it. The native browser backend waited, after each module script, until nothing at all was pending on the page. A module whose top-level
awaitnever settled left work pending forever, so each later module script also waited out its full 10-second budget: a page with one stalled module and two after it took 30 seconds to render. Each module script now waits only for its own evaluation, so the stalled module costs the page one budget. Work a module starts without awaiting it, such as a fetch, now finishes after the next module script runs. (#486) -
An awaited script evaluation waited for the whole page to go idle. The native browser backend ran an awaited evaluation or function call until nothing at all was pending on the page. A page with a fetch that never answers made every awaited evaluation wait out a 5-second budget, even for
Promise.resolve(1): two such evaluations took 10 seconds. Each awaited evaluation now waits only for its own promise, for at most 5 seconds, and an error from other work on the page no longer ends the wait early. An evaluation that did not settle in time used to return the result of the evaluation before it; it now comes back asundefined. (#541) -
A classic script could hold its page for 5 seconds after it finished. The native browser backend runs each classic script under a 5-second watchdog. When the script finished before the watchdog thread started, which happens on a loaded host, the watchdog missed the signal and slept out its full budget while the page waited for it. The watchdog now checks whether the script is done before it starts to wait. (#566)
-
Chrome’s WebSocket, WebTransport and WebRTC traffic, and a second DNS answer, reached addresses the SSRF policy refuses. The request check sees only HTTP requests, so a WebSocket opened a connection to a denied address, WebTransport and WebRTC sent UDP datagrams to one, and Chrome could resolve a checked host name again to a different address. With
deny_privateon, Chrome now sends every connection through a small proxy inside crawlberg. The proxy resolves each host once, checks the addresses against the SSRF policy, and connects only to an address that passed, so Chrome never resolves a name itself. This covers scrape, crawl,interact, pooled browsers, abrowser_profilesession and abrowser.endpointChrome on this machine. A refused connection is listed ashost:portin the result’s refused URLs for a one-shot scrape and forinteract; in a browser pool it is logged with its host and port. With an upstream proxy, the proxy sends a host name to the upstream unresolved and checks only address literals, as the HTTP client does, so the upstream resolves the name. Chrome’s requests reach anhttporhttpsupstream as they did before. Abrowser.endpointChrome on another machine cannot use the proxy: its HTTP requests are still checked, and crawlberg logs one warning that its sockets are not. Withdeny_privateon, a launched Chrome sends WebRTC UDP only through a proxy, which stops it; a pooled Chrome always does, because one pool serves crawls with either setting. (#165, #178, #452) -
A sandboxed Chrome ran without the WebRTC policy and sent UDP to denied addresses. crawlberg turns off WebRTC UDP that bypasses the proxy by writing a preference into the profile directory it gives Chrome, in the system temp directory. A sandboxed Chrome, such as the Chromium snap on Ubuntu, has a private /tmp: it opened an empty directory at that path, made a fresh profile, and sent WebRTC datagrams to denied addresses with no error. When the Chrome to launch is a snap, crawlberg now makes its scratch profile in
~/snap/<name>/common, which the snap reads at the same path. After each launch that relies on the profile, crawlberg also checks that Chrome wrote into that directory. If it did not, crawlberg stops Chrome and returns a browser error that names the cause, and the crawl does not run without the policy. (#165) -
A saved browser profile failed on a snap Chrome with an error that did not name the cause. crawlberg keeps saved profiles under
~/.local/share/crawlberg/profileson Linux. A snap, such as the Chromium snap on Ubuntu, cannot open a folder in the home directory whose name starts with a dot, so Chrome exited at once on a lock file it could not create. crawlberg now refuses a saved profile that a snap Chrome cannot open, before it starts Chrome. The error names the cause and the fixes: setXDG_DATA_HOMEto a folder the snap can open, use a Chrome that is not a snap, or turn offsave_browser_profile. (#556) -
Browser mode returned Chrome’s error page as the page. When the main frame ended on Chrome’s own error page, the Chromiumoxide backend returned that page’s HTML as content. This happened for a download with a status such as 501, 505 or 599, for an error status with an empty body, and for a navigation that failed at the network, which reported status 200. When the server answered, the fetch now reports its status, headers and URL with no body, and handles the status as HTTP mode does: a 404 or 500 is the same error, and a 400 or 501 is a page. When the server did not answer, the fetch fails with a browser error that names the URL. After a page navigates itself, only the redirects of that navigation make a 404 a page, not the redirects of the requested URL. (#317, #319)
-
A successful response whose body Chrome could not decode was reported as an empty page. A malformed compressed body now fails with a browser error that names its 2xx status and URL, whether it is the seed or the target of a page navigation. This applies before
interactrenderer scripts and actions too. (#417) -
The HTML, status, final URL and screenshot of a browser render come from one document. They were separate reads, so a navigation that committed between them could pair the HTML of one document with the status, URL or screenshot of the next. The render now reads which document is committed before and after it reads the HTML and takes the screenshot, and reads both again when the document changed. The final URL is the URL of that document. A page that navigates during each of three reads fails with a browser error. (#318)
-
A browser screenshot of a page that keeps navigating held the fetch until its deadline. Chrome can leave a screenshot unanswered while the page keeps replacing its document. The screenshot now stops after 5 seconds, and the page is reported without one.
-
interactreturned Chrome’s error page as the page. With the Chromiumoxide backend, a Scrape action, and the final HTML of a session, could be Chrome’s own error page, for example after a download that Chrome cannot show. Such a Scrape action now fails, and a session that ends on the error page fails with a browser error that names the URL. When the error page is for a navigation the SSRF policy refused, the session keeps its result: the refusal fails the action that caused it and is listed inssrf_refused_urls, the final HTML is empty, and the final URL is the refused URL. An ExecuteJs or Screenshot action that starts on the error page reports a failure. The action still runs once, so a script such ashistory.back()can leave the error page. The final HTML and the final URL of a session, and a Scrape action’s HTML, are read from one committed document. (#345, #346, #355) -
A secret in an endpoint host label printed in debug output. A browser endpoint, a bypass provider endpoint or a browser session proxy prints as its origin, and a per-account host such as
sk-live-abc.api.example.comprinted in full. The host now keeps its last two labels and prints***for each label to their left:***.***.example.com. An IP address prints as before. (#175) -
The Chrome backend’s record of a main-frame response printed its
Set-Cookie. The SSRF interception keeps the headers of each main-frame response, and its debug output printed every value. It now prints***for each credential header on the shared list, as the other response header maps do. (#141, #386) -
The native browser backend logged the proxy password. The backend put the proxy user name and password back into the proxy URL, and every module import logged that URL at debug level. The credentials now stay apart from the proxy address from the config check to the connection, where the HTTP clients send them as
Proxy-Authorization. No proxy URL that a log line or an error can show holds a password. (#238, #385) -
The
Debugtext of a proxy showed part of an unencoded password. Forhttp://user:4242#rest@proxy:8080, theDebugtext ofProxyConfigand ofStaticProxyProvidershowedhttp://user:4242. It now shows***for an address that the config check refuses. (#285) -
The
Debugtext ofBrowserPoolConfigshowed a credential in a Chrome flag. A flag value that holds an@, such as--proxy-server=http://user:pass@proxy:8080, now prints as--proxy-server=***. The flag name stays. -
The native browser backend could ignore its proxy and connect directly. A proxy URL that did not parse, or one whose scheme the HTTP client cannot speak, such as
ftp://, was dropped without an error, and every request of the render then went direct. A caller who relied on the proxy for egress control got neither the proxy nor a failure. The backend now checks the proxy URL when it builds its clients and fails the render with a configuration error that names the reason. The error never contains the URL, so credentials in it cannot leak. The same check covers page-initiated fetches and dynamic module imports, whose errors printed the proxy URL, credentials included. (#237) -
A proxy address without a scheme works everywhere the HTTP client takes it. An address such as
127.0.0.1:3128,localhost:3128oruser:pass@proxy:3128is read as anhttp://proxy, exactly as the HTTP client reads it. The config check refused it forproxy(#420), the native backend refused it inbrowser.proxyonce a username or password was set (#421), and the stealth mode ignored it and connected directly. -
Chrome renders ignored the proxy and connected directly. With the Chrome backend, a render launched Chrome without the proxy, so a caller who relied on
browser.proxyorproxyfor egress control got direct connections with no error. Only the interact path passed it. Every Chrome launch now takes the proxy, read the same way as the HTTP client reads it. A shared browser pool, or a Chrome reached throughbrowser.endpoint, opens each page in a browser context made with that crawl’s proxy, so crawls with different proxies share one Chrome and each goes through its own proxy. Requests to a loopback address go through the proxy too; Chrome sends them direct by default. (#434) -
A proxy flag in
chrome_argsreplaced the configured proxy on a launched Chrome. Withbrowser.proxyorproxyset, a--proxy-serverinbrowser.chrome_argssent a one-shot render or an interact session through the caller’s proxy, and a--proxy-bypass-listsent loopback requests direct.--no-proxy-server,--proxy-pac-urland--proxy-auto-detectdid the same, because Chrome reads them before--proxy-server. A pooled or connected Chrome used the configured proxy. Every Chrome now uses the configured proxy. Crawlberg drops each of these flags with a warning that names the flag but not its value, and loopback requests still go through the proxy. -
A Chrome proxy with credentials never connected. Chrome takes the proxy address as a launch flag and ignores credentials in it, so a render through
user:pass@proxy:3128or a proxy withusernameandpasswordmade no connection and failed without saying why. The Chrome backend now refuses a proxy with credentials, with an error that says so and does not show them. Chrome gets the address as the HTTP client reads it, so127.0.0.1:3128andhttp:proxy:3128now work. (#435) -
An
interactaction reported success while a request it sent was refused. A paused request counted toward its page only once the check had matched it to the page. For a frame the check does not know yet, that match reads the frame tree of every live page, and it can take longer than the 25 ms grace after an action. The action then ended with nothing in flight and reported success, and the refusal was charged to no action at all. A result’sssrf_refused_urlscould miss such a request for the same reason. The request itself was always refused. A paused request now counts from the moment the check receives the pause. (#192) -
A 204 or 304 seed timed out in browser mode. Chrome commits no page for a response without a document, so the Chrome backend waited for the browser timeout (20 seconds by default) and then failed. A 204, 205 or 304 answer, including one at the end of a redirect, now ends the fetch at once with the status, final URL and empty body that HTTP mode reports. The native backend already returned at once, but it reported an empty HTML skeleton as the body; it now reports an empty body too. (#121)
A 304 Chrome asked for itself is unaffected: when Chrome revalidates a page it holds in its cache, the page still renders from that cache with status 200. Only a 304 that no cache entry can satisfy is reported as an empty 304, which is what it carries.
-
max_redirectsdid not limit browser mode. Chrome follows a redirect chain itself, and the chain counted the whole of it as one hop, so a browser-mode crawl followed chains that HTTP mode refuses. Chrome now follows at most the redirects the chain has left. The chain stops on the redirect response at the limit, with the same redirect count, status and final URL that HTTP mode reports, and the next hop is never requested. This applies to both browser backends. (#90, #115)A navigation the page starts itself also counts: a meta refresh Chrome acts on counts as one redirect, as it does in HTTP mode, and so does a script navigation such as
location.replace. Each redirect such a navigation follows counts too. The limit covers every navigation of the page until the crawl reads it, and past the limit the page keeps the document it has. Before, only the redirects before the first document counted, so a page could lead Chrome through any number of refreshes or script navigations. A redirect inside an iframe does not count. (#117, #193) -
A native browser scrape skipped the redirect chain. It did not follow a meta refresh, so it returned the refresh page where HTTP mode returns the page the refresh points at. It also reported a seed that answers 404 as a page with status 404 when the seed had no trailing slash, where HTTP mode fails with
not_found. A native scrape now takes the same redirect chain as HTTP mode and uses the redirect count the backend reports. (#529, #530) -
A native browser scrape lost the cookies a meta refresh page set. Each hop of the redirect chain is its own native render, and each render started with an empty cookie jar, so the request to the refresh target did not carry the refresh page’s cookies and the result did not list them. The chain now carries the jar from one hop to the next, with the Secure and HttpOnly flags of each cookie.
-
The native browser stored cookies that a page had no right to set. A page could set a cookie for another host with a
Domainattribute, for example a page on127.0.0.1forlocalhost, and the native browser then sent that cookie to the other host, after a 302 or a meta refresh. It also stored a Secure cookie that a page set over plain http. The native browser now ignores a cookie whoseDomaindoes not match the host that set it, and a Secure cookie set over http. It also ignores a cookie whoseDomainis a public suffix, such asco.uk,github.ioorlocalhost, unless the host that set it has that exact name. A cookie set without aDomainattribute now goes back to the host that set it only, not to that host’s subdomains. Incrawlberg-browser,NativeCookiegainshost_only, so a struct literal of it needs the new field. -
The native browser matched a cookie by its name alone. A cookie with the same name but another path, or another
Domainsetting, replaced the first cookie. A deletion for one path removed the cookie at every path. A cookie set without aPathtook the whole request path, aPaththat did not start with/was kept as given, and the path/onlyalso matched/onlyfoo. The native browser now identifies a cookie by its name, its path and whether it has aDomain. A missing or relativePathgives the directory of the request path, and a path matches only at a/boundary. A page over plain http can no longer overwrite or delete a Secure cookie, and a__Secure-or__Host-cookie that breaks its prefix rules is ignored. -
A native scrape through a meta refresh listed only the last page’s refused addresses.
ssrf_refused_urlsnow lists the addresses the SSRF policy refused on every page of the chain. -
interactset no redirect limit, and a 204 or 304 seed timed out there. The pagesinteractopens now follow at mostmax_redirectsredirects, and a 204, 205 or 304 answer returns at once. When the navigation ends on a response without a document,interactreports the URL that answered, empty HTML, and a failed result for each action that names the status. The SSRF check still applies to every request. On the native backendinteractfollows at mostmax_redirectsredirects too, and stops on the redirect response at the limit. The limit covers the navigation to the page, not the navigations the actions start. (#116, #140, #115) -
A page could navigate to a refused address after it loaded. The Chromiumoxide backend stopped checking requests against the SSRF policy when the page finished loading, so a script that navigated during
extra_waitreached any address. The check now stays on until the HTML is read. A main-frame navigation it refuses, during the load or after it, fails the fetch with the SSRF policy error, because the page Chrome then shows is its own error page. A refused image or iframe keeps the page. (#143) -
Browser mode reached addresses the SSRF policy refuses. The request check covered one page and stopped when the navigation finished. In
interact, a click, a form submission, a scriptfetch()or a popup the actions started reached private and loopback addresses. In scrape and crawl, a popup the page opened, and a request it sent during the extra wait or while it was screenshotted, did too. Each browser now has one check for every page it serves, and each request is judged by the policy of the page it belongs to: the page, its frames, and the popups it opened. On a browser crawlberg launched, a request that belongs to no checked page is refused; on a browser reached throughbrowser.endpoint, another client’s tabs are left alone. A launched browser no longer opens a tab of its own. On a pooled browser, on abrowser.endpointChrome, and on a one-shot browser without a profile, every page crawlberg opens lives in a browser context of its own, so it shares no cookies or storage with the browser’s other pages; on abrowser.endpointChrome the page starts with the browser’s cookies. When a fetch or a session ends, that context is disposed: the page, its popups and every request of theirs Chrome still holds go with it, so nothing they sent reaches the network after. The check is turned off only when it stops, once every page it opened is gone; a request Chrome pauses after that is not checked. A session with abrowser_profileruns on a Chrome launched for it alone, so its page uses the profile’s own storage, cookies and localStorage included, and the check stays on until that Chrome is closed. Ininteract, a main-frame navigation refused before the actions fails the session with the SSRF policy error, as it fails a scrape. This applies to the Chromiumoxide backend. (#153, #165, #168, #281, #506) -
An
interactaction whose request the SSRF check refused was reported as successful. The action now fails with the SSRF policy error that names the refused URL. A refused request counts for the action that was running when the check received it from Chrome, so on a busy host it can count for the next action. This applies to the Chromiumoxide backend. (#167) -
An
interactsession or a scrape could send requests to a refused address as it ended, on a busy host. Under load Chrome can take longer than the close limit to destroy a page or a popup, and a page can still send just after Chrome reports it destroyed. The check then turned interception off, or closed the browser, while they were still sending, and their requests went out. A browser crawlberg launches with a throwaway profile, for oneinteractsession or one scrape, now keeps interception on until it is killed, so its requests stay paused until the process is gone. crawlberg then ends every process of that Chrome, waits until none of them and none of their threads is left, for at most what is left ofbrowser.shutdown_timeout, and then removes the profile. Ending the processes and removing the profile are not bounded by that timeout, so on a busy host aninteractcall can return later thanbrowser.shutdown_timeoutafter its last action. A scrape returns its result before the kill. A scrape with a savedbrowser_profileand a scrape through aBrowserPoolend as before. This applies to the Chromiumoxide backend. (#468) -
The SSRF check on a
browser.endpointbrowser turned interception off while the page of aninteractsession or a scrape, or a popup of it, was still open, on a busy host. crawlberg cannot kill a browser it does not own, and the check stopped waiting for the close after a time limit. Each page of the check now has a browser context of its own. Closing the page disposes that context, which takes the page, its popups and their pending requests before the check turns interception off. The browser and its other tabs stay open. This applies to the Chromiumoxide backend. (#484) -
A scrape on a
browser_profilecould fail right after another one on the same profile. A scrape returns before its Chrome has exited, and Chrome writes the profile until it exits. A scrape that started then copied the profile while Chrome renamed files in it, and failed with “failed to copy profile file”. A session on a saved profile now holds the profile until its Chrome has exited, or has been killed aftershutdown_timeout. A session on an unsaved profile copies the profile only while no Chrome writes it, and two saved sessions on one profile no longer run at the same time. A session that waits for the profile counts the wait against itsoverall_timeout. This applies to sessions in one process. A symlink to a profile directory shares the hold of the directory it points to. (#524) -
interactsilently ignoredbrowser_profileandsave_browser_profile. It still uses an isolated session rather than reading or writing the named profile, but now logs a warning that both settings are ignored. This applies to both browser backends. (#559) -
A browser-mode page did not say which of its requests the SSRF policy refused. A refused image, script, frame or
fetch()keeps the page, and the result now lists each refused address inssrf_refused_urls, without its credentials. Aninteractresult lists the refusals of the whole session, the extra wait included. The first five refusals of a page are each logged as a warning, then one warning reports the count, so a page cannot flood the log. This applies to both browser backends, for scrape, crawl andinteract. -
Feeds, hreflang alternates, canonical links and icons reported
file:andblob:addresses. They still used the older check from #307, which drops onlydata:,javascript:andvbscript:addresses, so afile:///etc/passwdfeed, hreflang or canonical link, or afile:orblob:icon, was still reported, though the crawler can never fetch it. Feeds, hreflang alternates and canonical links now use the same rule as the links list, images and asset discovery: onlyhttpandhttpsaddresses are reported. Icons use that rule too, but keep #307’s exception for an inlinedata:address: adata:icon is a real, usable icon that needs no fetch, unlike afile:orblob:address, so it still comes back. (#472) -
The links list, images and asset discovery reported
file:andblob:addresses. Afile:///etc/passwdlink, a<img src="file:///x.png">, or a stylesheet or script with ablob:address was reported as a normal link, image or asset, though the crawler can never fetch any of them: it fetches onlyhttpandhttps. Afile:link also raised an SSRF warning during a crawl. All three now report onlyhttpandhttpsaddresses. The links list already droppedmailto:,tel:and the inlinedata:,javascript:andvbscript:schemes; images and asset discovery now dropmailto:andtel:too. (#275, #341) -
Images, feeds, hreflang alternates, icons and canonical links reported an address that does not resolve. An address the URL parser cannot read, such as
http://[bad/x, or a relative address under a<base href>that cannot take one, such asblob:https://example.com/b, was reported as the page wrote it. It is now dropped, as the links list already dropped it. (#472) -
The Open Graph and Twitter card address fields reported any address.
<meta property="og:url" content="file:///etc/passwd">gavefile:///etc/passwd, and ajavascript:address came back unchanged.og_url,og_image,og_video,og_audioandtwitter_imagenow use the same rule as the links list: the address resolves against the page’s base URL and is kept only when it ishttporhttps. A whitespace-only address is absent, and a tag whose address is dropped does not clear an address that another tag set. (#312, #472) -
map()reported sitemap entries of any scheme. A sitemap<loc>offile:///etc/passwd,mailto:,ftp:or any other scheme came back as a page.map()now reports onlyhttpandhttpsentries, and skips a robots.txtSitemap:line or a sitemap-index child with another scheme instead of trying to fetch it. (#565) -
The browser crate’s default SSRF policy left the reason out of a connect-time refusal. When
crawlberg-browseris used directly, its check of a URL names the reason it refuses an address, such asloopback. A host name or address refused when the connection resolves it gave the same decision with no reason. That refusal now ends with the reason too, so::ffff:127.0.0.1gives the reasonloopbackat both checks. (#532) -
The full and CLI Docker images, and the Elixir NIF builder, failed before compiling. Their build rewrote the workspace
memberslist with a pattern that expects a one-line array, and the rootCargo.tomlwrites it on several lines, so cargo could not load the copied manifest. The full and CLI images now replace the whole array and copycrates/crawlberg-browser, which the core crate names as a path dependency. The NIF builder no longer rewrites the root manifest: the NIF crate is its own workspace, so it builds from its own manifest. (#553) -
soft_http_errorsdid not cover a refusal by a custom retry policy or an antibot strategy. A page refused by a custom retry policy, or by an antibot strategy that asks for browser escalation, came back as an error when no escalation tier was left. It now comes back as the same soft page as a WAF block: the refused status for a 4xx or 5xx, and 403 for a 2xx. A tier left to escalate to still runs first. (#549) -
soft_http_errorsreported every WAF block as a 403. A 429 or 503 block page came back with status 403, so a caller could not tell a rate limit from a forbidden response. A WAF block now reports the status of the response it refused. A block page served with a 2xx status still reports 403, because a 2xx soft error reads as success. A 429 or 503 soft page has no markdown or response metadata, like a 403 or 404 page. (#518) -
A robots.txt that opens with a UTF-8 byte-order mark lost its first group. The mark stayed attached to the first
User-agentline, that directive did not match, and the whole group, rules included, was dropped, so every path was allowed. A leading byte-order mark is now skipped once, as RFC 9309 asks. (#516) -
The sitemap walk and the well-known
/sitemap.xmlfallback gave no URLs for a gzip sitemap served with the wrong content type. A robots.txtSitemap:directive, a sitemap-index child, and the/sitemap.xmlfallback each decided whether to inflate a body by its content type, so a gzip sitemap served asapplication/octet-stream, or recognised only by its gzip header bytes, yielded no URLs there, whilemap()’s direct fetch read the same file. All three now inflate a body that starts with the gzip header, whatever its content type says, the way the direct fetch already did. (#534) -
The browser fallback read robots.txt with its own parser, which dropped the first group after a UTF-8 byte-order mark. A file that opened with the mark and disallowed
/privatelet the browser open/private. The browser fallback now uses the crawl engine’s robots.txt parser, which moves into the newcrawlberg-robotscrate;crawlberg::robotsre-exports it unchanged. That parser already skips a leading byte-order mark (#516). The browser fallback now decides these cases the way the crawl engine does (#540):- The longest matching rule wins. Before, any matching
Allowbeat a longerDisallow. - A
*inside a pattern, such asDisallow: /*.pdf$, matches any text. Before, only a trailing*did, and an inner one matched nothing. - A group with several
User-agentlines applies to each of them. Before, only the lastUser-agentline of the group counted. - A
User-agenttoken applies only when it is a prefix of the crawler’s user agent. Before, a token that contained the user agent, or that the user agent contained anywhere, also matched. - A trailing
# commenton a rule line is ignored. Before, it became part of the pattern. - When a group names the crawler, only the groups that name it apply. Before, the
User-agent: *rules applied as well. - A rule before the first
User-agentline joins the first group. Before, the browser fallback ignored it. - An unknown directive between two
User-agentlines joins them into one group. Before, only the secondUser-agentline counted.
- The longest matching rule wins. Before, any matching
-
When a robots.txt had two groups for the crawler, the crawl engine obeyed only the last one. A file with
User-agent: crawlberg/Disallow: /aand, further down, a secondUser-agent: crawlberggroup withDisallow: /clet the crawler fetch/a. The parser now combines every group that names the crawler into one, as RFC 9309 section 2.2.1 says, and does the same for severalUser-agent: *groups. When two combined groups set aCrawl-delay, the later one wins. When only an earlier group sets one, that value applies. The browser fallback uses the same parser (#540). -
Browser fetches left their Chrome profile directories in the temp directory. A one-shot fetch, an interact run or a pool that ended without its own cleanup left a
crawlberg-*directory of several megabytes behind: a pool dropped withoutshutdown(), or a fetch whose Tokio runtime stopped before its teardown ran. Each such directory is now removed when its owner is dropped. First crawlberg stops each process of the Chrome it launched that still uses the directory as its profile, and waits up to five seconds for them to exit, because Chrome’s helper processes outlive the browser and keep writing into it. On Linux 5.3 and later the wait lasts until the last thread of each killed process has exited, because a thread still finishing a write made the removal fail with “directory not empty”. This also works when a launcher script runs Chrome as its child, and a shell that only names the directory is left running. This work runs on a background thread, so it does not stall other tasks or hold the browser pool’s lock. A savedbrowser_profileis never removed; only the temporary copy of it is. (#415) -
map()did not follow a meta refresh. A page that forwards with a<meta http-equiv="refresh">tag or aRefreshheader gave no URLs, because the direct fetch followed only HTTP redirects. It now follows both the way the crawl does: the same tags win, only the same HTTP statuses (301, 302, 303, 307, 308) count as a redirect hop, each hop counts towardmax_redirects, each hop passes the SSRF policy, and the seed’s credentials go only to the seed host. The links come from the page it lands on. A chain that reaches the redirect limit, leads back to a URL it already requested, or ends on a missing page now stops there, as the crawl does, instead of failing the wholemap(). (#502) -
map()requested pages that robots.txt or the path filters refuse. Its direct fetch checked each request against the SSRF policy only, so a seed, an HTTP redirect or a refresh to a path robots.txt disallows was requested and its links returned, while the crawl refuses the same page without requesting it. Each request of the direct fetch now passes the crawl’s own checks first:exclude_paths,include_pathsfor a redirect or refresh hop, and, withrespect_robots_txton, the robots.txt of the URL’s own origin, which fails closed when that file is unreachable. A refused URL is never requested, andmap()returns the crawl’s forbidden error with the reason, which the REST API answers with a 403. The sitemaps thatmap()reads are not checked this way. (#512) -
A custom retry policy got no status for a 403 or a WAF block. A plain 403 and a response refused as a WAF block ended the attempt with an error that did not keep the response status, so
AttemptOutcome.statusstayed empty for them. Both errors now keep the status, so the policy reads 403 for a plain forbidden, and 403, 429, 503 or the 2xx status for a block. The built-in retry decisions do not change: a forbidden and a WAF block still escalate, and listing 403 inretry_codesstill does not retry them. (#133) -
Link extraction read markup inside raw-text elements and took the wrong
<base>. Onlyscript,style,textareaandtitlewere treated as raw text, by a hand-written scanner. Links and a<base href>insidexmp,iframe,noembed,noframesandplaintext, after<script/>and in a script inside SVGforeignObjectwere read as real, and a<!--in such text hid every link after it. Links inside a bogus comment (<? ... >,<!x ... >,<![CDATA[outside SVG) and inside an SVG or MathML CDATA section were read as real too. A crawled or scraped page is now read once by html5ever with scripting off, and that read decides the raw text, the link tags, the base, the meta refresh target and the render hint. Two kinds of page are still read twice: a page decoded again from a declared non-UTF-8 charset, and a body cut tomax_body_size. The base is the first<base href>in the finished document, as in a browser: a<base>in a table moves in front of it, and a<frameset>drops the body with its<base>. (#201, #287) -
One tag with tens of thousands of attributes slowed link extraction quadratically. The HTML parser compares each new attribute name of a tag with every earlier one. Attributes past the 1,024th of one tag are now overwritten with spaces before the parser reads the page, so the cost grows linearly. Repeated attribute names count toward the limit, so an
hrefafter the 1,024th attribute of an<a>or<base>tag is not read. (#269) -
A 2xx from a site behind Akamai, Imperva, F5 or Sucuri is returned as content again. Those products stamp their own header on every response they proxy, and a WAF fingerprint that matches on response headers alone was enough to refuse the response. Robots.txt, sitemap and asset fetches refused every 2xx served through one of them, and the crawl refused such a 200 when its body was under 5000 bytes, with the real page already in hand. A header-only fingerprint now needs the body to show the interstitial before a 2xx is refused. A 403 behind one of those CDNs still blocks. (#231)
-
Every fetch path now makes the same call on a 2xx. Robots.txt, sitemap and asset fetches checked the body of any 2xx up to 100 KB, the crawl checked only a 200 under 5000 bytes, and a
WafClassifierset on the engine flagged a 2xx to the antibot strategy and retry policy on a header-only match, so the built-in antibot strategy refused an ordinary 200 behind Sucuri. All three now apply one rule: any 2xx status, a body under 5000 bytes, and a header-only match that the body corroborates. So sitemap and asset fetches return a 2xx of 5000 bytes or more as content, the crawl refuses a 202 or 203 interstitial, and a classifier set on the engine flags a 2xx only under the same rule. A robots.txt that is a block page still denies the whole site at any size up to 100 KB. (#500) -
A robots.txt that says “blocked” in a comment is read as rules. Behind Cloudflare, a
server: cloudflareheader and the word “blocked” anywhere in the body matched a block-page fingerprint, so a real robots.txt with a comment such as “AI crawlers are blocked below” denied the whole site. The robots.txt fetch now leaves whole-line comments (lines that start with#) out of the fingerprint, so a file whose only match is in such a comment is read as the site’s rules. Any other body that fingerprints as a block page still denies the whole site at any size up to 100 KB. So does a robots.txt with the word in a rule (Disallow: /blocked-users) or in a trailing comment, and any body that contains<, which the check reads whole. (#507) -
A sitemap that lists a URL saying “blocked” is read behind Cloudflare. A
server: cloudflareheader and the word “blocked” anywhere in a small body matched a block-page fingerprint, so a sitemap listing a URL such as/blog/why-we-blocked-the-old-apiwas refused andmap()lost every URL it listed. A body with oneurlsetorsitemapindexroot, at least one entry, and no text outside its entries is now read as a sitemap, gzipped or not. A block page served at a sitemap URL is still refused. (#515) -
crawl_waf_blocks_totalcounts refused responses, once each. The counter moved on every WAF fingerprint match. The fetch path fingerprints one response more than once, so a single block added one or two, and aTomlClassifierset on the engine added one for every match it made. It now moves once for each response refused as a WAF block: by the fetch path, for a 403, 429 or 503 challenge or a 2xx interstitial, or by the engine, when its antibot strategy or retry policy refuses a response as a WAF block. A response that is returned as content does not count, and no response counts twice. -
An address with an upper-case scheme was refused. The REST API and the MCP tools tested a caller-supplied address against a lower-case
http:///https://prefix, soHTTP://example.com/andHttps://example.com/were rejected even though the URL parser accepts them. A URL scheme is case-insensitive. Both entry points now parse the address and read the parsed scheme instead. (#221) -
A configured user-agent rotation list had no effect on the wasm target. Every wasm request sent the fixed default agent, and robots.txt was judged for that same default agent. Neither used the rotation list. The wasm crawl loop now picks the next rotation agent once per page, judges that page’s robots.txt for it, and sends that same agent on the request – the per-page behavior the native crawl loop already had. A page on another origin, such as a subdomain under
allow_subdomains, is now judged by that origin’s own robots.txt, as on native. Before, every page was judged by the seed origin’s robots.txt, so such a page can now be refused. (#483) -
The WASM crawl sent
authandcustom_headersto every host it followed. The sequential crawl loop, which the WASM build runs, scraped each page as if it were a new seed, so a subdomain page followed underallow_subdomainsor a document link on another host got the credentials set for the seed host. Each page now keeps the seed’s credential scope, as the native crawl already did. The same loop also dropped the user name and password written into a seed URL, so no page got them, not even the seed; every page on the seed host now gets them. (#404) -
The native browser backend connected to a rebinding host’s second DNS answer. It checked a host’s addresses against the SSRF policy, and then its HTTP clients resolved the host again to connect. A DNS answer that changed between the two lookups reached an address the policy denies. The page, redirect, script
fetch(), module import and stealth clients now connect only to the addresses the policy checked, as the HTTP path already does. With a configured proxy, the proxy resolves the target. Two setups that worked before are now refused, as on the HTTP path: a proxy set by theHTTP_PROXYenvironment variable whose host name resolves to a private address, and, whencrawlberg-browseris used directly with its default policy, a host name that resolves to a private address. A refusal now names the policy’s reason. (#451) -
IPv6 forms that carry an IPv4 address bypassed the SSRF deny-list. The deny-list matches within one address family, so only the IPv4-mapped and NAT64 well-known forms were unwrapped before it ran;
http://[::10.0.0.5]/,http://[::ffff:0:a00:5]/andhttp://[2002:a00:5::]/all reached the private host 10.0.0.5 withdeny_privateon. The IPv4-compatible (::/96), IPv4-translated (::ffff:0:0:0/96), 6to4 (2002::/16) and ISATAP (interface identifier0000:5efeor0200:5efe, under any prefix) forms are now unwrapped as well, and the embedded address is checked against the IPv4 rows of the deny-list. The pre-connect check, the connect-time resolver and the browser crate’s fallback validator apply the same rules. An address that only has the shape of one of these forms is refused for the address it seems to carry:2001:db8::5efe:1:1reads as0.1.0.1and is refused. (#109) -
A Teredo address reached the private IPv4 address it carries. A
2001:0::/32address stores the client’s IPv4 address inverted in its last 32 bits, and nothing decoded it, sohttp://[2001:0:4136:e378:0:ffff:5601:5601]/reached 169.254.169.254 withdeny_privateon. The address is now decoded and checked like the other embedded forms, so a Teredo address that carries a public IPv4 address still works. (#196) -
The local-use NAT64 prefix
64:ff9b:1::/48carried private addresses past the deny-list. The IPv4 address in the last 32 bits, where a /96 network puts it, is now checked, sohttp://[64:ff9b:1::a00:5]/is refused. A /48, /56 or /64 network puts the address elsewhere and its unused bits read as zeros at that position, so a reading whose last three octets are zero is skipped unless the prefix bytes after the /48 are zero too. Addresses of those three network sizes are checked as IPv6 only, as before. (#108) -
The reserved range
240.0.0.0/4passed the SSRF deny-list. Withdeny_privateon,http://255.255.255.255/and every other address in the range was fetched, plain or embedded in an IPv6 form that carries an IPv4 address. The range is now refused everywhere the deny-list applies, with reasonprivate_network, the same reason the shared address space and the other RFC 1918 ranges already report. (#173) -
A denial reason could name an address the allowlist permits. The reason was classified from the first deny-listed candidate rather than the first one the allowlist did not admit, so an allowlisted
fe80::/10withfe80::5efe:10.0.0.5reportedlink_localinstead ofprivate_network. The allow or deny decision itself was always correct. -
With user-agent rotation on, robots rules were matched against the configured agent, not the one a request actually sent. A rotating crawl sends a different agent per request, but robots.txt group selection and meta or header directives always judged the page against the single configured agent. A site’s rule for the agent that made the request was ignored, and a rule for the configured agent applied even to a request that used a different one. Every robots decision now reads the agent the request actually sent; a crawl that does not rotate sees no change. A
user-agentset throughcustom_headersis judged the same way, since it is the agent the request actually sends. Withbrowser.modeset toalwaysorstealth, the browser never sends a rotated agent; robots decisions for a browser-fetched request now read the browser’s own configured or custom-header agent, so a disallowed browser request is blocked instead of judged against an agent it never sends. Withbrowser.modeset toauto, a request that escalates mid-crawl to the browser tier is now judged again at that point: the earlier robots decision, made before the tier was known, read whatever agent the HTTP attempt used, and the browser tier ignored it and sent its own agent regardless. Escalating to the browser tier now re-checks robots.txt against the agent the browser actually sends, and a disallow stops the fetch. An empty or whitespace-onlycustom_headers["user-agent"]value now counts as absent for both robots judging and what every tier sends, instead of being sent on the wire as a literal blank agent. A robots.txt, sitemap or asset fetch with acustom_headersagent configured alongsideuser_agentsent both as two separateUser-Agentheader lines; it now sends the custom-header agent once. (#423) -
The credential redactor passed a malformed address through unchanged. It only stripped
user:pass@when the value parsed as a URL with a host. A value that failed to parse, such as a stray space in the host, a bareuser:pass@hostwith no scheme, or an address inside a longer message, was logged exactly as received, credentials included. Such a value is now replaced whole with[address hidden: it may carry credentials]when it contains an@, and a value without an@comes back unchanged. A URL that parses with a host and has no whitespace is redacted as before. The placeholder also replaces any other value with an@that is not one URL with a host: an “invalid URL” error for such an address, amailto:ordata:value, and a message that mentions an e-mail address. Three sitemap warnings (the document budget cap, the index depth cap, and cycle detection) also logged their address without going through the redactor at all; they now do. A call that starts from an address with no host and an@, such asuser:pass@host/path, is now refused before any trace span or event records the address, and the error names it as(unparseable URL), so the password no longer reaches a log field. (#236, #243, #261, #399) -
Links after an abruptly closed or empty comment were not extracted.
tlends a comment by searching for a literal-->right after the opening<!--, so it never recognized<!-->or<!--->, which close before any-->exists; a comment closed with--!>instead of-->; or a plain, valid, empty comment,<!---->, whose close sits directly against the opener’s own dashes. After any of these,tlkept reading as if still inside the comment, so every link, image and base address past it was missed. The raw-text masking pass now neutralizes all of them beforetlparses the page. (#212) -
Robots directives ignored
noneand applied a crawler-scoped directive to every crawler. A robots meta tag orX-Robots-Tagheader that said onlynonewas read as neither noindex nor nofollow, althoughnonemeans both. A header addressed to one crawler, such asX-Robots-Tag: googlebot: noindex, bound crawlberg too, and a meta tag named for crawlberg’s own user agent was ignored.nonenow sets both directives. A directive named for a crawler binds crawlberg only when that name is a prefix of crawlberg’s user agent, the same rule robots.txt groups use, and the genericrobotsform still binds every crawler. (#156) -
A URL’s password leaked, and credentials reached hosts they were not for. The
user:pass@of a caller’s URL stayed inside every URL the engine handled, so logs, errors, results, cache keys and plugin callbacks each had to redact it, and several did not. Relative links and redirects also copied it to other pages. The engine now removes it at the start of each call and keeps it as a credential for the seed host only. The same host rule now applies toauth: a page, asset, robots.txt or redirect on another host gets no credentials. Both browser backends now sendBasic,Bearerand header credentials only to the seed host, one request at a time, instead of to every host a page loads from. robots.txt and sitemaps on the seed host are now fetched with the credentials. A response fetched with credentials is never stored in or served from the response cache or the shared robots.txt cache. (#378, #387, #388, #389, #390) -
A page could make the browser send a URL with userinfo. A page-supplied link, sitemap entry or redirect target loses its userinfo, and in the native browser a navigation, module import or
fetch()to a URL with userinfo is refused, as the Fetch standard requires. The chromiumoxide backend refuses such a request too. A URL that does not parse is reported without its text. (#347, #357, #382) -
Custom headers reached every host a crawl touched. Plain HTTP requests and both browser backends sent
custom_headersto other hosts: linked documents, third-party subresources and cross-host redirect targets. They now go only to requests on the seed’s host, the same as the credentials. (#393) -
A page script in the native browser did not get the seed-host credentials. A
fetch()or a module import to the seed’s host now carries the credentials and the custom headers, as it does in Chrome. A module redirect to another host drops them, and every module redirect is now checked against the SSRF policy. (#409) -
A link whose
hrefdoes not resolve was returned as raw text. Such a link is now left out of the page’s links instead of appearing with its unresolved text as its URL. (#394) -
A redirect to a non-web address failed the whole scrape. A 3xx whose
Locationwas amailto:,tel:,javascript:,data:,file:,about:orftp:address, or a custom app scheme, failedscrape()with anssrf_policy_violationerror. A crawl stopped on such a seed with the same error and dropped such a linked page. A browser sends no request for such an address. ThatLocationis now no redirect target, matching how a refresh naming such an address directly already was, so the 3xx response is the page. The same holds for robots.txt, sitemap and asset fetches: a robots.txt that redirected to such an address made the crawl refuse the whole site. Redirects tohttpandhttpsaddresses still pass the SSRF check. (#361) -
A redirect to a non-web address still failed or timed out with the Chromiumoxide backend. Chrome either refused such a redirect or waited for an external application. Browser mode now returns the original 3xx status, headers and URL without following the
Location. This also applies when a page script starts the redirect after its initial load. Its body is empty in this backend: at the response headers Chrome receives an internal 200 with only sandboxed plain-text headers, so it cannot act on the non-webLocation, attachment or HTML content. (#471) -
The Chromiumoxide interaction renderer script ignored
browser.timeout. A configured post-navigationeval_scriptcould wait for chromiumoxide’s fixed 30-second command deadline when the page’s renderer was busy. It now fails within the configured browser timeout. (#569) -
A relative meta refresh could still fail the scrape once it resolved to a non-web address. A meta refresh target was checked for a fetchable scheme before it resolved, so a relative target passed that check and could still resolve to a
mailto:,ftp:or other non-web address afterward, for example under a<base href>on such an address, and the fetch then hit the SSRF policy. The scheme is now checked on the resolved address instead, the same way theLocationheader already was, so such a target is no redirect target either, and the page is kept. (#478) -
A meta refresh target ignored the page’s base address.
<meta http-equiv="refresh" content="0; url=next">under<base href="/app/">was requested at/next, the page’s own path, while a browser requests/app/next. The target now resolves against the page’s base URL, the same base the links list uses. ARefreshHTTP header still resolves against the response’s own address: it arrives before any document exists to carry a base element, and Chrome ignores the body’s base for it too. (#300) -
A
data:orjavascript:base address was used as the page base. With<base href="javascript:alert(1)//">, every relative link, image, feed, icon and canonical link on the page resolved against the script address, and the markdown kept relative links as written. The page base is now the page address when the base address has one of these schemes, in any letter case and with spaces around it, as the HTML spec and browsers do. (#311) -
The browser page used an absolute subresource address without parsing it. A
<script src>or<link rel=stylesheet href>that began withhttp://orhttps://reached the interception block list and the network events exactly as written, while a relative address was parsed and normalized. A script address with trailing spaces or an inner tab or newline therefore slipped past a block pattern such as*blocked.jsand was still fetched and run. A module script’s network event also carried the raw address. Every script and stylesheet address in the page markup now goes through the URL parser against the page address, and an address that does not parse is skipped. (#225) -
The CLI and
browser.endpointconfig field refused an upper-caseWS://orWss://address. Both compared the raw text against a lower-casews:///wss://prefix, but a URL scheme is case-insensitive (RFC 3986 §3.1). Both now parse the address and read its scheme, and a websocket endpoint with no host is still refused. The browser connection uses the same parse and sends the address with a lower-case scheme, so an upper-case, space-padded or slash-less spelling that the checks accept also connects. The CLI’s rejection error no longer prints the address, the same as the config check. (#343) -
A failed connection to a remote browser printed its password. When crawlberg could not connect to a
browser.endpoint, the connect error showed the address as configured, with itsuser:pass@credentials and its CDP path token. The error now prints only the scheme, the host and the port. (#424) -
The interact backend’s connect error printed a browser endpoint’s password. It built the same connect error as the launch path, without redacting the address. It now prints only the origin, the same as the launch path. (#473)
-
The SSRF check could print a credential as the refused scheme. An address written without a scheme, such as
user:token@hostorKEY:@host:1, parses with its user name as the scheme, and the refusal printed that scheme:disallowed scheme: user, orForbidden URL scheme 'user'from the browser check. The refusal now names the scheme only when it is a known one, such asftporfile. For any other scheme,DisallowedSchemecarriesunrecognizedand the browser check says the scheme is forbidden without showing it. The address in the same error goes through the credential redactor, which hides such an address whole. (#329) -
A crawl of an address without a host printed its credential. A crawl refused a seed such as
user:token@hostbecause the seed has no host to read robots.txt from, and the reason named the seed as written, sotokenreached the crawl result, the error event and the error hook. Such a seed is now refused before the crawl starts and is named(unparseable URL). The robots refusal for an address without a host also names it through the credential redactor. (#427) -
Links with an encoded
&were crawled at the wrong URL. The links list kept character references as written, sohref="list?a=1&b=2"was requested aslist?a=1&b=2. Every attribute value that crawlberg reads is now decoded first, as a browser decodes it. This also covers image addresses, feed and favicon links, and text such as an image’s alt text. Thejavascript:,mailto:,tel:anddata:addresses that the links list, the images list and asset downloads skip are now recognised as the URL parser reads them, in any letter case and with tabs or newlines inside, sojava	script:is skipped likejavascript:. (#86) -
The native browser never ran a module script loaded from an address. A
<script type="module" src="app.js">was registered with empty code, soapp.jswas never fetched and the page rendered as if the script were absent. The module is now fetched through the module loader, with the same SSRF policy, proxy and seed-host credential as animport(), and then run with every module it imports. Every module address, including each module that a module script or an inline module imports, now goes through the interception block list, as a classic<script src>does, and every module request carries the page’s User-Agent. A module that fails to load, or whose server does not answer within 10 seconds, is skipped and the other scripts still run. The same 10-second bound now also applies to the modules an inline module script imports. (#441) -
Uppercase markup was ignored.
<A HREF="up.html">was missing from the links list, so the crawl never followed it, and uppercase<IMG>,<TITLE>,<META>and<LINK>tags were skipped the same way. Tag names now match in any case. (#87) -
The images list ignored
<base href>. Image addresses now resolve against the same base as the links list: the first<base href>, resolved against the page URL. (#88) -
Attribute values were matched with exact case. HTML compares values such as
rel,name,http-equivandtypewithout case, but crawlberg compared them byte for byte, so<meta name="ROBOTS" content="noindex">did not mark the page as noindex, andrel="Canonical",rel="Alternate"andrel="ICON"were skipped. These values now match in any case.relis a list of words, so it matches when any word matches:rel="shortcut icon"andrel="alternate stylesheet"count, and a link withrel="External NoFollow"is nofollow. A comma also separates the link qualifiersnofollow,ugcandsponsored, sorel="ugc,nofollow"is nofollow too. Asset downloads now also fetch alternate stylesheets. The fallback scan for<meta>tags in malformed pages also reads<META NAME=...>now. (#100) -
Feed, favicon, asset and canonical addresses ignored
<base href>. They resolved against the page URL, and the canonical URL was not resolved at all, so<link rel="canonical" href="c.html">was reported asc.html. They now resolve against the same base as the links list, as a browser resolves a<link href>. (#101) -
Attribute values with spaces or parameters were not matched.
<meta name=" robots ">was not read as the robots tag, and a JSON-LD or feedtypewith parameters, such asapplication/ld+json; charset=utf-8, was skipped. Atypeis now compared by its MIME type without the parameters. Atype,name,propertyorhttp-equivvalue is also compared without the ASCII whitespace around it. HTML strips that whitespace from a<script type>, but not from the others: a browser ignoreshttp-equiv=" refresh ". Reading those values with the spaces is a deliberate leniency for pages that add them. (#136) -
An empty canonical link was reported as a canonical URL.
<link rel="canonical" href="">gave a canonical URL of"". An empty or whitespace-onlyhrefpoints at the page itself, so the page now has no canonical URL. (#137) -
hreflang addresses were not resolved. The alternate-language links kept each address as the page wrote it, and
<base href>had no effect. They now resolve against the same base as the links list. The language code is reported without the spaces around it, and a link whose language orhrefis only whitespace is skipped, as an empty one was. (#126) -
Attribute values kept CR and NUL characters. A browser turns CR and CRLF in an attribute value into LF, and NUL into U+FFFD. crawlberg did this only for values with a character reference, so
href="x.html\r\n"stayed as written. Every attribute value now gets this rewrite. (#160) -
A feed or icon link with a blank
hrefwas reported.<link rel="alternate" type="application/rss+xml" href=" ">was reported as a feed at the page URL, and an emptyhrefas a feed at"". A feed or icon link whosehrefis empty or only whitespace is now skipped, as a canonical or hreflang link is. (#187) -
The links list dropped Unicode spaces from the ends of an address. A link such as
href=" page.html"was reported aspage.html, but a browser and the Markdown rewrite keep the no-break space. Every address in a page (links, feeds, icons, hreflang, canonical, images, assets, the Markdown rewrite and a meta refresh target) now loses only what the URL parser removes: control characters and spaces up to U+0020 at either end, and tabs and newlines inside. An address with nothing else in it counts as blank. (#191) -
Images and assets with a blank address were reported at the page URL.
<img src=" ">, anog:imageortwitter:imageof only whitespace, and a stylesheet, script or image asset with a blank address each resolved to the page itself. They are now skipped. -
A
srcsetwas split on Unicode spaces. The first<source srcset>candidate was cut at a no-break space, and leading commas hid the candidate after them. The list is now split as a browser splits it, on ASCII whitespace and commas. An inlinedata:candidate is skipped, as an<img>one is. -
A meta refresh target dropped a trailing no-break space. The target now keeps it, as a browser does, and a target of only control characters is no redirect.
-
Some inline and script addresses still reached the images and links lists. A
<picture><source srcset>whose first candidate was adata:address in upper or mixed case, such asDATA:image/png;base64,..., was reported as an image. Anog:imageortwitter:imagewhose content was adata:address, in any case, was reported as an image too. Both are now skipped, as an<img>with adata:address is. The links list now also skipsvbscript:links in any case, as it skipsjavascript:. (#200) -
A refresh target kept its quotes, and the two refresh forms cleaned the target by different rules. A
<meta http-equiv="refresh">orRefreshheader written as0; url='/next'sent the crawl to'/next'with the quotes, where a browser goes to/next. TheRefreshheader target was trimmed by the Unicode whitespace rule, which drops a no-break space, while the meta refresh target was cleaned by the URL parser’s rule, which keeps it. Both forms now use one reader that follows the HTML refresh steps: a leading delay, then;,,or whitespace, then an optionalurl=in any case, then an optional pair of matching quotes. The URL parser’s rule then cleans the target. As in a browser, a value with no leading delay is not a refresh, and a target withouturl=is followed, so in0; /go?url=/elsewherethe target is/go?url=/elsewhere. A refresh to an address the URL parser reads with a scheme the crawl cannot fetch, such asmailto:,javascript:ordata:, is no longer a redirect: the page is kept, where the scrape used to fail with an SSRF policy error. (#206, #208) -
A page with several meta refresh tags was sent to a different target than a browser. The crawl skipped a meta refresh with a blank target and followed the next one, and otherwise followed the first tag. Chrome acts on the refresh with the shortest delay, and on the later tag when two delays tie, and a blank or self target reloads the page. The crawl now chooses the same tag, and stays on the page when that tag reloads it. A
javascript:refresh takes no part in that choice, as the HTML refresh steps require, so a later refresh can be used. (#279) -
The Alef pin named 0.97.0, but the committed Go binding already carried 0.97.1’s goroutine thread pinning. Regenerating with the pinned 0.97.0 binary drops
runtime.LockOSThreadaround the FFI’s cgo calls; regenerating with 0.97.1 reproduces the committed binding exactly.alef verifydid not compare the Go binding with a fresh render, so it could not see the mismatch. Repinned Alef to 0.97.1. Alef 0.97.1 no longer turns off the SSRF private-network check in the wasm e2e tests and wasm doc snippets on its own, soalef.tomlnow asks for it withwasm_config_overrides. The Rust mock server ine2e/rustandtest_apps/rustis regenerated with 0.97.1. (#412) -
Images with a script address were reported. The images list skipped only
data:addresses, so<img src="javascript:...">, avbscript:<source srcset>or anog:imageofjavascript:...came back as an image. It now skipsdata:,javascript:andvbscript:addresses in any case, as the links list does. Asset discovery now skips the same addresses when it finds assets on the page. No asset with one of these addresses was downloaded before, because the downloader accepts onlyhttp:andhttps:. (#276) -
Feed, favicon, canonical and hreflang links with a script address were reported. A
<link rel="icon" href="javascript:...">came back as the page’s favicon, and ajavascript:orvbscript:feed, canonical or hreflang link came back as an address. Feed, canonical and hreflang links now skipdata:,javascript:andvbscript:addresses in any case, as the links list does. Favicons skip the script schemes and keep anydata:icon, whatever its media type. These links, and the<source srcset>,og:imageandtwitter:imageentries of the images list, are checked on the address after it resolves against the base, so an address that resolves to a script scheme is skipped too. (#291) -
Link extraction could disagree with the markdown about the same tag. Link extraction read every page with tl. On a page with an unterminated quote or a stray
=before a tag’s>, tl could read a different tag boundary than the page’s real structure, so the links list showed no link, or the wrong address, for a link the markdown still carried. Each real<a>start tag is now rewritten into unambiguous form first – one copy of each attribute, double-quoted, as html5ever’s tokenizer reads it – so link extraction and the markdown agree on the same tag. This reads every page’s links a second time and is slower on a link-heavy page; a well-formed<a>tag is rewritten to itself. (#294) -
The bypass provider could expose a vendor API key. For a vendor that takes its key as a query parameter, the vendor’s request URL carries the key.
BypassProvider::fetchreturned that URL as the response’sfinal_url, and its send and body-read errors printed it. A caller offetchthat readfinal_urlor formatted the response with{:?}saw the key. Crawl and scrape results never carried it, because the engine does not read a bypass response’sfinal_url.final_urlis now empty, as the field’s contract allows when the vendor does not report the resolved URL. The send and body-read errors now name the vendor and the error kind only. (#89) -
A caller’s debug output of a config printed its secrets. Crawlberg does not log these types, but a caller that formats one with
{:?}, such astracing::debug!(?config), a panic or anexpectmessage, printed a bypass provider config’s API key, token or auth header value. The same held for custom request headers, a CDP endpoint token, proxy credentials in a browser session key, the REST API token, cookie values and the native browser’s proxy URL. Each now prints***in place of the secret and keeps the non-secret fields. A browsereval_scriptprints as***with its length, because a script can embed a token. This covers the Rust types only: the language bindings define their own config types, and theirreprandinspectoutput is unchanged. (#118, #290) -
An unclosed
${in a bypass provider config echoed its value. The loader error printed the whole config value, which can hold a secret. It now names the field and the byte position. (#119) -
A config validation error echoed the rejected
browser.endpoint. An endpoint that is notws://orwss://printed the value, so one carrying a?token=parameter reached the error text and, through it, an API error body. The error now names only the field and prints no part of the value, not even redacted: the endpoint is a capability, and the field name is enough to find it. Theproxy.urlerror has not carried the value since #401. (#118)Redaction covers
Debugand errorDisplay.serdeserialisation is deliberately unchanged:CrawlConfig,BrowserConfig,ProxyConfig,AuthConfigandCookieInfostill serialise every secret in full, because a config must round-trip throughto_json()/JSON exactly. Treat serialised config as secret-bearing. -
A caller’s debug output of a bypass provider config printed
${ENV}values. Crawlberg prints only the vendor name for a provider, but a caller that formats a loadedProviderConfigwith{:?}saw a secret substituted into the endpoint, a fixed query value or the JSON body template. The endpoint now prints as its origin only: the scheme, the host and a non-default port, or***when it does not parse as an absolute URL or has no host. Each query value prints as***. The body template prints as***with its length, and with whether it holds the{{url}}marker. (#144, #152) -
A CDP endpoint token in the URL path printed in full. The canonical endpoint is
ws://host:9222/devtools/browser/<GUID>, and the GUID in the path is the capability that drives the browser. Redaction covered only the userinfo and the query, so the debug output ofbrowser.endpointandBrowserPoolConfig.browser_endpointprinted the GUID, and an endpoint that did not parse printed whole. Both now print throughcrawlberg::net::redact::redact_url_to_origin, the origin-only helper the bypass provider config uses, which prints***for a value without a host. A proxy URL now prints as its origin too, in aProxyConfig, a browser session key and a static proxy provider. The port stays: it tells a container-mapped endpoint from the default 9222, and it is no more secret than the host. Two pooled endpoints on the same host and port now print the same. (#152) -
A failed bypass request logs its cause. The send and body-read errors carry only the error kind, so the provider now logs a warning with the vendor, the endpoint’s origin and the cause chain when a send or a body read fails. (#89)
-
A caller’s debug output of a response printed its credential headers. The fetch and bypass responses, the native browser’s rendered page and responses, and the network events printed every response header value with
{:?}, including aSet-Cookiesession cookie. A response header map now hides the values of a denylist of credential headers:Authorization,Proxy-Authorization,Cookie,Set-Cookie,Authentication-Info,X-Api-KeyandX-Amz-Security-Tokenprint as***. Every other response header prints in full, becauseContent-Type,Serverand the like are the debugging value. Header names always stay visible. A request header map prints no value at all, whatever the header’s name, ascustom_headersinCrawlConfigalready does. (#141) -
An absolute redirect target was followed exactly as sent, without going through the URL parser. A relative redirect target was resolved through
Url::join, which parses it and reports the parser’s normalized form, stripped of an embedded tab or newline and trimmed of leading/trailing spaces. An absolutehttp:///https://target skipped that parse entirely and came back byte-for-byte as received, so aLocation,Refresh, or<meta refresh>value crafted with stray whitespace was followed and reported exactly as sent. Both forms now go through the same parser, and a target that fails to parse, absolute or relative, is refused rather than followed: the redirect source it came from contributes nothing, and the chain falls through to the next source or stops. A target is now followed in the URL parser’s normalized form: an IDN host becomes punycode, a default port is dropped, the host is lower-cased, a bare origin gains a trailing/, dot segments are removed, a space becomes%20, and127.1becomes127.0.0.1. (#207) -
A sitemap-index child
<loc>was fetched and deduplicated on its raw text instead of its parsed form. A same-host absolute child address, and any child address when the sitemap index’s own URL failed to parse, skipped the URL parser entirely, so two spellings of the same address (a default port, an upper-case scheme, a stray tab) were fetched as two separate documents. Every child address is now parsed and normalized before it is fetched and before it is used as the duplicate key, matching the resolver already used for redirect targets, and a child address that fails to parse is skipped instead of fetched as raw text. (#226) -
map()returned sitemap<loc>entries as raw text and kept duplicates. Two spellings of one page, such ashttps://example.com/aandHTTPS://example.com:443/a, came back as two entries, a relative<loc>came back as a bare path, and a<loc>that is not an address came back as text. Each<loc>is now resolved against the sitemap’s own URL with the same parser as sitemap-index children and returned in its normalized form. A<loc>that does not parse is dropped. An address is returned once permap()call, even when several sitemaps list it, and a duplicate does not count towardmap_limit. A relative<loc>is now subject toexclude_paths, like every other entry.map_searchnow matches the normalized address, so a search for a raw spelling, such as a default port or non-ASCII text in the path or host, no longer matches. A<loc>that is only a query, such as?q=1, is dropped instead of reported as a page, and so is a<loc>that resolves to the sitemap’s own address once a fragment such as#topis ignored. (#323, #340) -
A sitemap-index child differing only by a URL fragment was fetched twice. The fragment never reaches the server, so
/a.xmland/a.xml#xname the same document, but the host rewrite kept the fragment on a same-host child address before it was fetched and used as the duplicate key. A relative child address such asa.xml#xkept its fragment too. The fragment is now dropped from every child address, so both addresses fetch and dedupe as one document. (#324, #363) -
map()resolved a relative sitemap<loc>against the address it requested, not the one that answered. When/sitemap.xmlredirected to/nested/sitemap.xml,<loc>page</loc>became/pageinstead of/nested/page. Urlset entries and sitemap-index children now resolve against the sitemap’s URL after redirects. A redirected index that lists its own address, the one it answered from, is no longer fetched a second time. (#339, #374) -
A sitemap index’s children on other hosts were fetched from the index’s own host. An index at
https://example.com/sitemap.xmlthat listedhttps://blog.example.com/sitemap.xmlandhttps://shop.example.com/sitemap.xmlhad each child moved ontoexample.comwith its path kept, so both becamehttps://example.com/sitemap.xml, the index itself, and were skipped as a cycle. Their pages were missing from the result. Each child is now fetched from its own host, as the sitemaps.org protocol allows. The SSRF policy checks every child fetch, and the seed’s credentials and custom headers still go only to the seed host. (#398) -
A robots.txt
Sitemap:line on another host was fetched from the seed’s host. A robots.txt onexample.comthat namedhttps://cdn.example.net/sitemap.xmlmademap()fetchhttps://example.com/sitemap.xmlinstead, which is another document or none. The sitemaps.org protocol lets robots.txt name a sitemap on another host, so the line is now fetched from the host it names. The SSRF policy checks the fetch, and the seed’s credentials and custom headers go only to the seed host. A relativeSitemap:line now resolves against the address that served robots.txt after its redirects, not the address being mapped. (#268, #349) -
A non-ASCII
map_searchterm never matched an addressmap()normalized.map()returns each address in the URL parser’s normalized form, which percent-encodes a non-ASCII path and encodes a non-ASCII host as punycode, so a search forcafénever foundhttps://example.com/caf%C3%A9and a search forbüchernever found the matchingxn--bcher-kva.examplehost.map_searchnow also matches the decoded, human-readable form of the address, alongside the address text itself. The term and the address are compared after Unicode normalization and default case folding, socafétyped with a combining accent findscafé, andSTRASSEfinds/Straße. Case folding does not use a locale, so the Turkish dotted and dotlessido not match their Turkish case partners. (#338) -
The
searchfield ofPOST /v1/mapnever matched a non-ASCII term. The REST handler kept its own lower-case substring check against the returned address, socafénever foundhttps://example.com/caf%C3%A9. It now setsmap_searchfor the call, so the endpoint matches a term the same way as the CLI and the MCPmaptool. (#362) -
map()resolved a redirected HTML page’s links against the address it requested, not the one that answered. When/startredirected to/dir/page.html, a link tox.htmlon that page came back as/x.htmlinstead of/dir/x.html. Every other branch of a directmap()fetch (a urlset, a sitemap index, a gzipped sitemap) already resolved against the URL after redirects; the HTML link branch now does too, matching the crawl engine. (#360) -
One look-around pattern refused the whole configuration.
include_pathsandexclude_pathscompiled on an engine without look-around or backreferences, so a single(?!...)pattern madecreate_enginereject every pattern in the list. A pattern that engine accepts still compiles there, with the same meaning. A pattern compiles withfancy-regexonly when theregexcrate’s first error is an unsupported look-around or a numbered backreference, so look-around and numbered backreferences such as\1work. A pattern whose first error is anything else, such asa{2,1}, still refuses the configuration and names the pattern. When a look-around comes before a malformed part in the same pattern, the look-around is the first error and the pattern still goes tofancy-regex(#283). A look-around or backreference pattern is evaluated only on a matched text (the path by default) of up to 2048 bytes, and gives up after 100,000 backtracks. A URL whose text is longer, or that hits that limit, stays out of the crawl: an exclude pattern counts as a match, an include pattern as no match, and one warning per crawl names the pattern. The seed is exempt from the include check. The REST API refuses a look-around or backreference pattern inincludePathsorexcludePathswith a 400. (#78) -
DownloadedDocumentprinted every response header value under{:?}. The type derivedDebugoverheaders, so aSet-Cookieor an echoedAuthorizationreached any debug render of a scrape or crawl page result — the value itself, not just the name.DownloadedDocumentnow has a hand-writtenDebugthat prints***for every header on the shared sensitive list (Authorization,Proxy-Authorization,Cookie,Set-Cookie,X-Api-Key,X-Amz-Security-TokenandAuthentication-Info), matching names without case; every header name and every other value stays visible. Output is unchanged for a document crawlberg produced itself, because no path in the core populatesheadersyet — the leak was reachable through a deserialised or caller-built value. The Elixir and Ruby binding mirrors keep their own derivedDebugover their own header map and are not covered by this. (#159) -
A custom retry policy could not read the status of a failed attempt.
AttemptOutcome.statuswas always empty when the attempt ended in an error, so a policy written outside crawlberg saw the error but not the 503 or 500 behind it. The field now holds the status for every status the built-in mapping turns into an error itself (401, 404, 408, 410, 429, 500, 502, 503, 504). It stays empty when no response caused the error, such as a connection failure. (#99)
Changed
Section titled “Changed”- Upgraded
html-to-markdown-rsto 3.16.0. A comma inside a parenthesisedsrcsetdescriptor no longer starts a new candidate, soa.png (x, b.png 3x ), c.png 2xshowsc.pngand not theb.pngwritten inside the descriptor (#320). The front matter shows the base address with its character references decoded,it'srather thanit's(#103).
Internal
Section titled “Internal”- The test that failed when
html-to-markdown-rsreached 3.15 is replaced. It checked that the converter had nobase_urloption. Tests now hold the behaviours that matter: a fragment-only link stays as written, whichbase_urlwould change, and the link pre-pass keeps an empty source empty and strips userinfo from a link. (#190)
[1.8.0] - 2026-09-27
Section titled “[1.8.0] - 2026-09-27”Includes twelve issues raised by an external evaluation, ten of them in the crawl path. Most were defects a green e2e suite could not see: the fixtures covering the affected behaviours passed with the bugs fully present, and the assertion vocabulary cannot express request counts or elapsed time at all, so the whole “how many requests did we send, and how long did we wait” class was invisible by construction.
Upgrading
Section titled “Upgrading”-
CrawlPageResultgained two fields and rejects unknown ones.noindex_detectedandnofollow_detectedare always serialised, andCrawlPageResultcarries#[serde(deny_unknown_fields)], so a page result serialised by this version is rejected by every older crawlberg — even when both values arefalse. The break is one-directional: an older result still loads here, because both fields default tofalse.What this affects:
- A cross-version pipeline that serialises a crawl result on one crawlberg and reads it on another. Upgrade the readers before, or with, the writers.
- A persisted
CrawlCache: entries written by this version cannot be read back by an older build, so a rollback must treat the cache as cold rather than reuse it. - Any binding that round-trips a page result through JSON across the FFI boundary
(
cberg_crawl_page_result_from_json), where the core and the binding can be at different versions.
-
The regenerated bindings add two required
CrawlPageResultconstructor arguments. Code that constructs aCrawlPageResultby hand — Swift’sinit, Dart’sconst CrawlPageResult({...}), Ruby’sinitialize, the Java constructor, the Python signature — must passnoindex_detectedandnofollow_detected. Reading a result that crawlberg returned is unaffected.
Four changes can affect an existing setup:
-
interact()now enforces the SSRF policy. It previously enforced none on the default browser backend, so a targetssrf.deny_privateshould have rejected was fetched anyway. Code that relied on reaching a loopback or private address throughinteract()must now opt in deliberately, the same wayscrape()andcrawl()already required. (#74) -
Saved browser profiles. Default Chrome flags now actually reach Chrome (see below), so cookies in a
browser_profilewritten by 1.7.2 or earlier may no longer be readable: they were encrypted with a keychain-backed key and the mock keychain uses a different one. -
BrowserConfiggained two fields and rejects unknown ones. A configuration serialised by 1.8.0 that carriesoverall_timeoutorshutdown_timeoutis rejected by older crawlberg versions. Older configurations still load unchanged. -
CrawlPageResult.normalized_urlnow normalises the post-redirect URL rather than the originally discovered one, so it keys on where the content actually came from. This also feedsCrawlResult::unique_normalized_urls().
-
CrawlEngineBuilder::document_filterlets a Rust consumer decide document materialization from the response bytes rather than the declared MIME type alone. The predicate receives the normalized MIME type, at mostdocument_max_sizebytes of the already bounded body, and the decisiondocument_mime_types/the built-in classification would have reached, so it can widen that decision (by_declared_mime || bytes.starts_with(b"%PDF")) instead of replacing it.crawl(),scrape()and the wasm crawl loop all honour it. With no predicate the declared-MIME decision is unchanged.The predicate runs for every fetched response, an ordinary HTML page included, so one that returns
truefor HTML materializes every page as aDownloadedDocument— duplicating its whole body into the result and writing it todocument_output_diron native targets. Keep it as narrow as the documents it is meant to admit. (#95) -
Relative links in page markdown pointed nowhere. The markdown kept each address exactly as the HTML wrote it, so
rel/child.htmlcould not be followed outside the page, and a<base href>had no effect. Relative addresses now resolve against the page’s<base href>or the URL that served the page, the same base thelinkslist uses. This covers<a href>;<img>src,data-src,data-lazy-src,data-original,data-srcsetandsrcset;srcon<iframe>,<video>,<audio>and<source>;<blockquote cite>; and the addresses of<graphic>. Character references in an address are decoded first, so/appresolves to/app. Absolute URLs, fragment-only links andmailto:,javascript:anddata:addresses stay as written. Because resolved links are longer,fit_contentcan now drop a line of relative links that it kept before, the same way it already treated absolute links. (#63) -
The markdown front matter showed the base address as written. A page with
<base href="/other/">gotbase: /other/. The front matter now shows the resolved base, the same address that relative links resolve against. (#94) -
ContentConfig.extract_metadataleaves the YAML frontmatter out of a page’s markdown when set tofalse. The head values remain available onPageMetadata, which is populated independently of the converter. (#64) -
CrawlConfig.path_patterns_match_querymatchesinclude_paths/exclude_pathsagainst the path and query (/blog?p=42) instead of the path alone. Path-only stays the default, because a pattern anchored with$changes meaning once the query joins the text. (#61) -
CrawlConfig.dedup_include_querykeeps the query in the dedup key, with its parameters sorted, so/item?id=1and/item?id=2are no longer one page.strip_tracking_paramsandtracking_paramsremove tracking parameters from the URL that is fetched and reported, not only from the key. (#65) -
CrawlConfig.retry_initial_delay_ms,retry_max_delay_msandrate_limit_jitter_ratiomake the first retry delay, the backoff ceiling and the per-domain delay jitter configurable. (#67) -
BrowserConfig.overall_timeoutandshutdown_timeoutbound a browser fetch end to end. (#66) -
CrawlPageResult.final_urlandredirect_countreport where a page’s content came from and how many hops it took. (#62) -
The Python release now publishes a macOS x86_64 wheel, so an Intel Mac no longer falls back to building the sdist. It carries a deployment target of 11.0, matching the existing arm64 wheel. (#57)
-
The vendored C header gate failed for lag rather than for a defect. It required each prebuilt platform bundle’s
crawlberg.hto declare exactly the same C API as the canonical header, but a vendored copy ships beside a dylib from the last release, so it legitimately lacks whatever the canonical header has gained since — adding twoCrawlPageResultgetters for #135 turnedmainred for that reason alone. The comparison is now one-directional: a declaration the vendored copy has and the canonical header does not still fails, because that means a prebuilt bundle promising a symbol HEAD removed or re-signed, while declarations the copy is merely missing are reported as lag. (#162) -
A whitespace-only favicon
hrefor imagesrcreported the page as its own favicon or image. The guard wasis_empty(), which is false for" ", and resolving a whitespace-only reference against a base yields the base itself, so<link rel="icon" href=" ">,<img src=" ">and a blankog:image/twitter:imagecontentall listed the page URL. Such an address is now skipped, via a sharedis_blank_addresshelper. Only ASCII whitespace counts as blank, because HTML strips nothing else from a URL attribute — an NBSP-only reference is a real value and is percent-encoded (#191). Canonical (#137) and hreflang (#126) leak the raw value instead, because they do not resolve at all. (#220) -
A browser fetch reported no response headers at all on the crawl path.
browser_http_to_crawlbuilt an empty header map, so every header a browser backend had collected was discarded before the crawl or the escalation path could read it —ETag,Cache-ControlandX-Robots-Tagreached no caller and no WAF classifier, however faithfully the backend reported them. This is why anofollowsent only as anX-Robots-Tagheader had no effect in browser mode even after the crawl learned to honour it. Headers are now carried through. The chromiumoxide backend still hardcodes its own status, content type and headers, so this reaches callers today on the native backend only; #166 covers the rest. (#148) -
Dropping a one-shot browser fetch ran no teardown at all. Teardown was straight-line code after the fetch, reached only once the fetch had finished, so a caller that dropped the future while it ran — a cancelled request, a
select!that lost, a deadline above crawlberg — got none of it. Against abrowser.endpointChrome that left crawlberg’s CDP websocket open, and the tab it had opened open with it: a connectedchromiumoxide::Browserowns no child process, so dropping it does nothing, and its handler loop never ends by itself. Against a launched Chrome the process went with the dropped handle, but its--user-data-dirstayed on disk. Teardown now belongs to a value whoseDropruns it, so a dropped fetch and a finished one take the same path, and the profile directory is owned by a guard from the moment it is created rather than from the moment the launch succeeds — a fetch cancelled mid-launch never had a session to tear down. One window remains open: the Chrome process thatBrowser::launchis still building cannot be reaped from outside it, so a fetch cancelled during the launch can leave that process behind, and it recreates the directory it was just removed from (#198). (#131) -
Teardown waited five seconds for the CDP handler after killing a hung Chrome. Killing the process does not end the task that runs its CDP handler: chromiumoxide’s handler loop returns only when a
Browser.closeresponse reaches it, and a closed websocket merely parks the loop, so the wait could never do anything but expire in full and abort the task anyway — about five seconds added to every teardown that had to kill a Chrome that had stopped responding. The close now reports whether the process exited or had to be killed, and the handler is aborted at once in the killed case. A browser that closed cleanly is unchanged, still given the same grace period to wind its handler down. (#146) -
A pooled browser fetch that hit its overall deadline leaked its page.
overall_timeoutwrapped the whole pooled fetch, so expiry dropped that future before it could release the page it had borrowed from the shared browser — andchromiumoxide::Pagehas no closingDrop, so the CDP target stayed open for the rest of the process’s life, still running scripts. The deadline now bounds page acquisition and navigation individually and the release runs on every path, the deadline one included. That release is bounded byshutdown_timeoutrather than by the overall deadline, so a browser too wedged to close a page cannot hold a fetch open, and an already-computed result is no longer replaced by a timeout error because teardown was slow. Closing a timed-out page’s popups is not covered here. (#179) -
A refused URL’s credentials reached the error text. The browser navigation path and interact mode built the SSRF violation error with a struct literal instead of the redacting constructor, so a request Chrome was refused at a redirect —
https://user:secret@10.0.0.1/— carried itsuser:pass@userinfo into the error message, and from there into API error bodies, MCP error payloads and tracing fields. Both sites now build the error throughCrawlError::ssrf_violation, which redacts the userinfo before it is stored. The pre-navigation seed check and the HTTP redirect path already used the redacting path and are unchanged. (#180) -
Four CI gates passed without examining anything. The vendored-C-header check compared only
packages/go/include/crawlberg.h, the one copy the header generator writes alongside the canonical file, leaving the three prebuilt-native copies unchecked; it now discovers every trackedcrawlberg.hfrom the repository index, byte-compares the generator’s own outputs, compares the vendored bundles as a normalised declaration stream, and fails on any copy it does not classify. The e2e fixture-drift check excludedpython,php,rubyandcfor formatter skew; measuring each formatter against alef 0.96.4 showed onlypythonhad any, soruffis now pinned and asserted and all four languages are gated. A pull request stacked on another pull request’s branch matched no CI workflow’sbranches: [main]base filter and ran none of them while showing green checks, so a base-branch guard now fails such a pull request explicitly. The hand-maintained docs-site changelog mirror had no check and had lost two[Unreleased]entries; it is resynced and gated. (#162, #127) -
A WAF challenge served with 503 or 429 was retried instead of escalated. WAF detection ran only for a 403 and for a 2xx, so a Cloudflare or Akamai interstitial served with 503 became a plain server error — and a challenge served with 429 a plain rate limit — before anything looked at the response. It was then retried by the same JavaScript-less client that provoked it and never reached the browser or bypass tier. A 403, 429 or 503 is now fingerprinted before it is turned into an error: a detected challenge is a WAF block and escalates, while a 429 or 503 with no WAF signal is unchanged — same error, same message, its status still attached, and still retried exactly as
retry_codessays. Escalation is chosen over retry for a detected challenge because re-issuing the identical request only reproduces it. Response headers are checked first, so a challenge named by a header costs no body read; only a 429 or 503 whose headers say nothing now reads a body that was previously discarded, under the usualmax_body_sizecap. Browser mode was never affected: CDP reports its own 200 for a navigation, so it cannot observe a 503. (#169) -
Links, images and the base address were read from
script,style,titleandtextareatext, and a comment opener in that text hid the real markup after it.tlhas no raw-text element handling and parses the contents of these elements as markup, so<script>document.write('<a href="/x">')</script>added/xto the links list and a<base href>inside title text changed the base for the whole page. In the other direction a<!--anywhere in script or style text started a comment for the parser, which then swallowed every tag up to the next-->: real links after the script were missing from the links list altogether, not merely mis-resolved. The<characters inside raw-text element content are now masked in the source before it is parsed — the point at which a browser stops reading markup — so link, image, feed, favicon, heading, meta-tag, base-address and<meta http-equiv="refresh">extraction all see the document a browser sees. Title text and JSON-LD payloads are unchanged unless they contain a literal<, which valid HTML writes as<. Contents ofsvgandmathare left alone, because a browser parses those as markup too. (#124, #125) -
A redirect in browser mode reported the requested URL. Chrome follows a redirect itself, and the page result kept the URL that was asked for, so relative links on the landed page resolved against the wrong path and
final_urlnamed a page that never served the content. The browser backends now report the URL they landed on. In a crawl, that URL passes the same SSRF check, robots.txt, path filters and duplicate check as an HTTP redirect target, and a page whose landed URL is refused is dropped. (#75) -
Dropping a crawl stream did not stop the crawl at once. The crawl noticed the dropped receiver only when it next sent a page, so failed fetches kept it starting requests, a fetch in flight went on to retry, and a seed still resolving retried to the end. The crawl now stops when the receiver goes away: in-flight fetches are aborted, and no later seed of a batch stream is fetched. This fixes the Rust stream. The Python binding’s generated stream still lets one or two requests start after the stream is closed; a later change to the binding generator fixes that. (#77)
-
A dropped batch stream still reported every seed it had not started. The batch went on starting each remaining seed, and each one sent a
Completewith zero pages to the event emitter and the event sink for a crawl that never ran. The batch now stops starting seeds when the stream is dropped, and a seed it never started reports nothing. (#91) -
retry_codesdid not gate error retries. A 408, 429, 500, 502, 503 or 504 response, and a transport timeout, were each retried the fullretry_counteven whenretry_codeslisted other statuses; only a status that raised no error of its own was checked against the list. A non-emptyretry_codesis now an allowlist over exactly those failures: one is retried only when the status it was raised for is listed, and a timeout that never saw a response carries no status, so it is not retried at all. An empty list is unchanged and still retries every rate limit, server error, bad gateway and timeout.map()and the wasm scrape path now follow the same rule, so with an empty list they retry these failures up toretry_countinstead of never. (#76)This narrows retries for any configuration that already sets
retry_codes, including a list written to add a status:retry_codes = [503], meaning “also retry 503”, now excludes the other five, so against a rate-limiting origin its 429 responses are no longer retried. List every status you want retried, or leaveretry_codesempty to retry all of them. The defaultretry_countis 0, so a configuration that never raised it sends one request either way and is unaffected. -
A 408 was told apart from other timeouts by guesswork. Every timeout counted as a 408, whether or not a response caused it, so a transport timeout was retried under
retry_codes = [408]. An error raised for a response status now carries that status, andretry_codesmatches only that. (#92) -
crawl()andscrape()returned a 504 as a page. The HTTP fetch treated a 504 as a success on these paths, whilemap()already reported it as a server error, so an emptyretry_codesdid not retry it and a gateway timeout page reached callers as content. Every path now maps a status to the same error, so a 504 is a server error everywhere and is retried like a 503. The messages of these errors onmap()now match the other paths:timeout,service unavailableandgateway timeout. (#76) -
A crawl ignored the page’s own robots instructions. With
respect_robots_txton, a crawl now leaves the links of a page markednofollow(by its robots meta tag or any of itsX-Robots-Tagheaders) unfollowed. A link markedrel="nofollow"is still followed, because it is a hint and not a robots directive. Anoindexpage is still crawled and its links followed. Each page result now reports both directives innoindex_detectedandnofollow_detected. Withrespect_robots_txtoff, nothing changes. See Upgrading above for the wire-format consequence of the two new fields. (#135) -
Only the first
X-Robots-Tagheader was read. A response that sent the header twice had anofollowornoindexin the second one ignored, andscrape()reported only the first value. Every header now counts, andx_robots_tagreports them joined with,. (#135) -
Two IPv6 deny reasons named only the first address in their prefix.
classify_private_ipmatchedfe80::/10andfc00::/7by exact first-hextet equality, sofeaa::1andfd12::1were reported asprivate_networkrather thanlink_localandunique_local— andfd12::is the common case, since RFC 4193 randomises the unique-local global id. Both prefixes are now matched as ranges. These addresses were refused before and are refused now; only the reason string in the error and the log field changes. (#205) -
allow_subdomainshad no effect. Every cross-host link was dropped as external before the host-scope check ran, so a link to a subdomain of the start host was never requested. The scope decision is now one helper shared by both crawl loops. (#60) -
A redirect on a discovered link was not followed. Only the start URL resolved its redirect chain; a discovered link answering 3xx was reported as a page with an empty body and its target was never requested. Frontier fetches now resolve redirects with robots,
exclude_pathsand SSRF enforced on every hop. Relative links on a redirected page also resolve against the final URL instead of the pre-redirect one, which was wrong whenever a redirect crossed origins. (#62) -
retry_countdid not bound requests. Two retry loops ran nested and the outer one never read the setting, so a URL answering 503 was requested4 * (retry_count + 1)times: 4, 8 and 20 forretry_count0, 1 and 4. Retries now have a single owner. Backoff existed in six disagreeing implementations, including one uncapped shift reachable from an unvalidatedusize; they now share one function andretry_countis bounded. (#67, #68) -
A browser fetch could wait without a limit.
BrowserConfig.timeoutcovered only navigation and the ready wait, leaving page creation, setup, content extraction and shutdown unbounded — and a completed page result was not returned until Chrome exited, so a Chrome that would not exit held a finished result. One deadline now covers the whole fetch, shutdown no longer blocks the result, and the browser is killed if it does not close in time. (#66) -
Default Chrome flags never reached Chrome. The one-shot launch path passed them with a
--prefix that chromiumoxide prefixes again, so Chrome received----no-first-runand ignored it. On macOS a crawl no longer shows a keychain prompt, because--use-mock-keychainis now among the defaults and actually applied. (#59) -
Cross-host document links stopped being followed. The fix for #60 applied the host-scope rule to every link type, but
classify_linkmatches a file extension before it compares hosts, so a cross-host.pdf/.docx/.ziplink is a document link rather than an external one, and every earlier version followed it by default. Documents served from a CDN or object store were silently dropped. They are followed again, and this settles whatstay_on_domainmeans: it governs document links, which is the one thing it has ever actually done. Itsfalsedefault is unchanged, so no existing configuration behaves differently. (#72) -
interact()enforced no SSRF policy on its default backend. Neither the pre-flight URL check nor the per-request interception thatscrape()andcrawl()apply was installed, sossrf.deny_private— on by default — had no effect, and a page could reach loopback, private ranges or cloud instance metadata from the crawler’s network position. The native backend was never affected. Both defences are now in place, validated once for both backends. Pre-existing rather than introduced here. (#74) -
crawl()ignoredremove_tags. The setting was folded into the markdown converter’s exclude selectors on the scrape path only, so a crawl kept elements a scrape of the same page removed. Both paths now share one merged configuration. Pre-existing rather than introduced here. -
browser-chromiumoxidewithoutbrowserdid not compile. The interact launcher called into a module gated on the wider feature. No CI job built that configuration; one now builds all fifteen. (#70) -
A fully successful release reported failure. The job that pushes the Go module’s subdirectory tag checked the repository out at a tag that the Swift checksum job force-moves in the same second, and died in
actions/checkout. It now creates the tag through the API, with no working tree and no tag fetch. (#71)
Changed
Section titled “Changed”-
Repinned Alef to 0.96.2, which corrects two defects in the generated Python bindings.
-
Upgraded
deno_core,utoipato 6 andsaphyr, then upgraded the OpenTelemetry stack to 0.33 (tracing-opentelemetry0.34) onceliter-llm2.1.0 published with a matching floor, along withserial_test4 andsysinfo0.39. OpenTelemetry 0.33 needed no source change.cssparserstays at 0.37 becauseselectors0.40 still requires it.Two things are worth recording for anyone repeating this. Before
liter-llm2.1, bumping OpenTelemetry resolved both 0.32 and 0.33 whenever theotelfeature was on, andcargo check --workspace --all-featuresexited 0 in that state — a split graph is a duplicate resolution, not a type error, so only reading the lockfile detects it. Andopentelemetry-otlp0.33 turns export retries on by default (exponential backoff with jitter, three retries);init_otlpdoes not configure aRetryPolicy. -
CI now builds every feature configuration, and runs the binding-parity gate when
alef.tomlchanges — it was the only workflow whose path filter omitted the file while being the only place that gate runs. -
Closed the size and complexity baseline work (#42). Nothing left in the baseline is debt: five entries are config or log files, one is generated, and the remaining two are the same defect in poly’s parameter counting, which counts an attribute on a parameter as a parameter. Reported as Goldziher/poly#28.
Internal
Section titled “Internal”-
A test now fails if
html-to-markdown-rsresolves to 3.15 or newer. 3.15 added abase_urlconversion option that resolves relative addresses the same way the pre-pass above does, and the caret requirement admits it on a routinecargo updatewith nothing to compile against and nothing to fail — leaving two resolvers in the crate and no sign of it. Adoptingbase_urland deleting the pre-pass is the intended end state, but it is deliberately deferred:base_urlresolves an emptysrcto the page URL and rewrites fragment-only links, neither of which the pre-pass does. (#190) -
Teardown no longer shuts down an external Chrome. With
browser.endpointset, crawlberg connects to a Chrome it did not start, and every teardown sent that Chrome aBrowser.close: a one-shot fetch,interact(), and a browser pool shutdown. Crawlberg now closes only the tabs it opened and disconnects from a browser it connected to. A Chrome that crawlberg launched is still closed as before. (#73)
[1.7.2] - 2026-09-24
Section titled “[1.7.2] - 2026-09-24”A wasm and kotlin_android correctness release. A wasm engine handle was unusable after one call, and the kotlin_android e2e suite had never once completed a run. Both traced to the binding generator, so the fixes arrive via Alef 0.96.0.
-
A wasm engine handle survives more than one call. Every generated wasm entry point took
WasmCrawlEngineHandleby value, and wasm-bindgen’s glue for a by-value exported struct calls__destroy_into_raw()on it, nulling the JS object’s pointer. A secondscrape()on the same engine threwnull pointer passed to rust, andbatchScrapeconsumed the handle identically — it only appeared to work because callers used it once. The core API always took a reference and the binding body immediately re-borrowed, so the move bought nothing; the Node binding has always emitted&JsCrawlEngineHandlefrom the same IR. The emitted.d.tsis unchanged, so no JavaScript or TypeScript caller needs editing. (#56) -
Transient robots.txt failures no longer block unrelated crawls for five minutes. Sharing the robots outcome cache across crawls made one
DisallowAllfail-closed for every crawl of that origin and user-agent for the full TTL, and the cache could not tell a DNS blip from a WAF interstitial. Timeout, connection, DNS and TLS failures never reached the origin and now expire after 15 seconds; 5xx, 429 and WAF blocks keep the full five minutes, because backing off there is the intended behaviour. The catch-all stays durable, so an unrecognised failure does not get both fail-closed and the shortest memory of it. (#55) -
The kotlin_android e2e suite completes. It had never finished a run: a hung native call ran to the job’s 90-minute cap, which GitHub records as
cancelledrather thanfailure, so the job read as green while testing nothing. Once it completed, 23 failures surfaced that had been latent throughout. Generated stream tests deserialized the raw fixture blob into a request DTO that did not declare those fields; sealed-class serialization dropped thetypediscriminator, so the native layer rejected everyinteractcall; and generated enums lacked the wire-valuetoString()the assertions compare against. The suite now also logs failures with stack traces and times itself out well inside the job cap. -
The shell formatting CI job runs. It invoked
shfmt, which no runner image ships and nothing installed, and had exited 127 on every run since 2026-09-15.
Changed
Section titled “Changed”-
Alef pin moved from 0.93.1 to 0.96.0, carrying the generator fixes above.
-
alef.tomlnow lists the modules alef parses for the API surface. The[[crates]] sourcespaths are read as files and scanned for type definitions; alef does not walk the module graph, so apub usere-export left behind by a file split is invisible to it.path_mappingsgained entries for the same reason: alef derives a type’s import path from the file it was found in, which after a split is not where the crate re-exports it.
Notes for wasm callers
Section titled “Notes for wasm callers”config.content.skipImages = true does not work, and never did. wasm-bindgen’s getter returns a
detached clone, so the natural idiom mutates a throwaway. Read, modify and assign back:
const c = config.content;c.skipImages = true;config.content = c;The setter consumes its argument, so build a fresh one for the next edit. The same applies to
ssrf, auth, browser and proxy.
Internal
Section titled “Internal”-
CrawlConfig.contentnow has end-to-end coverage. Of 267 fixtures exactly two set it, one with an empty assertion list and the other asserting only batch counts, so acontentthat was ignored entirely passed green in all sixteen generated language suites. Two matched fixtures now pin it from opposite directions, and a Rust-level test asserts the same without depending on regenerated suites. -
The size and complexity baseline (#42) goes from 83 findings across 44 files to eight entries, each with a stated reason rather than left as debt. Behaviour-preserving throughout: no public or crate-visible item changed name, signature, module path or field set. Several of the functions restructured had no test that called them at all, so characterization tests were captured against the original implementations first.
[1.7.1] - 2026-09-16
Section titled “[1.7.1] - 2026-09-16”A release-tooling fix. 1.7.0’s Java artifact never reached Maven Central; this version carries the fix and publishes it. No library code changed, so 1.7.0 and 1.7.1 are byte-identical apart from the version string and the two CI files below.
-
Checkstyle’s suppressions file is found regardless of where maven is invoked from.
checkstyle.xmlnamed it by a bare relative path, which checkstyle resolves against the process working directory, not againstconfig_loc— andoptional="true"meant a miss discarded the entire suppressions file in silence.mvn checkstyle:checkfrompackages/javatherefore passed whilemvn -f packages/java/pom.xmlfrom the repo root, which is how the release job invokes it, failed on the same bytes. That is what stoppedio.xberg.crawlberg:crawlberg:1.7.0reaching Maven Central. Every suppression in the file had been inert in CI for as long as it has existed. The path is now anchored to${config_loc}and the filter is no longer optional, so a missing file is an error rather than a silent skip. -
The publish workflow fails a run that cannot publish. Every publish job gates on
is_tag == 'true', so aworkflow_dispatchcarrying a branch asrefskips all of them and still reports success. Run 35122273610 skipped 68 jobs that way and went green having published nothing, which is indistinguishable from a real success in the run list.is_tagis true in every legitimate mode, dry-run included, so refusing a non-tag ref rejects no real dispatch.
[1.7.0] - 2026-09-16
Section titled “[1.7.0] - 2026-09-16”The generated bindings move to Alef 0.90.0 and the markdown converter to html-to-markdown 3.14. Two binding changes are source-breaking, both in generated code and neither visible on the wire.
Changed
Section titled “Changed”-
BREAKING (java): enum constants are now
SCREAMING_SNAKE_CASE.LinkType.InternalbecomesLinkType.INTERNAL. 38 constants across 11 enums:AssetCategory(10),BrowserMode(4),CrawlStrategyKind(4),ImageSource(4),LinkType(4),BrowserWait(3),FeedType(3),BrowserBackend(2),ScrollDirection(2),ContentFilterKind(1) andDocumentContentEncoding(1). The@JsonValuestring each constant carries is unchanged, so nothing serialises differently – only Java source that names a constant needs editing. -
BREAKING (swift):
DownloadedDocumentis a native struct, not an alias for the bridge type. It is nowCodable,SendableandHashable, with Swift-cased stored properties (mimeType,contentHash,contentPath,contentBase64) and a memberwise initialiser.DownloadedDocumentRefandDownloadedDocumentRefMutremain aliases to the bridge types. -
Upgraded
html-to-markdown-rsto 3.14, which fixes five ways HTML could lose visible content on its way to markdown. The one that reaches the widest input is an html5ever serializer defect: 0.40.0 dropped the leading byte of a two-byte UTF-8 sequence, so a single§,©,°or·could make a repaired document’s re-parse fail and silently truncate everything after it. The rest: character references in attribute values are now decoded (title="A&B"reached the output literally, across twelve attributes); a nested<table>behind a wrapper element no longer emits raw|characters that re-parse as the outer row’s cell boundaries; content after a table whose last row is never closed is no longer dropped; an<a>wrapping a block element no longer crushes that block into the link label; andkeepInlineImagesInis honoured for<a>. -
Deduplicated the html5ever stack. 3.14 pins html5ever 0.40, matching this workspace’s own direct dependency, where 3.12 pinned 0.39 and the graph carried two copies of each crate in that stack.
Cargo.lockloses five duplicate entries –html5ever,markup5ever,string_cache,string_cache_codegenandweb_atoms– and 57 lines net. -
The Java binding gains a handle borrow lifecycle. Alef 0.90.0 emits package-private
HandleLeaseandHandleTransfertypes –AutoCloseable, reference-counted and synchronized on the handle – so a native engine handle cannot be closed while a streaming call still holds it. No public API changes;crawlStreamandbatchCrawlStreamare the callers. The two methods cross the 150-lineMethodLengthlimit as a result, and generated Java is now suppressed for that check: its method sizes belong to the emitter, not to this repo. -
Bumped the Alef pin from 0.85.19 to 0.90.0 and moved the shell-formatter configuration into
alef.tomlas[workspace.poly.shell-formatter]. It had been hand-edited into the generatedpoly.toml, which carries a DO-NOT-EDIT header – the nextalef generatewould have deleted it and returned poly to formatting no shell at all, since poly runsshfmtonly when a config enables it.
- The version bump now refreshes
uv.lock.alef sync-versionsrewritespackages/python/pyproject.toml, but nothing regenerated the workspace lock beside it, so every 1.6.x release was tagged with a stale one: 1.6.1 through 1.6.4 all shippedcrawlberg 1.6.0inuv.lock, and 1.6.0 itself shipped 1.4.2.uv sync --lockedanduv run --lockedfail against such a lock.
[1.6.4] - 2026-09-14
Section titled “[1.6.4] - 2026-09-14”A browser-flag fix, plus two release-infrastructure and test-correctness fixes.
- Pass Chrome command-line flags with a single
--prefix. chromiumoxide’sBrowserConfig::argtreats the whole string as the flag key and renders it back as--{key}, so every already-prefixed flag reached Chrome as----flagand was discarded as unknown. Confirmed in a launched browser’s own argv: 21 flags carried four dashes. That silenced every entry of the built-in safe defaults — including--disable-dev-shm-usage, the standard workaround for the small/dev/shmin CI containers, where Chrome otherwise stalls or crashes — every caller-suppliedchrome_argsentry, and the interact path’s--proxy-server, so a proxy configured for an interaction was never actually applied. - The Elixir publish job no longer corrupts
Package.swiftonmain. It checks out the release tag, and the Swift injection job force-moves that tag onto a commit which rewrites the__ALEF_SWIFT_CHECKSUM__placeholder into a literal checksum — so pushing this job’sHEADtomainfast-forwardedmainthrough that commit and destroyed the placeholder. The next release then failed with “carries no__ALEF_SWIFT_CHECKSUM__placeholder” and published norelease/swift/<version>branch for SwiftPM to resolve, which is what left 1.6.3 unresolvable on SwiftPM. Whether it happened at all was a race between that checkout and the tag move, so it bit some releases and not others. The checksum commit is now built in a throwaway worktree based on the currentorigin/main, carrying the checksum file and nothing else.
Changed
Section titled “Changed”- Integration tests reach loopback through
CrawlConfigBuilder::allow_private_networks(true)instead of writingCRAWLBERG_ALLOW_PRIVATE_NETWORKinto the process environment. The previous approach was justified by a comment claiming#[serial]made it safe; it did not —serial_testserialises serial tests against each other and does nothing about a non-serial test callingstd::env::varat the same moment, and that reader sits on theCrawlConfig::defaultpath most of these binaries use. On glibc a concurrentsetenvcan reallocateenvironunderneath agetenvand abort the process with no panic, no backtrace and no failing test name. No test now writes the process environment.
[1.6.3] - 2026-09-12
Section titled “[1.6.3] - 2026-09-12”Redirect targets are now judged before they are requested. exclude_paths gains one deliberate behaviour change, described below.
Changed
Section titled “Changed”- Apply
exclude_pathsto every URL in a seed’s redirect chain, not only the URL the chain lands on. A chain that passes through an excluded path is now refused, where it previously followed the redirect and crawled the target.
- Read a redirect target’s own robots.txt before requesting it, and evaluate every hop rather than only the URL the chain ends on; each origin’s file is read once per crawl. Thanks to @tobocop2.
- Publish each origin’s
Crawl-delaywhen its robots.txt is first read, so the delay reaches the rate limiter before any request to that origin instead of after the whole chain. - Refuse a redirect URL whose origin cannot be determined, rather than admitting it: in the component that decides whether a request may go out, a parse failure must not become permission.
- Report the redirects already followed, and keep the cookies they set, when a later hop is refused.
- Report a crawl that stops before its loop begins to
EventEmitterconsumers. Neitheron_errornoron_completefired for a seed failure, an unreachable robots.txt, or a disallowed seed, so a callback-driven consumer could not distinguish a failed crawl from a hung one. - Construct
crawlberg-browser’s test isolates inside a tokio runtime. Without one, deno_core aborts the process from a V8 background thread, which madecargo test -p crawlberg-browser --libfail intermittently under parallel execution with no panic or backtrace. - Inject the loopback SSRF policy in
crawlberg-browser’s tests instead of setting a process environment variable, which could abort the process when written while another test read it. - Add the gnu Rust target before building the Ruby Windows gem, so the platform gem compiles and RubyGems receives the release.
[1.6.2] - 2026-09-12
Section titled “[1.6.2] - 2026-09-12”Repairs the v1.6.1 release pipeline, which shipped to crates.io, npm, pub.dev, Maven and Hex but missed RubyGems, NuGet and Packagist.
- Report real progress in the
crawl.pages_completedfield of thecrawl.loop.iterationspan during a streaming crawl; it read a buffer that streaming never fills and so was pinned at 0 for every iteration. - Size the single-seed
crawl_streamchannel from the same default concurrency as every other caller, instead of a hardcoded 4 that matched neither the engine default nor the batch stream beside it. - Build the Ruby Windows gem against the mingw target the RubyInstaller toolchain actually uses, so rb-sys can generate bindings; the msvc host target made it refuse and took the entire RubyGems release down with it.
- Pack the NuGet package against the runtime identifiers that are actually built.
win-arm64was declared but never produced, so packing failed on its missing native assets. - Stop building the PHP extension for Intel macOS, where
setup-phpcan no longer provision PHP; one failed leg skipped the whole Packagist target.
[1.6.1] - 2026-09-12
Section titled “[1.6.1] - 2026-09-12”Robots.txt handling now fails closed. respect_robots_txt still defaults to false, so only callers that opted in are affected.
Changed
Section titled “Changed”- Regenerate language bindings, test harnesses, and package metadata with Alef 0.85.19.
- Upgrade html5ever and markup5ever to 0.40, refresh the Rust lockfile, and update the Node, Python, Ruby, and Elixir toolchains; cssparser stays at 0.37 because selectors 0.40 still depends on it.
- Migrate the Node workspaces to pnpm 12 and refresh JavaScript dependencies.
- Refresh the pinned GitHub Actions, correct two pins whose comments named the wrong major, and move setup-uv to 10.1.0.
- Build the robots.txt URL from the seed’s origin, so a port-bearing seed reads its own file instead of another origin’s, and a seed carrying credentials no longer sends them with the robots.txt request.
- Fail closed when robots.txt is unreachable, a 5xx or a network failure, as RFC 9309 section 2.3.1.4 requires; a 4xx response still means crawl with no rules.
- Treat HTTP 429 on robots.txt as unreachable rather than unavailable, a deliberate divergence from a literal reading of RFC 9309 section 2.3.1.3.
- Treat a WAF interstitial served in place of robots.txt as unreachable, since it is raised for a fingerprint on a 2xx response as well as for a 403.
- Report a fail-closed crawl through the existing
was_skippedanderrorfields, the error prefixedrobots_unreachable:, so no public struct gained a field. - Read robots.txt before the seed request, so a disallowed seed costs the site no requests and
Crawl-delayapplies to the seed as well. - Fetch the seed once instead of twice by carrying the redirect-resolution response into the crawl as the depth-0 page.
- Report
is_allowed: falsefromscrape()when robots.txt cannot be read, instead of failing open and parsing HTTP error bodies as policy. - Honour
respect_robots_txtin the WebAssembly crawl loop, which previously ignored it and fetched, returned, and followed disallowed URLs. - Key the browser page loader’s robots cache by origin, so two ports on one host no longer share one answer.
[1.6.0] - 2026-09-10
Section titled “[1.6.0] - 2026-09-10”Changed
Section titled “Changed”- Update Rust dependencies, including dirs 7 and liter-llm 2.0; retain cssparser 0.37 for selectors 0.40 compatibility.
- Regenerate language bindings, test harnesses, and package metadata with Alef 0.85.14 to correct Java defaults, collection and enum assertions, and truncated mock responses.
- Synchronize package versions and consumer manifests to 1.6.0.
- Preserve custom test harnesses under explicit ownership and check their release pins during version sync.
- Enforce configured HTTP request timeouts on WebAssembly, including redirected requests.
- Resolve native Node packages from the workspace so frozen documentation installs work before publication.
- Export matching canonical and vendored C headers through
task c:headers. - Run documentation prose linting correctly and track the shared docs workflow’s v1 tag.
- Publish separate NuGet runtime packages and native downloader archives with checksums for Dart and Go.
- Refresh PHP consumer development dependencies to resolve known security advisories.
[1.5.2] - 2026-09-05
Section titled “[1.5.2] - 2026-09-05”-
The v1.5.1 release published nothing: the plugin version pin was left at 1.5.0 while the crate moved to 1.5.1, so
validate-versionsfailed and took the whole workflow with it. The bump chain behindtask version:syncrunsalef sync-versionsand then seven further steps that regenerate everything derived from the version – stubs, scaffolding, README install snippets, test-app installers, e2e download scripts, andscripts/sync_plugin_version.py, which re-pins the coding-agent plugin. Those seven were written as{{.ALEF}} <subcommand>, butALEFwas never defined in.task/orTaskfile.yml, so each one rendered as a baregenerate/stubs/verifyand the chain aborted with exit 127 on the first of them. Only the literalalef sync-versionsahead of them ever ran. That is why the alef-managed binding manifests tracked the crate whileplugin/.ai-rulez/config.toml– and theplugin/package.json,plugin/gemini-extension.jsonandplugin/kimi.plugin.jsonbundles rendered from it – stayed behind on 1.5.0.validate-versionsgates nearly every publish job, so its failure skipped 36 of them and no artifact reached any registry.ALEFis now defined, so the full chain runs and thealef verifygate at the end of it is reachable for the first time since it was added. Because the release shipped nothing, v1.5.0 and v1.5.1 never reached npm; the latest npm release remained 1.4.2, and this is the first 1.5.x to land there. -
task version:set VERSION=Xcould not set any version other than the one already inCargo.toml..task/config/vars.ymldefined aVERSIONvariable computed fromCargo.toml, and Task resolves{{.VERSION}}inside an included taskfile from that shared file in preference to a CLI-passedVERSION=.... Bothversion:setandalef:bumplive in included taskfiles, so both read the computed value instead of the argument:version:setre-set the version it already had, andalef:bumpwould have written the crate version into alef.toml’salef_versionpin. Therequires: vars: [VERSION]guard never caught it, because the variable was always defined. Root-level tasks resolve the argument correctly, which is what made the shadowing easy to miss. The computed variable is renamedCARGO_VERSION; it had no other readers. -
Every test app and e2e harness installed crawlberg 1.2.1 – nine releases behind – and the Zig test app downloaded a
v1.2.1release asset.test_apps/node,test_apps/wasm,test_apps/go,test_apps/javaandtest_apps/zigeach pin the published package they exist to validate, and every one of those pins had sat at 1.2.1 since 2026-08-11, across 1.3.0, 1.3.1, 1.3.2, 1.3.3, 1.4.0, 1.4.1, 1.4.2, 1.5.0 and 1.5.1 – so the suite that answers “does the released artifact work” was answering it about a package from three weeks and nine releases earlier.test_apps/zig/build.zig.zonis the worst case, because its.urlpointed atreleases/download/v1.2.1/crawlberg-zig-v1.2.1.tar.gzand that asset still returns 200: the fetch succeeded, so the staleness never surfaced as an error. (e2e/go/go.modcarried the same stale pin, though areplacedirective redirects it to the local tree, so there it was cosmetic.) Nothing kept these lines in step because alef classifies all of them as create-once seeds – it writes each only when the path is absent and never re-renders it – so no regeneration step has ever touched them. Adopting the files is not the fix: the same paths hold hand-grown build logic (e2e/zig/build.zigalone is 903 lines of FFI, rpath and mock-server wiring), and adopting a create-once seed consents to alef replacing that content with a placeholder on the next overwriting regen. Six[[workspace.sync.text_replacements]]entries now stamp only the version-bearing line in each file on everytask version:sync, leaving everything around it untouched. The Zig.hashdeliberately keeps its placeholder value, whichzig fetchresolves once the release publishes. -
The published TypeScript interaction examples did not type-check. Every generated
interactsnippet and the Node e2e interaction suite readresult.actionResults[0].successdirectly, butactionResultsis optional onInteractionResult, so all eleven failedstricttype-checking withTS18048: 'result.actionResults' is possibly 'undefined'– a reader who copied one out of the docs got a compile error rather than a working example. Regenerating on alef 0.84.2 emitsresult.actionResults?.[0]?.successinstead. The effect is confined to TypeScript and Node: alef gates the fix on the target language at the accessor’s entry point, and regenerating every backend against 0.84.2 changed no other language’s output.
Changed
Section titled “Changed”-
Regenerated all language bindings on alef 0.84.2 (from 0.82.2), picking up its reproducible-generation fix.
-
Upgraded
vitest4 -> 5 across all six Node and WASM suites, and@vitest/coverage-v8to the matching major, since it is version-locked to vitest. Dev-dependency only – no published package carries vitest, and no shipped code changed.
[1.5.1] - 2026-09-03
Section titled “[1.5.1] - 2026-09-03”- A cached HTTP client was reused across tokio runtimes, so requests failed intermittently
in any process that creates and drops runtimes.
reqwest::Clientinstances are cached process-wide, but the cache key carried no runtime identity. hyper drives each pooled connection with a task spawned on the runtime that built the client, so when that runtime is dropped the connection dies while the client stays cached – and the next caller, on a new runtime, checks out a dead connection and fails mid-request. The failure surfaced aserror sending request for url ...when the connection died during send, orerror decoding response body(classifieddata_loss) when it died while reading the body, and neither is retried, sinceretry_countdefaults to 0 and only status-derived errors are retryable. The cache key now carries the runtime’s identity. Measured at ~8.5% of requests across 28 short-lived runtimes before the fix and 0% after. This affects embedders that create and destroy runtimes – most visibly every consumer’s#[tokio::test]suite, where it reads as flaky integration tests.
[1.5.0] - 2026-09-01
Section titled “[1.5.0] - 2026-09-01”Changed
Section titled “Changed”- Upgraded
html-to-markdown-rsto 3.12, picking up its Tier-1/Tier-2 GFM autolink parity fix and the case-insensitive HTML attribute matching behind the link fix below.
- Scoop is now a release channel alongside Homebrew: a release publishes a Scoop manifest for the CLI, and the install instructions cover it.
batch-scrapewith no URLs reported a different error than every other binding. The positional wasrequired = true, so clap aborted at parse time withthe following required arguments were not provided: <URLS>.... Empty input now reaches the library, which returns the sameinvalid_config: batch_urls must not be emptythe Python, Node, Go and other bindings already return.batch-crawlis unchanged.- Links with uppercase or mixed-case attribute names were silently dropped.
HTML attribute names are case-insensitive, but the parser matched them byte-for-byte
as written, so
<a HREF="/docs/guide.html">yielded no link at all andReL="nofollow"was not honoured – the link was lost, not merely mis-resolved. Theastral-tl0.8.0 upgrade lowercases attribute keys at parse time. Verified against a control build: both cases fail on 0.7.11 and pass on 0.8.0.
[1.4.2] - 2026-08-28
Section titled “[1.4.2] - 2026-08-28”- Python e2e configs passed raw dicts where a binding type was required. The
handle_nested_typesmap for the Python e2e generator declaredbrowser,proxyandauthbut notcontentorssrf, so generated tests emittedCrawlConfig(ssrf={...})andCrawlConfig(content={...}). The generated pyclasses only extract from real instances, so both raiseTypeError: 'dict' object is not an instance of ...at construction. Python now declares the same nested types as its wasm sibling.
Changed
Section titled “Changed”- Upgraded
deno_core0.410 -> 0.411 anduuid1.25 -> 1.26. - Removed the inert
[overrides.c]blocks forcrawl_streamandbatch_crawl_stream; both calls already listcinskip_languages.
[1.4.1] - 2026-08-25
Section titled “[1.4.1] - 2026-08-25”Changed
Section titled “Changed”- Regenerated all language bindings on alef 0.68.0.
[1.4.0] - 2026-08-24
Section titled “[1.4.0] - 2026-08-24”-
Bounded LLM extraction concurrency (
aifeature).LlmExtractornow builds aliter_llm::ManagedClientinstead of a bareDefaultClient, with liter-llm 1.18.0’s queueingInFlightLimitLayerwired in. The newLlmExtractorConfig::max_in_flightcaps simultaneously outstanding provider requests globally for the extractor’s client rather than per call site, so a wide crawl fan-out cannot burst past a provider’s per-key concurrency allowance. The bound is a dedicatedInFlightBoundenum –Limited(NonZeroUsize)orUnlimited– rather than anOption<usize>, so neither unsafe state is reachable by accident: the default isLimited(8), lifting the bound requires namingInFlightBound::Unlimitedat the call site, and a zero bound (which admits no request at all and would deadlock every extraction) is a compile error rather than a runtimeCrawlError::InvalidConfig.LlmExtractorConfigimplementsDefault, so..Default::default()picks up the bounded default instead of silently disabling the limiter.InFlightBoundis exported from the crate root.LlmExtractorConfig::response_cacheoptionally puts liter-llm’s in-memory response cache in front of the provider; cache hits are served without consuming an in-flight permit, so repeat pages still answer immediately while the bound is saturated.LlmExtractor::newkeeps its existing signature and picks up the default bound. Enablingainow also enablesliter-llm/tower, which suppliesManagedClientand the limiter. -
LlmExtractoris now public API (aifeature).crawlberg::{LlmExtractor, LlmExtractorConfig, LlmResponseCacheConfig}are exported from the crate root. The extractor had been declared as a privatemod llm_extractor;inside apub(crate)module with no re-export and a file-level#![allow(dead_code)], so it and itsContentFilterimplementation were unreachable from outside the crate – including the in-flight bound above. Theallow(dead_code)is removed; the type is reachable and used. Like the otherdefaultsimplementations it is a Rust-level export only and is not part of the generated language bindings.
-
The published Rust documentation snippets did not compile. 220 of the 264 generated Rust snippets read a
resultbinding their own call site had discarded into_, so every one of them failed to compile witherror[E0425]: cannot find value result. Regenerating against alef 0.67.5 emitslet result = scrape(&engine, &url).await.expect("call failed")and takes Rust snippet failures from 220 to 9; the remaining 9 are stream fixtures that miss atokio_streamimport. The same regeneration fixes the assertion-rendering defect in the Dart and C# snippets (dart 22 -> 12 failures, csharp 11 -> 9). The broken output had persisted across alef upgrades because alef’s generation cache is not keyed on the alef version, soalef generatereplayed stale bytes andalef verifyreported them as fresh. -
The Java binding no longer flattens a native error into a generic
CrawlbergRsException. All eight synchronous FFI entry points inpackages/java/src/main/java/io/xberg/crawlberg/CrawlbergRs.javacaughtThrowableand rethrew it asnew CrawlbergRsException("FFI call failed", e).checkLastError()reports the real Rust-side failure by throwingConversionErrorException,CoreErrorException,PanicExceptionorCrawlbergRsException— all of which areCrawlbergRsExceptionsubtypes, so every one was caught by that generic handler one frame later and re-wrapped. Callers saw"FFI call failed"with the real message demoted to a cause, andcatch (PanicException e)could never match because the concrete type had been erased. The regenerated code rethrows aCrawlbergRsExceptionunchanged and wraps only genuinely unexpected throwables. The six*Asyncwrappers are unaffected: they wrap inCompletionException, which is the documentedCompletableFuturecontract and already preserves the cause. -
A skipped
publish-cratesno longer reads as a passing gate, and a release that published nothing can no longer report success. Six places in.github/workflows/publish.yamlgated downstream build, publish and release-promotion jobs onneeds.publish-crates.result != 'failure'. That expression is TRUE when the dependency was skipped, andpublish-cratesskips for two opposite reasons: the version is already on crates.io (a re-run or a resumed release, where downstream must proceed) or an upstream gate such as version validation or crate packaging failed (where downstream must not).resultalone cannot separate them, so every one of those conditions was gating on nothing – tree-sitter-language-pack v1.15.5 promoted a GitHub release toLatestwith 40+ failed jobs and every registry publish skipped, and still reported success.A new always-running
crates-gatejob resolves the ambiguity once, into an explicitoutcome(published/already-present/not-required/dry-run/blocked) and anokflag that every consumer now tests instead ofresult. Because the job always runs, its outputs always exist; because it never fails, depending on it cannot skip a consumer. The legitimate already-published path stays exactly as permissive as before, and only the gate-failed path is newly blocked.Nothing in the workflow failed a run whose publish jobs were skipped:
release-finalizeandannounce-discordgate on!contains(needs.*.result, 'failure'), which is blind toskipped, so a release that reached zero registries would still be flipped out of draft and announced. Both now also require the crates gate, and a newrelease-reportjob verifies every enabled publish target individually, treatingskippedas a failure unless the target is not enabled for this release or its registry probe already found this exact version published. -
The Ruby gem published on a failed build and bypassed version validation.
publish-rubygemsacceptedneeds.ruby-gem.result == 'failure'and carried!cancelled(), so a release in which the gem build failed still ran the publish step against whatever artifacts happened to exist. Only thelinuxmatrix leg keeps the source gem – the other three legs delete it – and that source gem is the only artifact crawlberg has ever shipped: all 23 versions on RubyGems are platformruby, with no per-platform gem in the registry’s history. So the acceptedfailurenever preserved a partial-platform publish; it only allowed a release with no source gem at all. The disjunction was not a deliberate choice either – it entered in a bulk regeneration commit, replacing an explicit== 'success'. The job now requiresruby-gemto succeed. Separately, it was reached withoutvalidate-versionsinneeds:at all. The other publish jobs that omit it still inherit the gate, either through aneedschain carrying no skip override (publish-pypi,publish-packagist) or through an explicit success check on a job that does carry it (publish-hex,publish-homebrew-bottles); the!cancelled()here removed both routes, so the version gate could not block a Ruby publish.validate-versionsis now a dependency and its success is required. Dropping!cancelled()also restores the default dependency skip, so a failedcheck-rubygemsno longer lets the publish proceed on an unanswered already-published check. The gate now matchespublish-node,publish-maven, andpublish-nugetexactly. -
Entity-escaped sitemap URLs were truncated to the text after the last entity. quick-xml reports an entity reference as its own
Event::GeneralRefand splits the element’s character data around it, so<loc>https://example.com/s1.xml?a=1&b=2</loc>arrived as three events. The sitemap parsers assigned each text event to the current field instead of accumulating, so every piece but the last was discarded and that<loc>parsed tob=2. Because&must be entity-escaped in XML, every sitemap URL carrying more than one query parameter was silently corrupted – a truncated string still looks like a successful parse – so the wrong URLs were enqueued and the real ones were never crawled. Bothparse_sitemap_xmlandparse_sitemap_indexnow buffer character data across events and commit it on the closing tag, and they resolve the reference events themselves, so the escaped character survives rather than vanishing from the middle of the value. This covers every text-bearing field –loc,lastmod,changefreq,priority, and the sitemap-indexloc– and applies to numeric character references (&,&) as well as the named XML entities. The defect predates the quick-xml 0.42 upgrade; a probe built against 0.41 truncates identically. -
The docs advertised musl artifacts that are not published. The README claimed precompiled binaries “across every binding” and linked a platform matrix that did not exist, and
RELEASE.mddescribed the two Node musl packages as an OIDC misconfiguration with a trusted-publisher fix. Neither held up against the registries:@xberg-io/crawlberg-linux-x64-musland@xberg-io/crawlberg-linux-arm64-muslare name-reservation placeholders at0.0.1and have never carried a release, PyPI has nomusllinuxwheels, and the Go and PHP release assets are glibc-only. The cause is not credentials — thenode-bindingsmatrix inpublish.yamlhas no musl target, so no artifact is built, and the publish step skips platform directories with no binary rather than failing. Installation now carries a per-ecosystem musl support matrix stating which bindings work on Alpine (CLI, Docker, Rust, Ruby, Java, C#, Elixir) and which do not (Node, Python, Go, PHP), why the omission is deliberate, and the Alpine workarounds. It also records the two silent failure modes:npm installon Alpine exits 0 while installing no native binary, because npm skips the unresolvable optional dependency, andpipfalls back to an sdist build.RELEASE.mdnow documents the decision instead of a fix that would publish nothing, and the two placeholder package READMEs say they are unpublished rather than describing a binary that does not exist. -
The coding-agent plugin version gate never ran on the commits that cause drift.
plugin/sat at 1.3.1 while core shipped 1.3.2 and 1.3.3, so every runtime bundle — OpenCode, Hermes, Claude Code, Cursor, Codex, Gemini, Kimi, Factory — declared a version two releases stale. The checker for this already existed (scripts/sync_plugin_version.py --check, run byCI Plugin), butci-plugin.yaml’spaths:filter did not listCargo.toml. A release commit bumpsCargo.tomland nothing underplugin/, so the workflow was never triggered and the gate reported nothing rather than failing —CI Pluginlast ran on the 1.3.1 release.Cargo.tomland.task/tools/version-sync.ymlare now in the filter, so any core bump re-runs the gate.sync_plugin_version.pyalso grew--expect <version>, which asserts that core and the plugin both equal the version being released;publish.yaml’svalidate-versionsjob runs it against the tag, so a drifted plugin now fails the release instead of publishing a bundle that lags the version it claims to be. The 13 stale version declarations are re-synced to 1.3.3. -
Two e2e assertions were tautologies rather than URL leaks, and tested nothing.
links_protocol_relativeexists to prove that<a href="//cdn.example.com/resource">inherits the page’s scheme, but asserted only that some link URL contains//— true of every absolute URL, and satisfied by the fixture’s one ordinaryhttps://example.com/normallink without the protocol-relative pair being resolved at all. It now asserts both resolved forms,http://cdn.example.com/resourceandhttp://images.example.com/photo.jpg, which a passthrough of the raw href cannot produce.strategy_best_first_seedasserted thatpages[0].urlcontains/— true of every URL, including the/page1and/page2results the fixture exists to rule out. Its seed is the mock origin root and carries no path, so it now assertsnot_contains/page, which fails if any non-seed page is crawled first. Both were confirmed to fail under mutation before being accepted. -
The URL being scraped decided its own network error classification.
network_error_kindkeyword-scanned a string built fromreqwest::Error’sDisplay, which embeds the request URL, so every keyword the scan looks for —dns,ssl,tls,certificate,handshake,resolve,lookup,timeout,proxy,connect— could be supplied by the path or hostname being fetched rather than by the failure. Measured against a refused TCP connection:/blog/dns-explainedreporteddns:,/blog/ssl-explainedand/blog/certificate-pinningreportedssl:, and/blog/timeout-tuningreportedtimeout:— all four wereconnection refused (os error 61). The scan now runs overchain_without_request_url, which3afbde890had applied only inside the data-loss predicate. The one classification this changes in the fixture suite iserror_invalid_proxy: it was tagged[network:proxy]purely because its path spells “proxy”, while its actual chain istcp connect error ... connection refused, and it is now tagged[network:connection]. TheCrawlErrorvariant isConnectioneither way —NetworkErrorKind::Proxyhas always mapped toconnection_with_source— so the fixture’sconnectionassertion is unchanged and still correct.A refused proxy CONNECT names no proxy anywhere in its chain, so
[network:proxy]was in practice reachable only through the request URL. The unit test that accepted either tag is replaced by one that pins[network:connection], alongside a table-driven test that walks every scanned keyword. -
Two more e2e assertions matched a substring of their own request URL. Same defect class as
error_unsupported_scheme: every classified network error embeds the URL, and the URL embeds the fixture id, so an assertion whose expected substring also occurs in the id passes on the address rather than the behaviour.error_data_loss_truncatedasserteddata_losswhile actually returningconnection: [network:connection] ... /fixtures/error_data_loss_truncated— its mock route declares acontent-lengthits body does not satisfy, which panics hyper 1.9.0 in the generated mock server, and the test passed 5/5 anyway. It now assertsdata_loss:, a prefix no URL path segment can carry.error_empty_batch_urlsassertedurls, which its own id supplies; the fixture was long ago repurposed to a 404 case (mock_responsesis empty and the description says so), so it now assertsnot_found, matching what it actually exercises.error_data_loss_truncatedis consequently red, and stays red: like its siblingerror_partial_responseit needs a mock server that can emit an unknown-length or truncated body, and both alef-generated harnesses build every response as a known-lengthBody::from. A red test that names a real gap is the correct state; loosening the assertion would only restore the false pass. -
CrawlError::DataLosswas unreachable for the case it names, and network classification keyed off the request URL.classify_reqwest_erroronly reached its data-loss branch underNetworkErrorKind::Other, but hyper renders a truncated body’sIncompleteMessageas “connection closed before message completed”, sonetwork_error_kind’s genericcontains("connection")arm claimed every truncated body first — a response cut short against its declaredcontent-lengthcame back as a plain connection failure. Worse, the string those heuristics scan is built fromreqwest::Error’sDisplay, which embeds the request URL, so a path such as/blog/dns-explainedor/fixtures/error_data_loss_truncateddecided its own classification. The data-loss predicate now runs forConnectionas well asOther, and it matches against the chain with the request URL removed. Covered bytruncated_body_produces_data_loss_prefix(a raw socket that under-delivers itscontent-length) anda_url_spelling_truncated_is_not_a_data_loss(a refused connection whose path spells the keyword). -
The PHP e2e format hook pointed at a php-cs-fixer that no checkout has.
[crates.e2e.format].phpinvoked../../vendor/bin/php-cs-fixer, butvendor/is gitignored and the repo-rootcomposer.jsonnever declaredfriendsofphp/php-cs-fixer— only the lock file did. So no fresh checkout (every CI runner, and this one) could resolve the binary, and alef’s format hook silently no-ops on a missing command: regeneration rewrote all 31 generated PHP files with alef’s raw, over-indented template output and reported nothing. Declaredfriendsofphp/php-cs-fixerinrequire-dev(pinning to the v3.95.1 the lock already carried, so no dependency churn) and rewrote the hook tocomposer installfirst and invoke the tool throughcomposer exec, which resolves from the repo-root manifest regardless of cwd. The php e2e job already installscomposer, so this now resolves in CI rather than only on a developer machine. -
Four SSRF/scheme fixtures asserted against the mock server address instead of the address under test.
validation_ssrf_loopback_denied,validation_ssrf_ipv4_mapped_ipv6_denied,validation_ssrf_nat64_loopback_deniedanderror_unsupported_schemeeach declare aninput.urlthat is the subject of the assertion, but every backend’smock_urlargument discarded it and substituted the per-fixture mock server address. All three SSRF fixtures therefore exercised the same trivial IPv4-loopback case, and the IPv4-mapped-IPv6 (::ffff:127.0.0.1) and NAT64 (64:ff9b::7f00:1) normalization paths incrates/crawlberg/src/net/ssrf.rshad no e2e coverage in any of the 16 generated language suites. Setpreserve_input_urls: trueon those four fixtures so the declared addresses reach the call verbatim. -
error_unsupported_schemeasserted on a substring of its own fixture id. With the mock server URL substituted, the error text contained.../fixtures/error_unsupported_scheme, socontains("unsupported")matched regardless of what actually failed. With the realgopher://invalid.example.com/URL, crawlberg returnsssrf_policy_violation: gopher://invalid.example.com/ - disallowed scheme: gopher, not anUnsupportederror — the old assertion does not hold. Tightened the assertion todisallowed scheme: gopher, which names the rejection the fixture exists to cover. -
cargo test -p crawlbergcould not compile.crates/crawlberg/tests/test_interact.rsmatched onCrawlError::unsupported(message)— the macro-generated constructor function, not the enum variant — which is E0164 (fncalls are not allowed in patterns). Sincebrowser-chromiumoxideis off by default, the#[cfg(not(feature = ...))]test was always compiled and always failed the build. Replaced with thematches!(..., Err(CrawlError::Unsupported { message, .. }) if ...)idiom used elsewhere in the file. -
Redirect-cycle detection missed the first return to a bare-origin seed URL. In
follow_redirects(crates/crawlberg/src/engine/crawl_loop.rs), the cycle-detectionseenset was seeded with the caller’s raw URL string, while every subsequent hop key came fromresolve_redirect’s WHATWG-serializedUrl::joinoutput. A chain seeded at a bare origin (e.g.http://host:port, no trailing slash) that redirects back to/produced a hop key ofhttp://host:port/— never equal to the raw seed — so the cycle was missed on its first return and only caught one hop later (redirect_count == 2instead of1). Addedcanonical_redirect_key, used for the seed and for every hop’scontains/insertpair across all three redirect mechanisms (3xxLocation,Refreshheader,<meta http-equiv="refresh">). -
Skipped
redirect_loopandredirect_max_exceededfor the wasm binding (fixtures/redirect/redirect_loop.json,fixtures/redirect/redirect_max_exceeded.json): wasm’sfetchfollows redirects transparently with no manual hop tracking, somax_redirectsis never enforced there and a genuine cycle exhausts the browser’s own redirect budget and errors instead of stopping at one hop.
Changed
Section titled “Changed”-
Upgraded workspace dependencies across semver-incompatible boundaries (
cargo upgrade --incompatible):quick-xml0.41 -> 0.42 anduuid1.24 -> 1.25. quick-xml 0.42 is a breaking change — element names now read as&strinstead of&[u8], andBytesText::xml_contentis infallible rather than returning aResult— sositemap.rsmatches on string literals and consumes the decoded text directly. Theflutter_rust_bridge(=2.12.0) and renamedgetrandom(0.2/0.3) requirements were deliberately left behind; both are pinned on purpose, the latter to force the JS backend features into the older getrandom lines pulled in transitively. -
Bumped the declared
liter-llmminimum from1.17to1.18(resolved: 1.18.0) now that the in-flight limiter is used, and refreshedhtml-to-markdown-rsto 3.11.4 in the lockfile. liter-llm 1.18.0 is a breaking change shipped as a minor —EmbeddingProvider::embedtakes&EmbeddingInputrather than&str, andVectorMetadatagained animage_urlfield — but crawlberg references none of those three surfaces, so the upgrade is source-compatible here. -
Added
[crates.e2e.snippets](output = "docs-site/src/snippets/generated") toalef.toml, matching the config shape tree-sitter-language-pack and html-to-markdown use for the alef doc-snippet migration.[workspace.docs.snippets].dirsstill points at the flat, hand-writtendocs-site/src/snippetstree, deliberately not yet repointed atgenerated/:alef e2e generatecannot write any snippet today because 263 of 264docs-tagged fixtures leakMOCK_SERVER_URLmock-harness scaffolding into their would-be snippet body and alef’s mock-harness guard aborts the whole batch before writing anything. Unlike tslp/h2m, nearly every crawlberg e2e call takes a URL, so this needspreserve_input_urls+ a$mock_urlplaceholder added across the fixture set — verified correct in isolation againstfixtures/engine/engine_scrape_basic.json, but not applied repo-wide pending its own review. The 14 hand-writtengetting-started/basic_usage.mdsnippets are unchanged.
[1.3.3] - 2026-08-22
Section titled “[1.3.3] - 2026-08-22”-
CI Lint’s
Validate (poly)job runs again.poly lint .never reached a crawlberg finding: golangci-lint v2.12.2 (the reusable workflow’s default) vendorshonnef.co/go/toolsv0.7.0, whose IR builder panics building the Go 1.27 stdlib withbuildir: package "poll": unexpected expr: *ast.KeyValueExpr. Pin v2.13.1, which handles 1.27. -
CI Lint’s
Alef snippetsjob can pass. It ranalef snippets check --strict, and--strictfails the run on every Skip, Unavailable and Downgraded result — the exact statealef.tomldocumentsstrict = falsefor while six languages are still annotatedsnippet:syntax-only. The command-line flag force-enabled what the config deliberately disables, so the job was unpassable. Dropped from the workflow. -
The Dart snippet validates at
compileagain. It reportedTarget of URI doesn't exist: 'package:crawlberg/crawlberg.dart'because no session resolved the local package. A[workspace.docs.snippets.sessions.dart]session (cwdpackages/dart, manifestpubspec.yaml,dart pub getbefore, explicitPUB_CACHE) fixes it; thesnippet:skipannotation added while the session was deferred is gone. It was deferred because any semanticalef.tomledit rotates the global inputs hash, so it had to land with a full regeneration. -
CI Rust’s
alef verify --exit-codegate passes. Unmasked oncedeps:checkstopped killing the job, it failed on drift that had accumulated unseen: 39 alef-owned files carried no provenance marker (frozen), and five carried one but are no longer emitted (orphaned). -
alef no longer claims the three hand-written SSRF e2e suites. Their self-label read “These are NOT generated by alef”, and alef’s ownership predicate (
core::hash::content_has_alef_marker) is a case-insensitive substring match forgenerated by alefover the first 10 lines, with no negation handling. All three were therefore claimed and reported permanently stale and orphaned, while alef could never stamp them. Reworded the label; the tests themselves are untouched. -
Removed two orphaned generated files.
InvalidInputException.javasurvived the deletion of its Rust error variant and exists in no other binding, andcrates/crawlberg-py/src/pyproject.tomlis a stale copy ofpackages/python/pyproject.tomlwhosemanifest-pathdoes not even resolve from its own directory. The latter’s dead[workspace.sync].extra_pathsentry is gone too. -
CI Rust’s
Validate Rustjob gets past its first command.task rust:lint:checkrunsdeps:checkfirst, which hard-fails whencargo-macheteis missing; nothing installed it, so the job died beforecargo fmt,clippyor the fuzz/config checks ever ran. -
e2e/dart/test/metadata_test.dartcompiles again.faviconsandhreflangsonPageMetadataareOption<Vec<_>>in the Rust core, so the generated Dart bindings expose them as nullableList<FaviconInfo>?/List<HreflangEntry>?. The test called.any(...)on them directly instead of the already-established?.length/!pattern used elsewhere in the same file, which Dart’s null safety rejects at compile time. Changed both call sites to!.any(...).
[1.3.2] - 2026-08-21
Section titled “[1.3.2] - 2026-08-21”-
The release actually publishes. v1.3.1 was tagged and released but published nothing to any registry: the
Validate versionsgate failed on staleCargo.lockfiles undere2e/rust,fuzzandpackages/ruby/ext/crawlberg_rb/native, which skipped the crates.io publish job. Every language-package build behind it then failed withfailed to select a version for the requirement ^1.3.1, because the publish preparation was retrying against a registry version that had never been pushed. Use this version instead of v1.3.1, which carries no artifacts anywhere. -
pnpm installsucceeds in the WASM and Node test suites again. The last dependency upgrade movedvitestto ^4.1.10 (and@types/nodeto ^26) in package.json without regeneratingpnpm-lock.yaml, so CI — wherefrozen-lockfileis on by default — refused to install:specifiers in the lockfile don't match specifiers in package.json. The lockfiles undere2e/wasm,test_apps/wasmandtest_apps/nodeare regenerated.
[1.3.1] - 2026-08-21
Section titled “[1.3.1] - 2026-08-21”Changed
Section titled “Changed”-
The engine now drives the crawl through the configured
Frontier.CrawlEngineBuilder::frontierpreviously accepted any implementation and then ignored the queue half of it: URLs lived in aVeclocal to the crawl loop, sopush,pop,pop_batch,len, andis_emptynever ran and a persistent or distributed frontier had no effect on the crawl. Discovered links are now pushed to the frontier, and the loop refills a bounded local window frompop_batch. -
Global traversal order is now a property of the frontier, not the strategy. The engine passes its selection window — at most
max_concurrententries — toCrawlStrategy::select_next, so a strategy reorders only what has already been popped.InMemoryFrontieris FIFO and yields a breadth-first crawl; the newLifoFrontieryields a depth-first one.DfsStrategyalone no longer produces a globally depth-first crawl, andBestFirstStrategynow picks the highest priority within the window rather than the global maximum. With the defaultscore_url(inverse depth) that is not an observable difference; with a custom one, visit order changes. -
crawl.frontier_sizecounts the selection window plus the entries pushed to the frontier and not yet popped. The meaning — URLs known to be pending — and the value in the default configuration are unchanged. -
A panic inside SSRF validation now fails the crawl instead of being downgraded to a warning and skipping the link.
-
Generated bindings regenerated on alef 0.62.8, and alef pinned to 0.62.8.
-
All Rust dependencies taken to their latest versions (
cargo upgrade --incompatiblefollowed bycargo update): 87 packages changed, two added, six removed, none downgraded. Notable major bumps:ctor0.10 to 1.0,napi3.8 to 3.12,minijinja2.19 to 2.24,diplomat0.15 to 0.16,rmcp3.0 to 3.1.cbindgen0.29.2 to 0.29.4 changes generated C enum emission to guard on C23.
CrawlConfig::crawl_strategy(bfs,dfs,best_first,adaptive). The strategy implementations have always existed but no binding could select one, so every crawl ran the breadth-first default. SelectingdfspairsDfsStrategywith a LIFO frontier, because traversal order is a property of the queue andDfsStrategyover a FIFO frontier is not depth-first.CrawlConfig::content_filter(bm25) withbm25_queryandbm25_threshold, and aBm25Filterexport. The filter existed but was not re-exported and no config could reach it, so every crawl ran unfiltered. Abm25filter without a query is now a config error rather than a filter that silently keeps every page.LifoFrontier, an in-memory frontier that pops the most recently pushed entry, for depth-first crawls.Serialize/DeserializeonFrontierEntry, so a frontier backed by a database, a file, or a message queue can encode the entrypushreceives instead of maintaining a mirror struct that silently drops newly added fields (#40).
-
The default crawl is genuinely breadth-first. The engine removed the strategy-selected entry with
Vec::swap_remove, which moves the last element into the vacated slot; sinceBfsStrategyalways selects index 0, index 0 held the newest URL after the first removal. A seed linking toa,b, andcwas crawled as seed,a,c, andbwas never fetched under amax_pagesbudget (#39). -
Discovered links reach the queue in document order. They were enqueued from a
JoinSetdrained in SSRF-validation completion order, leaving sibling order nondeterministic and breadth-first traversal unreproducible (#39). -
A URL selected immediately before the page budget was exhausted is returned to the frontier instead of being silently dropped.
-
Four e2e fixtures asserted fields that do not exist on the result type, so alef refused to generate the suite.
redirect_loop,redirect_max_exceededandredirect_to_404assertedis_error, andrate_limit_basic_delayassertedrate_limit.min_duration_ms; both are call-level properties rather than response fields. The redirect fixtures now assert real fields (redirect_count,pages[0].status_code, anderrorfor the 404 case), and the rate-limit fixture carries an explicitnot_representablemarker alongside a realpages_crawledcheck.redirect_loop’s mock was also wrong: its start URL returned an unrelated 200 while the actual redirect cycle sat on unreachable paths, so the fixture never exercised loop detection at all. -
packages/ruby/ext/crawlberg_rb/native/Cargo.tomlande2e/rust/Cargo.tomlnow follow the project version. Both are alef-owned but were never reached by the version sync, so each release left them pinned to the previous version.
Security
Section titled “Security”h2advanced to 0.4.18, resolving RUSTSEC-2026-0258 (unbounded empty DATA frames: a peer could queue empty frames without limit, risking unbounded memory use or a panic on length overflow). Low severity.- The wasm crawl loop deduplicates through the frontier rather than a loop-local
HashSet, so a persistent frontier no longer re-enqueues URLs it had already crawled. It also no longer discardsmark_seenfailures. - URLs still being fetched when a crawl stops early are returned to the frontier. They are marked seen at discovery, so a persistent frontier that never got them back would blacklist them permanently — never crawled, with no error raised and no failure counted.
- A crawl no longer ends on a single short
pop_batchwhen the frontier still reports work. Queue-backed frontiers legitimately under-deliver (SQS short polling returns 0-N messages from a non-empty queue); the loop now confirms withFrontier::is_emptybefore finishing, at most once per completed fetch. - The
strategyandfiltere2e fixtures assert something again. Theircrawl_strategy/content_filterinputs named no real config field, so both bfs and dfs fixtures ran the same default strategy and every bm25 fixture ran unfiltered; the ordering assertions on top of that were emitted as skipped comments in all 16 languages. Themetadatasuite additionally failed to compile once itsarticle.*/response_headers.*mappings went live, because those fields areOptionand were not declared as such.
[1.3.0] - 2026-08-13
Section titled “[1.3.0] - 2026-08-13”This release contains a source-breaking change to CrawlError. It is a minor bump rather than a major one, so
cargo update will pull it into an existing crawlberg = "1" dependency — pin to =1.2.1 if you are not ready to
adapt. Only the Rust crates ship in this release; the language bindings stay on 1.2.1 until their generator is fixed.
Changed
Section titled “Changed”- Breaking. The 17 message-only
CrawlErrorvariants are now struct variants carrying{ message, source }, andSsrfPolicyViolationgains asource.CrawlError::Timeout(text)becomesCrawlError::Timeout { message: text, source: None }; matches and constructions must be updated. Every#[error]format string is byte-identical to 1.2.1, soDisplayoutput — and anything keyed on it, including the[network:<tag>]prefix and the 500/503/504 suffix matchers — is unchanged. Error::source()now yields the originating error on every variant instead ofNone. This is what makesdowncast_ref::<reqwest::Error>()work again, recoveringis_connect(),is_timeout(), and.url()from the underlying failure. The source isArc-backed becauseCrawlError: Cloneis load-bearing in the retry path.html-to-markdown-rsmoves to 3.11. The full suite passes unchanged, so this release carries no markdown output drift.
- The HTTP cache honours the response’s own
Cache-Controlinstead of storing any 2xx for a flat TTL.no-store,private,no-cache, andmax-ageare respected, withs-maxagetaking precedence. A crawl cache is shared — one entry is replayed to whoever asks next — so storing aprivateorno-storeresponse could hand one tenant’s content to another. - Conditional revalidation, making good on the
etagandlast_modifieddoc comments that previously promised it. A stale-but-validatable entry now earns a 304 for the cost of one bodiless round trip.DiskCacheno longer unlinks a TTL-expired entry, since that entry is exactly what a conditional request needs; themax_entriessweep still reclaims it. CrawlCache::get_stale, defaulted toOk(None)so implementations outside this crate keep compiling and simply decline revalidation.
Security
Section titled “Security”- Closed a DNS-rebinding TOCTOU in SSRF enforcement.
validate_urlresolved the host and checked every answer, then hyper resolved it again to open the connection — so the addresses checked were never the addresses connected to. A host withTTL=0could answer the validation lookup publicly and the connect lookup with a loopback or cloud-metadata address. The check now runs inside the resolution hyper actually uses. It is skipped when a proxy is configured, because hyper then resolves the proxy host and client-side pinning is impossible through a proxy anyway. - Configured credentials are now scoped to the origin host across redirects. Redirects are followed manually under
redirect::Policy::none(), so reqwest’s own cross-host credential stripping never ran, and every hop reattachedconfig.authunconditionally — an open redirect off an authenticated origin handed the caller’sAuthorizationheader to the redirect target. Both redirect drivers were affected. Hostless or unparseable hop URLs fail closed; scheme and port are deliberately not compared, since an http→https upgrade does not change the party the credentials were issued to. - The default deny-private SSRF policy now covers RFC 6598 shared address space (
100.64.0.0/10, which carries Alibaba Cloud’s metadata endpoint at100.100.100.200and Tailscale/CGNAT node addresses) and the IPv6 unspecified address::, the analogue of the already-denied0.0.0.0/8. - A
ProxyProviderreturning an unparseable URL no longer connects directly with no trace.Proxy::customcan only answerSome/NoneandNonemeans direct, so failing closed is unreachable from inside it — the bypass is now logged instead. The URL itself is deliberately not logged, because the redaction helper returns its input unchanged when the input does not parse, which is exactly this branch. - An unset
max_body_sizeis capped at 100 MiB. reqwest is built with gzip and brotli andResponse::chunkyields decompressed bytes, so no cap let a few hundred compressed bytes expand to gigabytes in memory before any downstream truncation ran. Enforced at the read site rather than inCrawlConfig::default, so a config deserialized from JSON or built by a binding that omits the field cannot bypass it. Reading above the ceiling is now an explicit opt-in. - Sitemap index walks are bounded by total fetches, not just depth and per-tier breadth. Those bound the tree’s
shape, not its size: 100 children per tier across 10 tiers is 100^10 fetches, and
map_limitdoes not help because it bounds URLs returned, so a tree whose leaves are empty or filtered never reaches it and keeps fetching.
- A byte-order mark now outranks the
Content-Typecharset, as the WHATWG sniffing algorithm requires. When the two disagreed the body was silently corrupted — a stalecharset=utf-8header on a real UTF-16 body replaced every non-ASCII character with U+FFFD across html, metadata, links, and markdown, with no error raised. - robots.txt user-agent groups match in one direction only, as RFC 9309 specifies. Accepting the reverse let the UA
crawlbergclaim a group written for a more specific bot such ascrawlberg-news, silently substituting that bot’s rules for the*block meant for us. DiskCache::setno longer reports success for writes that never happened. It returnedOk(())before writing whenever the eviction scan’sread_dirfailed, so a cache directory deleted at runtime made every subsequent write a silent no-op for the life of the process. The scan now degrades to writing without evicting. Related: a concurrent eviction betweenexists()andread_to_string()is an ordinary miss rather than an error, and a panicking write task propagates instead of reporting success.- Browser pool teardown is guarded against runtime-less drops and leaks.
tokio::spawnpanics with no active runtime, andPooledPage/PooledSessioncross an FFI boundary into host GC and finalizer threads, so a late drop could abort the embedding process; bothDropimpls now spawn only viaHandle::try_current(). Discarding the handler-shutdown timeout also leaked one CDP handler loop per relaunch. - The wasm crawl loop no longer traps at engine construction.
Instant::now()compiles forwasm32-unknown-unknownbut its backend traps withunreachableat runtime, andPerDomainThrottle::new()called it fromCrawlEngineBuilder::build()— so every wasm scrape and crawl died there. The published@xberg-io/crawlberg-wasmwas broken for real users, not only in tests. - The wasm crawl loop honours
max_links_per_pageinstead of a hardcoded 10,000 cap, and matches native on URL dedup and link counting. Its dedup key omitted the//path collapse, so the two targets disagreed on which URLs were duplicates, and its link cap counted raw anchors rather than enqueued links, so a page whose first N anchors were external or already seen discovered nothing on wasm and everything on native.
[1.2.1] - 2026-08-11
Section titled “[1.2.1] - 2026-08-11”1.2.0 did not publish completely — use this release instead. Its publish run failed partway: crawlberg never
reached crates.io (it stayed at 1.1.4), and the kotlin-android and WASM packages were never published. Only
crawlberg-browser 1.2.0 made it to crates.io. Everything listed under 1.2.0 below ships here.
- The crate now compiles under default features and for
wasm32-unknown-unknown.interact’s screenshot encoder was compiled unconditionally while all of its call sites are behind a browser feature, and aPathBufimport was unused on wasm32. Under-D warningsboth were hard errors, which broke the kotlin-android native builds, the WASM package build, andcargo publish’s tarball verification — the latter is why 1.2.0 never reached crates.io.
Performance
Section titled “Performance”- Response bodies and headers are no longer cloned for hooks that are not configured. The per-attempt
HttpResponsehanded to the WAF classifier and antibot strategy (two full-body copies plus a header-map deep copy) is now built only when one of them is actually present, and the retry loop’s fallback response is moved rather than cloned. - The WAF classifier is built once per process instead of once per response. It previously re-parsed the embedded fingerprint corpus and rebuilt its matcher set on every robots.txt, asset, sitemap, and wasm page fetch.
http_fetchwalks the response header map at most once instead of up to three times per response.
[1.2.0] - 2026-08-11
Section titled “[1.2.0] - 2026-08-11”SsrfPolicy.allowlist(HostMatcher) is now exposed to every language binding via a binding-safe tagged representation (exact/suffix/cidr). Allowlist entries permit access regardless of the default denylist. Closes #37.CrawlConfig.ssrf_deny_private_explicitlets a caller pinssrf.deny_privateto an explicit value so it is no longer consulted fromCRAWLBERG_ALLOW_PRIVATE_NETWORK, removing the ambiguity between a caller who meansdeny_private: trueand a binding whose struct default happens to land there.CrawlConfig.max_links_per_pagebounds how many links are enqueued from a single page. Links past the cap are dropped and a warning is logged.CrawlConfig.document_output_dirwrites downloaded document bytes to disk (<dir>/<content_hash>.<ext>) and drops them from the result, populatingDownloadedDocument.content_pathinstead ofcontent. No effect on wasm32 (no filesystem).CrawlConfig.document_content_encoding(newDocumentContentEncodingenum) opts a downloaded document’s bytes intoDownloadedDocument.content_base64for bindings that need an in-memory, serializable copy. Off by default: base64-encoding a document by default would duplicate an already up-to-document_max_sizebuffer (50 MB default) in memory per document.CrawlConfig.capture_screenshot(scrape-only, chromiumoxide-only) captures a base64-encoded PNG screenshot of the page.CrawlConfig.browser_profile(chromiumoxide-only) selects a named browser profile for persistent sessions (cookies, localStorage).
Changed
Section titled “Changed”- JS evaluation paths (
ExecuteJsinteractions andeval_script) now run under a timeout, so a hung script can no longer permanently burn a worker slot or hang the isolate. - Credentials are redacted before reaching tracing spans, SSRF-violation error messages, and
Debugoutput —ProxyConfigandAuthConfigno longer leakuser:pass@in errors or logs. - Idle per-domain rate-limiter and EWMA domain state now expire on a TTL instead of accumulating unboundedly for long-running processes that crawl many distinct domains.
- Document persistence now writes via
tokio::fsinstead of blockingstd::fson the async document-download path. - Bindings regenerated on alef 0.60.0.
-
E2E fixtures use Alef’s canonical
brewlanguage identifier, allowing strict fixture-driven generation to proceed. -
Dart: the native loader downloads and caches the library again on a cold cache. It only read the versioned cache and then threw a
StateError, even thoughnativeDownloadAndCacheLibrary()was defined and exported for exactly that case. The loader also now searches for the_dart-suffixed cdylib that is actually built, opens every candidate by absolute path (a hardened runtime rejects a relativedlopen), and names the real environment variable in its error message instead of printing the identifier$nativeLibDirEnvliterally. Fixed upstream in alef 0.55.6.Behavior change: an unresolvable native now throws a descriptive
StateErrornaming the asset URL and the download command, where it previously returnednulland let flutter_rust_bridge attempt its own relative-pathdlopen.
[1.1.4] - 2026-08-05
Section titled “[1.1.4] - 2026-08-05”- The Dart package resolves its native library from its own installed location rather than a path derived from the crate name, so loading works from any working directory and under hardened runtimes (alef 0.54.x).
- CI runs poly’s whole-project lint phase. It was skipped entirely, so
golangci-lint,rubocop,steep,dart-analyze,credoandcheckstyleran in the git hooks only and CI never saw them. - The Rust unit-test script no longer hides failures. A single
if ! { cmd1; cmd2; } | tee logsuppressedset -eand collapsed the exit status onto the last command, so ten failing test binaries reported green for weeks. Each cargo invocation is now checked via its ownPIPESTATUS.
Changed
Section titled “Changed”- Regenerated all language bindings on alef 0.55.0.
[1.1.3] - 2026-08-04
Section titled “[1.1.3] - 2026-08-04”Changed
Section titled “Changed”- Regenerated all language bindings on alef 0.51.2 and updated dependencies.
- Ruby: the gem no longer publishes its generated types into the global
Objectnamespace (theParsercollision with theparsergem); generated types stay namespaced underCrawlberg(tree-sitter-language-pack #173, via alef 0.51.1).
[1.1.2] - 2026-08-01
Section titled “[1.1.2] - 2026-08-01”cargo binstall crawlberg-clisupport — prebuilt CLI binaries can now be installed directly from GitHub Releases without compiling from source. Adds[package.metadata.binstall]to the CLI crate plus a release-timeverify-binstallCI job that installs viacargo binstalland smoke-tests the binary across the target matrix.
Changed
Section titled “Changed”- Updated dependencies.
[1.1.0] - 2026-07-31
Section titled “[1.1.0] - 2026-07-31”- Advertise a typed
outputSchema(SEP-2106) on every MCP tool, derived from the result types viaschemars(gated behind themcpfeature). This completes the structured-output story: clients now get both the machine-readablestructuredContentand a schema to validate it against.download,get_version, and the batch tools serialize dedicated DTOs so their schema and output share one source of truth. Drift tests assert every serialized field is a declared schema property and every required property is emitted, so the schema andstructuredContentcan never diverge.
Changed
Section titled “Changed”- Raw
println!/eprintln!/print!/eprint!/dbg!are denied in production code across the whole workspace (clippyprint_stdout/print_stderr/dbg_macro);tracingis the sole diagnostic surface, and CLI result output to stdout opts back in per call site (#[expect(clippy::print_stdout)]). Language bindings were regenerated with alef 0.48.11. - Breaking: the
telemetry-initCargo feature is renamed tootelto match the org-wide observability feature name; update--features telemetry-initinvocations to--features otel. - Breaking: the
crawlberglibrary is now emit-only — it installs no global subscriber or OTLP exporter. The subscriber/OTLP install (init_otlp,TelemetryConfig,TelemetryGuard,TelemetryInitError) and the console-logging module (LogConfig,LogFormat,try_init,layer) moved tocrawlberg-cli; the libraryloggingfeature is removed. The libraryotelfeature no longer pulls the exporter/subscriber stack — it only forwardsliter-llm/otelso theaiintegration’s GenAI metrics compile in. crawlberg’s own spans, semantic-convention attributes, and metric instruments remain always-on and flow into whatever exporter the consumer installs. The W3C helpers (with_traceparent,current_traceparent) are unchanged. Consumers that installed telemetry via the library should usecrawlberg-cli --features otel(export is activated at runtime byOTEL_EXPORTER_OTLP_ENDPOINT) or install their own subscriber. crawlberg-cligains anotelfeature that installs the OTLP export pipeline for every command (includingserve), gated at runtime byOTEL_EXPORTER_OTLP_ENDPOINT; the console subscriber is installed by default when OTLP is not configured. The server Docker image builds withcrawlberg-cli/otel.- Upgrade
html-to-markdown-rs3.9 → 3.10 andliter-llm1.11 → 1.12.liter-llm1.12 makestracingan always-on dependency (itstracingCargo feature is gone) and ships a real OTLP export path in its CLI; crawlberg’sotelforwarding toliter-llm/otel(behindai) is unaffected.
- The publish workflow no longer leaves the Homebrew formula pointing at a stale bottle when a release republishes the CLI.
[1.0.12] - 2026-07-30
Section titled “[1.0.12] - 2026-07-30”- Leverage the rmcp 3.0 Tasks extension (SEP-2663): the MCP server advertises the
io.modelcontextprotocol/taskscapability and, when a client both declares it and augments atools/call, runs the tool as a pollable async task (tasks/get/tasks/update/tasks/cancel) instead of blocking. Task support is exercised end-to-end over the stdio transport; on the stateless HTTP transport, which cannot propagate per-request client capabilities, a task-augmented call degrades gracefully to inline execution. crawlberg mcp --http [--host <h>] [--port <p>]serves the MCP Streamable HTTP transport directly (stdio remains the default, so existing client manifests are unaffected). Requires themcp-httpfeature.
Changed
Section titled “Changed”- MCP tool results now carry machine-readable
structuredContent(SEP-2106) alongside the human-readable text block, so schema-aware clients get typed output regardless of theformatparameter. - The Streamable HTTP MCP transport is now stateless by default (SEP-2567):
legacy_session_modeis disabled andjson_responseenabled, with a shared,Arc-backed task store so tasks remain observable across requests. - Upgrade
base64from 0.22 to 0.23, aligning with rmcp 3.0’s requirement.
[1.0.11] - 2026-07-29
Section titled “[1.0.11] - 2026-07-29”Changed
Section titled “Changed”- Upgrade
rmcp(andrmcp-macros) from 2.0 to 3.0. The MCP server, param, and error code is source-compatible with the new major, so no adjustments were needed; contract and HTTP transport tests pass unchanged. - Update the remaining Rust dependencies within range (
schemars,tokio-stream,sse-stream,ref-cast). - Regenerate all language bindings on alef 0.48.8, which fixes the Swift e2e
suite (optional
Vec<Named>metadata fields such asheadingsare JSON-bridged to aRustStringgetter and are no longer emitted as uncompilable.countassertions) and adds a per-RID native runtime project for the C# meta+runtime split.
- Refresh the PHP e2e
composer.locksoguzzlehttp/guzzleresolves to^8.0; the lock still pinned 7.x against the^8.0constraint, abortingcomposer installbefore the PHP e2e suite could run.
[1.0.10] - 2026-07-27
Section titled “[1.0.10] - 2026-07-27”Changed
Section titled “Changed”- Regenerate all language bindings on alef 0.48.4, which fixes Java (Maven)
publishing by lowering the maven-enforcer version floor and fixes C# (NuGet)
publishing by generating a
runtime.jsontemplate rendered at pack time. - Verify Rust dependencies against their latest incompatible versions; all were already current, so no dependency versions changed.
[1.0.9] - 2026-07-26
Section titled “[1.0.9] - 2026-07-26”Changed
Section titled “Changed”- Regenerate all language bindings on alef 0.48.2.
- Update dependencies to their latest compatible versions.
Removed
Section titled “Removed”- Remove unused Java PMD ruleset and stale linter configuration.
[1.0.8] - 2026-07-20
Section titled “[1.0.8] - 2026-07-20”- wasm32 builds no longer fail compiling
mio.reqwestwas declared with its default feature set (default-tls,http2,system-proxy), which enablestokio/net→mioat the Cargo-manifest level.miohas no wasm32 support, so any downstream wasm build that pulls crawlberg (e.g.xberg-wasm) failed to compile — even though reqwest’s own code cfg-gates its native transport off wasm.reqwestis nowdefault-features = falseat the workspace level, with the native TLS/HTTP2/proxy features re-added only under[target.'cfg(not(target_arch = "wasm32"))'.dependencies]in the crates that need them (crawlberg,crawlberg-browser,crawlberg-bypass, and the internalbenchmark-harnesstool). Native behavior is unchanged; wasm builds get a fetch-backed reqwest with no tokio/mio.
[1.0.7] - 2026-07-19
Section titled “[1.0.7] - 2026-07-19”- Elixir NIF now builds and publishes. 1.0.6 could not publish the Elixir
package — the generated streaming-start NIF cloned the
Arc<RwLock<Handle>>and called a core stream method that does not exist on it (E0599), failing all NIF builds. Regenerated with alef 0.38.0, the streaming NIF read-locks and clones the inner handle first, matching the non-streaming path. - Elixir
create_engine/1no longer double-encodes its config. The generated binding unconditionally re-encoded its argument, so the documentedJason.encode!(%CrawlConfig{})string form was JSON-encoded twice (serde rejected the string) andcreate_engine(nil)became"null". alef 0.38.0 forwardsniland pre-encoded strings as-is, encoding only native maps. - Dart
freezeddev-dependency pinned back to^3.2.5. The 1.0.6 release carried a4.0.0-dev.3prerelease that requires a newer Dart SDK than CI provides; reverted sodart pub getresolves the stable release. (packages/dart/pubspec.yaml) - Swift e2e length assertions on JSON-bridged metadata collections compile
again.
metadata.headings/hreflangs/faviconsareOption<Vec<T>>fields that swift-bridge exposes as a scalarRustString(no.count), so the generated.lengthassertions emitted uncompilable.count. alef 0.38.0 skips these, matching the other C-ABI backends.
- Bindings, stubs, READMEs, docs, and e2e suites regenerated with alef 0.38.0 (up from 0.34.4).
[1.0.6] - 2026-07-19
Section titled “[1.0.6] - 2026-07-19”map()/map_urls()no longer materialize the entire sitemap tree before applyingmap_limit. The limit previously bounded only the returned slice, not peak memory: a large sitemap-index host could drive the process into multiple GB and be OOM-killed even with a smallmap_limitset.map_limitand theexclude_paths/map_searchfilters are now compiled once and threaded through the sitemap fetch loop — entries are filtered as they are parsed, and both child-sitemap fetching and per-child parsing stop once the limit is reached. Peak memory is bounded to roughly the limit plus a single child sitemap. (crates/crawlberg/src/map.rs,crates/crawlberg/src/sitemap.rs) Closes #33.
- Refreshed in-major dependencies (
deno_core0.408,uuid1.24) and lock files. - Internal maintenance: pruned stale TODO markers, closed remaining todo gaps, and added the ai-rulez Poly commit hooks.
[1.0.5] - 2026-07-09
Section titled “[1.0.5] - 2026-07-09”Security
Section titled “Security”- Per-hop SSRF re-validation on the headless-browser tier. Closes the known
limitation noted in 1.0.4: real headless Chrome follows 3xx redirects and
client-side navigations internally, so only the seed URL was checked. Browser
fetches now enable CDP Fetch interception for the duration of each navigation
and validate every request URL (initial navigation, redirects, and
subresources) against the SSRF policy before Chrome connects. Blocked requests
are failed with
BlockedByClient; a blocked main-frame request surfaces as a preciseCrawlError::SsrfPolicyViolationrather than a generic navigation error. This brings the chromiumoxide backend to parity with the native backend, which already re-validates each redirect hop. (crates/crawlberg/src/browser.rs)
- Bindings, stubs, READMEs, docs, and e2e suites regenerated with alef 0.34.4
(up from 0.31.1). The 0.34.4 scaffold formats generated files in place instead
of excluding them from poly, and refreshes the
.gitattributes/.pubignorescaffolding.
[1.0.4] - 2026-07-09
Section titled “[1.0.4] - 2026-07-09”Security
Section titled “Security”-
SSRF validation on the headless-browser tier. The browser fallback (reached directly via
BrowserMode::Always/Stealth, or via dispatch escalation toTier::Browser) navigatedpage.goto(url)without the SSRF check the HTTP tier already enforced, so a seed or escalated URL could reach loopback, RFC1918, link-local, or cloud-metadata addresses through a real browser. The target is now validated againstCrawlConfig::ssrf— the samedeny_privatepolicy and DNS resolution as the HTTP tier — before any navigation. (crates/crawlberg/src/browser.rs)Known limitation: in-browser redirects and client-side navigations are not yet re-validated per hop (that requires CDP request interception); the pre-navigation check plus
deny_privatecover the direct and DNS-rebinding-on-the-seed vectors.
[1.0.3] - 2026-07-04
Section titled “[1.0.3] - 2026-07-04”Maintenance release. Migrated pre-commit hooks to poly + mago (dropping prek,
phpstan, and php-cs-fixer), made the update/upgrade tasks resilient to
per-language failures, and regenerated bindings. Version-only bump synced
across all manifests.
[1.0.2] - 2026-07-02
Section titled “[1.0.2] - 2026-07-02”Maintenance release. Migrated the toolchain to poly via the shared reusable validate workflow, upgraded binding dependencies, and regenerated bindings. Version-only bump synced across all manifests.
[1.0.1] - 2026-06-29
Section titled “[1.0.1] - 2026-06-29”Maintenance release. Version-only bump synced across all manifests; .gitignore
ai-rulez block reorganized.
[1.0.0] - 2026-06-27
Section titled “[1.0.0] - 2026-06-27”First stable release. Promotes 1.0.0-rc.2; version-only bump synced across all manifests.
[1.0.0-rc.2] - 2026-06-27
Section titled “[1.0.0-rc.2] - 2026-06-27”Release candidate 2. Maintenance release with version bump.
[1.0.0-rc.1] - 2026-06-26
Section titled “[1.0.0-rc.1] - 2026-06-26”Changed
Section titled “Changed”- Renamed the project from
kreuzcrawltocrawlberg. The crate (crawlberg), every per-language package, the C FFI symbol prefix (kcrawl_*→cberg_*), the Go module (github.com/xberg-io/crawlberg), and the docs domain (docs.crawlberg.xberg.io) follow. - Rebranded the
kreuzbergnamespace toxberg. npm scope@kreuzberg→@xberg-io, JVM/Maven groupIddev.kreuzberg→io.xberg, ecosystem links and badges move togithub.com/xberg-io/xbergand theXberg.devbrand, andKREUZBERG_*env vars becomeCRAWLBERG_*. The legal entity name (Kreuzberg, Inc.) is unchanged.
- Swift publish now creates the
release/swift/<version>branch carrying the substituted XCFramework checksum. The alef-generated Swift e2e/test-app pins.package(url: …, branch: "release/swift/<version>"), but the publish workflow only force-moved thev<version>tag and never created that branch, so SwiftPM could not resolve the package. The checksummed commit is now also pushed torefs/heads/release/swift/<version>. (.github/workflows/publish.yaml)
[0.3.0] - 2026-06-23
Section titled “[0.3.0] - 2026-06-23”First stable release. crawlberg ships a Rust core with active bindings for Python, TypeScript/Node, Ruby, PHP, Go, Java/JNI, C#, Elixir, WebAssembly, Dart, Kotlin/Android, Swift, Zig, and C FFI, plus a CLI, an HTTP API, and an MCP server.
- Tiered dispatch engine. The crawl engine chains HTTP → Bypass → Browser
tiers driven by per-attempt signals rather than a single bypass
short-circuit. Public
crawlberg::types::dispatchsurface:Tier,EscalationStrategy,EscalationReason,AttemptOutcome,RetryDirective,RetryPolicy,WafSignal,WafClassifier,DomainStatePort,DomainRecommendation,EscalationBudget, andDispatchProfile(dispatch enums are#[non_exhaustive]).CrawlConfig::builder()andDispatchProfile::builder()provide fluent construction. - WAF detection. A TOML fingerprint corpus (
rules/waf_fingerprints.toml, 34 fingerprints) with an Aho-Corasick matcher,TomlClassifier::watch()hot-reload (debounced, atomicArcSwap, Kubernetes ConfigMap-safe), andEwmaDomainStatefor per-domain block-rate tracking that promotes/demotes the starting tier. - SSRF defense. New
crawlberg::net::ssrfmodule —SsrfPolicy,HostMatcher(Exact/Suffix/Cidr),SsrfError, and asyncvalidate_url.CrawlConfig::ssrfplus builder methodsallow_private_networks(bool)andssrf_allowlist_host(HostMatcher);CrawlError::SsrfPolicyViolation. Exposed as a settable DTO (deny_private,max_redirects) across every binding. - Browser pool injection.
BrowserPool/BrowserPoolConfigandNativeBrowserExecutor/NativeBrowserExecutorConfigare public;CrawlEngineBuilder::with_browser_pool/with_native_executorandCrawlEngineHandle::from_enginelet consumers construct andwarm()a pool once and reuse it across all crawl jobs. - Public substrate parsers.
crawlberg::robotsandcrawlberg::sitemapare public (parse_robots_txt,is_path_allowed,RobotsRules,parse_sitemap_xml,parse_sitemap_index,is_sitemap_index) — usable without spinning up the engine. - Pluggable proxy rotation.
ProxyProvidertrait +StaticProxyProviderbaseline, wired into the reqwest fetch path viaCrawlEngineBuilder::with_proxy_provider; called per request and taking precedence over the staticCrawlConfig::proxyvalue. - CLI.
batch-scrape,batch-crawl,download,citations, andversionsubcommands, bringing the CLI to 1:1 with the core and MCP surfaces. - MCP server. Tools are 1:1 with the CLI (
batch_crawl,generate_citations, …), each declaringread_only/destructive/open_worldsafety annotations, and are served over both stdio and rmcp Streamable HTTP at/mcpwhen the binary is built with theapi+mcpfeatures. - Observability. OpenTelemetry counters
crawlberg_waf_fingerprint_matches_totalandcrawlberg_escalations_total, plus property tests, cargo-fuzz targets, and Criterion benchmarks covering the WAF subsystem.
Changed
Section titled “Changed”- Memory-bounded streaming crawl.
crawl_stream/batch_crawl_streammove each page into itsCrawlEvent::Pageand drop it instead of accumulating every page, bounding peak memory on large crawls (≈2.5 GB → ≈20 MB working set).crawl()’s batch result is unchanged. - Dispatch model.
CrawlError::WafBlockedis now a struct variant ({ vendor, message });DomainStatePortmoved to an observation model (recommend/observe);SimpleRetryPolicy’s off-by-one is fixed (max_retries=3yields 3 retries);#[non_exhaustive]added toCrawlError,NetworkErrorKind, and the dispatch enums so future variants are non-breaking. - Asset downloads route through
http_fetch, so every file fetch is subject to the SSRF policy.
- Crawl loop materializes downloaded documents. The
download_documentsflag was previously honored only by single-pagescrape(); the crawl loop now buildsCrawlPageResult.downloaded_documentfor linked PDFs/DOCX via a shared helper instead of fetching, flagging, and discarding the bytes. - SSRF rollout hardening. Follow-up fixes to the SSRF refactor: redirect
final_urlis tracked again (per-hop re-validation moved intofollow_redirects), within-batch URL dedup no longer races, crawl child-depth is incremented (restoringmax_depthandinclude_pathssemantics), andCrawlConfigJSON deserialization honorsCRAWLBERG_ALLOW_PRIVATE_NETWORKthrough aSsrfPolicy::from_envserde default. Each is covered by a regression test. - MCP server exposed zero tools. The handler was missing rmcp’s
#[tool_handler], sotools/list/tools/callreturned an empty list over both stdio and HTTP; it now delegates to the generated tool router.
Security
Section titled “Security”- SSRF defense, enabled by default.
scrape(),crawl(),batch_crawl(), sitemap fetch, robots.txt fetch, and asset download refuse URLs resolving to loopback (127.0.0.0/8), RFC1918 private networks, link-local (169.254.0.0/16), cloud metadata (0.0.0.0/8), multicast (224.0.0.0/4), IPv6 ULA (fc00::/7), IPv6 link-local (fe80::/10), IPv6 multicast (ff00::/8), or any non-http(s) scheme. Includes DNS-rebinding mitigation (every resolved IP must pass the policy), redirect-chain re-validation (bounded byssrf.max_redirects, default 5), and link-enqueue validation with bounded concurrency. Opt out viaCRAWLBERG_ALLOW_PRIVATE_NETWORK=1orCrawlConfig::allow_private_networks(true).
- Bindings, facades, READMEs, docs, stubs, and e2e suites are generated by alef (pinned at 0.26.6) across all 14 language targets.
- Publish-pipeline hardening: a native per-arch Docker matrix that drops QEMU emulation, Flutter-free Dart native builds for pub.dev, Swift artifactbundle checksum injection and Apple system-framework linking, and lockfile-preserving source publishes for the Elixir NIF, PHP extension, and Ruby gem.