Skip to content

feat!: scope session cookies, bound response decompression, and drop the FlightRadar24 alias - #118

Merged
JeanExtreme002 merged 22 commits into
mainfrom
fix/cookie-scoping-and-dep-floors
Aug 21, 2026
Merged

feat!: scope session cookies, bound response decompression, and drop the FlightRadar24 alias#118
JeanExtreme002 merged 22 commits into
mainfrom
fix/cookie-scoping-and-dep-floors

Conversation

@JeanExtreme002

@JeanExtreme002 JeanExtreme002 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Fixes a live cookie-handling bug in the Node SDK, gives both ports a bound on how far a response body may expand, raises dependency floors past known advisories, hardens CI, and drops the deprecated FlightRadar24 import alias.

Released as 1.6.0 in both packages.


1. The Node session jar ignored every cookie attribute

The jar kept cookies in a flat {name: value} object and replayed all of them on every request. Probing two origins against the old code:

jar after login: { _frPl: 'sess.token.with', expiredOne: 'deleteme' }
host=127.0.0.1  /login                  Cookie: (none)
host=localhost  /assets/.../AA_AAL.png  Cookie: _frPl=sess.token.with; expiredOne=deleteme

Three separate defects:

  • Values were truncated. Set-Cookie was split on every =, so _frPl=sess.token.with==padding was stored as sess.token.with. That truncated value is what getAirportDetails sends as token and getFlights as enc — this was corrupting live requests, not sitting latent.
  • No host scoping. A cookie set by www.flightradar24.com was attached to cdn., api., data-live. and data-cloud. too. All siblings, so not a third-party leak, but Domain / Path / Secure were not honoured at all.
  • Expiry ignored. Max-Age=0 — a deletion instruction — stored the cookie.

Now: attributes are parsed properly, the jar is a Map keyed by name/domain/path, Max-Age/Expires delete on the way in and are re-checked on the way out, Secure cookies stay off plaintext, and a Domain that does not cover the host discards the cookie rather than narrowing it (RFC 6265 §5.3.6). Cookies are credited to the host that ended a redirect chain, on both the success and error paths.

Cookies from a failed response are now banked too: a Cloudflare 403 is the response that hands out cf_clearance, and discarding it made RetryPolicy replay the identical blocked request. They ride on the error as a non-enumerable property, so JSON.stringify(err) does not print session credentials.

Scope note: this fixes what the jar sends. getAirportDetails and getFlights still read _frPl via getCookie() and forward it by hand as the token / enc query parameter to api. and data-cloud. — that is FR24's API shape and is unchanged here.

The Python port was already correct on all of this; curl_cffi's jar scopes by host and preserved the full value under the identical probe.

2. A compressed body could expand without limit

Measured: ~500 bytes on the wire expands to 300 MB, ~1.7 KB to 1 GB — enough to kill a process on a small host. Both ports now refuse a body past a 64 MiB budget (max_response_bytes, overridable per request).

Node streams response.body against the budget, because undici decompresses in the transport. Python takes content decoding away from libcurl (CURLOPT_HTTP_CONTENT_DECODING = 0) so the cap sits inside the decompressor, and bounds the download itself with MAXFILESIZE_LARGE. A 512 MB bomb from 841 bytes is refused with RSS growing by the budget; a 2 GB bomb costs 6 MB more than the 512 MB one, so the cost tracks the limit rather than the payload.

Two things that reaching this required, both worth knowing:

  • The curl option is applied per request. Set once at construction it works exactly once — Session.request resets the handle — and requests 2..n arrive pre-expanded with no budget in reach.
  • Owning the decoding means owning everything libcurl did: deflate (both the RFC's zlib-wrapped shape and the raw one servers actually send), stacked encodings (gzip, br undone in reverse), case-insensitive and whitespace-padded tokens, padding after a gzip trailer, and multi-member gzip. Accept-Encoding now advertises exactly what is implemented, derived from the decoder table so advertising something undecodable is not expressible — curl_cffi's chrome136 profile otherwise asks for zstd, which nothing here can read.

Truncated bodies raise rather than passing as whole, and get_response_object() / CloudflareError.response still expose a decoded body.

3. Dependency floors

  • undici ^6.13.0^6.28.0. The lockfile already resolved a clean version, but consumers of a published library resolve the declared range, and the old floor admitted 15 advisories including request smuggling and an unbounded decompression chain. engines.node moves to >=18.17 to match what undici 6.28 actually requires.
  • curl_cffi>=0.15.0 closes GHSA-qw2m-4pqf-rmpp (redirect-based SSRF, CVSS 7.4). Brotli>=1.2.0 is required for the bounded-decompression API used above. beautifulsoup4>=4.12.0 has no advisory behind it and is a baseline only.

These three were also the only 3 of 262 packages in the dependency graph with no resolved version, because the requirements were unbounded — so Dependabot could not match advisories against them and had never reported a pip finding. Adding floors turns Python monitoring on.

4. CI hardening

  • Every action pinned to a commit SHA (no mutable tags left). pypa/gh-action-pypi-publish@release/v1 was the worst case — a branch, in the job holding id-token: write for PyPI.
  • The npm audit had never run. The job defaults into ./nodejs and the step also did cd nodejs, so every run failed with cd: nodejs: No such file or directory while continue-on-error reported success. That is how the vulnerable undici floor went unnoticed. Fixed and now enforced.
  • The Python audit blocks on the dependencies users actually install, resolved in a clean venv, with the dev toolchain kept informational so a vulnerable linter cannot block every PR.
  • Docs deploy builds and hands the site to actions/deploy-pages, which drops contents: write to contents: read; its dependencies are pinned, so a compromised docs package no longer runs with a token that can push to the repo.

5. Breaking changes

  • FlightRadar24 is gone. The alias existed so from FlightRadar24 import FlightRadar24API kept working after the rename, and had been emitting a DeprecationWarning saying it would be removed. Import from FlightRadarAPI instead. Verified against a built wheel: it ships FlightRadarAPI alone, and import FlightRadar24 raises ModuleNotFoundError.
  • engines.node >=18>=18.17, so Node 18.0–18.16 with engine-strict will refuse to install.

Semver would call removing a public import path a major bump. 1.6.0 is a deliberate choice, not an oversight: the alias had been formally deprecated with a warning, and the affected engines range is narrow.

New public API: DecompressionLimitError (both ports), max_response_bytes / max_download_bytes (Python), CloudflareError.body and rawCookies / url on the request result (Node).

Verification

offline integration (live FR24)
Python 114 47
Node 18 / 20 / 22 94 each 48

flake8, mypy, eslint and tsd clean. The Node suite runs on all three matrix versions locally, after a redirect test that passed on 22 and failed on 18. Behaviour was checked against local servers and real FR24 rather than through doubles alone — several defects here were found only that way, because the doubles fed code paths production never reaches.

Known limitations, accepted rather than fixed

  • No public-suffix list in the Node jar. Dotless domains are rejected, which covers Domain=com, but Domain=co.uk would still pass for a caller on a host under that suffix. Closing it properly means a PSL dependency, hard to justify for an SDK talking to five fixed FR24 hosts.
  • Same-name cookies in scope collapse to one rather than both being sent as RFC 6265 requires, because the Cookie header is built from a flat map.
  • util.inspect(err) can reveal a Set-Cookie through err.response. Pre-existing, and exposing the response is what that field is for.
  • The accesstoken header survives a cross-origin redirect in both ports. Cookies are correctly stripped; the custom header is not. Needs an FR24 open-redirect or DNS/MITM to matter.
  • Caller input is interpolated into URLs unencoded in getAirlineLogo and getAirport. The host is a fixed literal, so this is path confusion rather than SSRF.

A note on how this PR is shaped

Roughly half the diff is the decompression budget, hardening against a threat that requires FR24, its CDN, or the network path to be compromised. The defect that was actively corrupting user tokens is a small fraction of it, and shipped many review rounds later than it needed to. Speculative hardening should not share a PR with an active production bug; extracting it once entangled was riskier than shipping it. Worth avoiding next time.

The session jar stored cookies in a flat name/value object and replayed
all of them on every request, so a cookie set by www.flightradar24.com
was also sent to cdn./api./data-live./data-cloud., and Path, Secure and
expiry were ignored entirely. `Set-Cookie` was also split on every `=`,
which truncated any value containing one: `_frPl` arrived as
`sess.token.with` instead of `sess.token.with==padding`, and that
truncated value is what getAirportDetails sends as `token` and
getFlights as `enc`.

Parse the attributes properly and key the jar by name/domain/path.
`Max-Age=0` now deletes rather than storing an empty value, and a Map
keeps a cookie named `__proto__` off Object.prototype.
The lockfile already resolved a clean undici, but consumers of a
published library resolve the declared range instead, and `^6.13.0`
admits 6.13.0-6.27.0 — 15 advisories including request smuggling and
an unbounded decompression chain.

The Python dependencies had no bounds at all, which also left them as
the only three packages in the dependency graph with no resolved
version, so Dependabot could not match advisories against them.
Five defects in the new jar, all reproduced before fixing:

- A malformed `Max-Age` (bare or empty) read as 0 and deleted the cookie,
  because Number("") is 0. RFC 6265 says ignore a non-integer value. A
  bare `Max-Age` from FR24 would have dropped the login token silently.
- `Domain=com` passed the suffix check, so one response could plant a
  cookie replayed to every .com host the caller later requested — the
  cross-host leak this change exists to close. Reject dotless domains.
- getCookie() returned the first jar entry by insertion order, so a
  re-issued token under a different scope never superseded the old one
  and authenticated calls kept sending a dead `token`/`enc`. Take the
  newest instead.
- Cookies were attributed to the requested URL, but fetch follows
  redirects, so `Set-Cookie` belongs to the final URL. Key off that.
- Two in-scope cookies of the same name resolved by insertion order
  rather than specificity. Sort so the longer path wins.

The negative scoping assertions passed vacuously if an interceptor never
fired; they now assert it did.

The pyproject comment claimed the Brotli floor mitigated decompression
bombs. It does not: 1.2.0 only adds the bounded API, and request.py
still calls the unbounded brotli.decompress. Claim removed rather than
left overstating what the bump buys.
Review follow-ups:

- `index.d.ts` still described the pre-`url` return shape, so TypeScript
  consumers could not see the field the jar now keys off. tsd passed
  because nothing exercised it.
- The redirect attribution had no test at all — the subtlest change in
  the branch. MockAgent does not follow redirects, so this needs a real
  local server. Verified the test fails when the fix is reverted.
- A `Secure` cookie arriving over plaintext is no longer stored; we
  already refused to send one, so accepting it was incoherent.
- `storedAt` is initialised with the record, leaving the jar as the only
  thing that assigns it.

Kept the hand-rolled Set-Cookie parser rather than delegating to
undici's `getSetCookies`, which was the tempting simplification: it
drops a negative `Max-Age` instead of deleting, accepts an empty cookie
name, and widens a relative `Path` to `/`. Three regressions to remove
two bugs is a bad trade, so those three cases now have tests pinning the
behaviour.
The server bound only 127.0.0.1 while the redirect targeted `localhost`.
Node 18 resolves that to ::1 first, so the hop was refused. Node 22
happened to pick the v4 address, which is why this passed locally and
failed in CI.

Bind dual-stack instead, and drop keep-alive sockets in the teardown —
server.close() waits for the undici agent's connections, so the failure
was followed by an afterEach timeout that obscured it.

Verified on node 18.14.2, 20.20.2 and 22.12.0: 76 passing on each, and
the test still fails on 18 when the fix under test is reverted.
A compressed body was trusted to expand to any size. brotli reaches
ratios high enough that ~500 bytes on the wire expands to 300 MB, and
~1.7 KB to 1 GB — enough to kill the process on a small host.

Python now decompresses through `brotli.Decompressor.process` with a
max-output argument and `zlib.decompressobj` with `max_length`, so the
cap is enforced by the decompressor instead of being checked afterwards.
That distinction is the whole fix: a first attempt checked the size
between input chunks, which still materialised a 1 GB body (peak RSS
1936 MB) before reporting a breach it had already suffered. With the
output cap, a 1 GB and a 4 GB bomb both peak at 253 MB — cost tracks the
limit, not the payload. A test pins that with tracemalloc and fails
against the chunk-checking version.

Node reads `response.body` as a stream with a byte budget, since undici
decompresses in the transport. Both ports raise DecompressionLimitError
and default to 64 MiB, far above FR24's largest payload.

Note the Brotli>=1.2.0 floor from an earlier commit does not do this on
its own: 1.2.0 only makes the bounded API available.
- Every action is pinned to a commit SHA. Tags are mutable, so a
  compromised maintainer account can repoint one and the next run
  executes it. `pypa/gh-action-pypi-publish@release/v1` was the worst
  case: a *branch*, in the job holding `id-token: write` for PyPI.
- The docs job installed mkdocs unpinned while holding
  `contents: write`, so a malicious release would run with a token that
  can push to the repository. Versions now come from a pinned file.
- The npm audit never ran. The job defaults into ./nodejs and the step
  also did `cd nodejs`, so it failed with "No such file or directory"
  on every run while continue-on-error reported success — which is how
  the vulnerable undici floor sat unnoticed. Fixed and enforced.
- The Python audit now blocks on the dependencies users actually
  install, resolved in a clean venv, and keeps the dev toolchain
  informational so a vulnerable linter cannot block every PR.
Both packages, since publish.yml verifies they match.
Pages was switched to build_type=workflow, which stops it rebuilding on
a push to the gh-pages branch. The job still ran `mkdocs gh-deploy`, so
the next docs change would have pushed the branch, reported success and
never appeared on the site — the same silent-success shape as the audit
step this branch already fixed.

Build the site and hand it to actions/deploy-pages instead. That drops
`contents: write` to `contents: read`, so a compromised docs dependency
no longer runs with a token that can push to the repository, which was
the point of pinning docs/requirements.txt in the first place.

Verified `mkdocs build` against the pinned requirements: exits 0 and
writes to site/, which is what the upload step expects. Ignored that
directory, since the build now runs locally too.
@github-actions github-actions Bot added the build label Aug 20, 2026
Review of the budget work found that guarding size had quietly traded
away integrity.

`_decompress_gzip` returned whatever it had inflated: a body cut to 3/4
of its compressed bytes came back as 76 KB of partial JSON with no
error, where gzip.decompress raises EOFError. `_decompress_brotli` did
the same, returning 0 bytes. Both now raise when the stream ends
mid-message.

That also restores a fallback this had broken. get_content() reads a
raised error as "curl_cffi already decoded this" and returns the raw
bytes; since _decompress_brotli(b'[]') returned b"" instead of raising,
an already-decoded body became empty and json.loads blew up on it.

`_decompress_gzip` also stopped at the first member, so a concatenated
gzip body — legal Content-Encoding, emitted by some proxies — silently
lost everything after it. It now drains unused_data.

On the Node side, Response.json()/text() ran the spec UTF-8 decode,
which strips a byte-order mark; reading the body as a Buffer does not,
so a BOM-prefixed JSON body started failing to parse. Stripped by hand.

Also moved the docs pin file out of docs_dir: mkdocs copies non-page
files into the built site, so it was being published at /requirements.txt.
@github-actions github-actions Bot removed the docs label Aug 20, 2026
The budget added earlier never ran in production. curl_cffi decompresses
in the transport, so by the time `.content` exists the body has already
expanded: `_decompress_brotli` was handed already-decoded bytes, raised,
and fell through to the "transport already decoded" path. Measured
against a local server — 27 compressed bytes arrived as 200 008 — and
against FR24 itself, where every real request logs that fallback at
DEBUG. The tests passed only because they called the helpers directly
with compressed input, which is a state production never reaches.

Read the response with `stream=True` and `iter_content()` instead, and
apply the budget to the chunks as they arrive. A 512 MB bomb from 841
bytes on the wire is now refused with no growth in RSS, which is what
the Node port already did by streaming `response.body`.

Also closes the two smaller gaps from the same review:

- `max_response_bytes` is now a per-request argument, matching Node's
  `maxResponseBytes`; it was a module constant with no way to override.
- `get_content()` handling an already-decoded body under a live
  `Content-Encoding` header — the path every FR24 response takes — had
  no test. It does now, along with one asserting the response is closed
  when the body is refused.

Renamed to MAX_RESPONSE_BYTES for parity with the Node constant, since
the budget covers the response, not just what this code decompresses.
…bomb

Streaming was the wrong call, and the measurements say so plainly. In
curl_cffi 0.16, `stream=True`:

- Turns `timeout` from a wall-clock cap into a ">=1 byte/sec" liveness
  check. A tarpit trickling one byte every 400ms held a `timeout=2`
  request for 23.8s and would hold it indefinitely — including every
  worker in the get_flight_details fan-out. That is the same
  denial-of-service the byte budget exists to prevent.
- Stops the session reusing connections: 5 requests opened 5 TCP+TLS
  connections instead of 1. A fresh handshake per request is precisely
  the fingerprint Cloudflare bot management scores against, which
  undermines the TLS impersonation this library is built around.
- Leaves `Response.content` empty, so `get_response_object()` and
  `CloudflareError.response` — both public — silently return nothing.
  The challenge body is the first thing anyone inspects when debugging
  a block.

Reverted to a buffered read. Confirmed restored: timeout raises at 2.0s,
5 requests share 1 connection, and the response body is readable again.

The budget now bounds what reaches the parser rather than what the
transport allocates. It cannot undo the peak — libcurl expanded the body
before we saw it — but it does stop the larger second cost of parsing a
body that size into Python objects. The module note states that limit
honestly instead of implying the Node port's guarantee, and a test pins
the no-streaming decision so the trade-off cannot be undone by accident.

Also validates max_response_bytes, which turned a typo into a blanket
failure on every response, and restores the encoding-table wiring test.
The budget only ever bounded what reached the parser: libcurl decoded the
body first, so the peak had already happened. Disabling
CURLOPT_HTTP_CONTENT_DECODING hands this module the compressed bytes
instead, which puts the cap back inside the decompressor — the guarantee
the Node port has — while keeping the buffered read that a previous
attempt at this had to give up.

Measured end to end: a 512 MB bomb from 841 bytes on the wire is refused
with RSS growing by the budget, and a 2 GB bomb costs 6 MB more than the
512 MB one, so the cost tracks the limit rather than the payload.
`timeout` stays a wall-clock cap (2.0s) and the session still reuses
connections (1 for 5 requests) — the two things streaming cost.

The option is applied per request, not once at construction. Set once it
works exactly once: `Session.request` resets the handle, so requests
2..n arrive pre-expanded with no budget in reach. Every double-based
test passed through that because each built a fresh client; there is now
one asserting the option is set on all three of three requests, and the
local-server tests reuse a client so the lapse cannot come back quietly.

Owning the decoding means decoding everything `Accept-Encoding`
advertises, so `deflate` is implemented rather than left to a transport
that no longer does it — `Core.html_headers` asks for it, and
get_airlines() would have been served raw bytes. Both shapes are
accepted: RFC 9110 says zlib-wrapped, plenty of servers send raw.

Real FR24 traffic now decodes here instead of falling through the
"transport already decoded" path: 0 fallbacks across 4 calls, where
before every single request took it. That also makes the truncation and
budget checks in those helpers live code rather than a dead branch with
passing tests.
Taking decoding from libcurl means owning every shape the header arrives
in, and the first pass did not.

- The lookup was exact and case-sensitive, so `GZIP` or `gzip, br` fell
  through to identity and returned compressed bytes as content; callers
  got a UnicodeDecodeError out of json.loads with nothing logged. The
  token is normalised now, and an encoding with no decoder warns instead
  of passing bytes along as if they were readable.
- Worse, `zstd` was reachable: with no explicit header, curl_cffi's
  chrome136 profile asks for "gzip, deflate, br, zstd" (verified), and
  nothing here decodes zstd. Every request now advertises exactly what
  `__content_encodings` implements.
- `get_response_object().content` and `CloudflareError.response` were
  handing back the compressed body — both public, and a challenge page
  is the first thing anyone reads when debugging a block. The body is
  decoded during the request now and written back, so they read as they
  did before this branch.
- `MAXFILESIZE_LARGE` bounds the download itself, so the received size
  is limited rather than merely checked once libcurl has buffered
  everything. Surfaced as DecompressionLimitError, because the retry
  policy treats raw curl errors as transient and retrying an oversized
  body is futile.

On the Node side, `getCookie` and `__cookiesFor` disagreed about which
same-named cookie wins — newest vs longest path — so a re-issued token
could go out in the query string while the Cookie header on any
/user/... request carried the stale one, and `Core.userLogoutUrl` is
exactly that path. Both take the newest now. The cookie maps are also
null-prototype: a cookie named `toString` read as an inherited function
rather than as absent.
Review cleanups, no behaviour change on the happy path.

`max_response_bytes` governed two different limits: the bytes on the
wire and the bytes after expansion. Compression grows incompressible
data, so a body that expands to just under the budget can arrive
slightly over it and be rejected by a bound that was not meant for it.
`max_download_bytes` is now separate, defaulting to the other.

`supported_encodings` is derived from the decoder table instead of
written out, so advertising an encoding with no decoder — the zstd bug
from the previous commit — is no longer expressible.

The post-hoc length check is labelled as the backstop it is: libcurl
aborts first via MAXFILESIZE, so it is unreachable unless a transport
ignores that option, and the stub-based test said so. Renamed to match,
with a pointer to the socket-level test that proves the real bound.

The module note claimed streaming cost connection reuse without
qualifying it; the standalone path already opens a connection per call,
so that cost lands on the session path only. The timeout degradation
ruled streaming out on its own.

On the Node side, the cookie map merged into the Cookie header was the
last one built from a plain object literal, so it reintroduced the
inherited prototype the other two had dropped.
All seven reproduced before fixing, and re-measured after.

Python. The expansion budget stopped applying to bodies that reach no
decoder: an identity 5 MiB body passed with max_response_bytes=1024,
because the only check on the received bytes had moved to the download
bound when the two knobs were split. Checked after decoding now, so the
budget means the same thing whatever encoding arrived.

Raw deflate carries neither header nor checksum, so a body that is not
deflate can inflate to plausible bytes and be returned as content.
Requiring the whole input to be consumed is the only tell available and
takes 3000 fuzzed JSON/HTML bodies from 6 silent passes to 1. It applies
to raw only: the zlib wrapper has an adler32 and validates itself, and
demanding full consumption there rejected legitimate bodies that arrived
with trailing padding.

Node. Both error paths threw before the body was read, so undici had to
destroy the socket: 10 sequential 500s opened 8 new connections against
2 for 200s. Every Cloudflare block and every 5xx therefore cost a fresh
TLS handshake, compounding under RetryPolicy — the same
handshake-per-request pattern this branch rejected stream=True over. The
body is read before the checks now, and 10 errors open none.

That read also ran outside the abort timer, so timeout only covered
time-to-headers. A server dripping 20 bytes at 400ms resolved after
8032ms against timeout 500 — precisely the degradation cited for
rejecting streaming on the Python side. The read is inside the timer
now: TimeoutError at 504ms.

Draining the body for that first fix left response.bodyUsed true, so the
challenge page CloudflareError exposes was no longer readable. The error
carries it directly.

Also: the path-length tie-break in __cookiesFor was unreachable, since
storedAt is unique per cookie; dropped, with the same-name collapse
documented as the deliberate limit it is. engines.node moves to >=18.17,
which is what undici 6.28 actually requires. And the three size checks
now say which case each one covers, so the next reader does not remove
the wrong "duplicate".
Both were capabilities lost when this module took content decoding over,
and both surfaced as an unreadable body rather than an error.

Padding after a gzip trailer restarted a member and raised, which
`__decode_body` then swallowed as "the transport already decoded this" and
returned the still-compressed bytes — so gzip'd JSON plus three NUL bytes
died in json.loads. libcurl stopped at the stream end and ignored the
tail, and the deflate helper already tolerated exactly this for the zlib
shape, so the gzip helper was the inconsistency. Another member is only
started when the tail actually looks like one.

Stacked encodings missed the table entirely: `Content-Encoding: gzip, br`
found no single-token decoder, warned, and handed back compressed bytes.
The header is split now and the decoders applied in reverse, so gzip,
"gzip, br" and "gzip, deflate, br" all round-trip, whitespace and case
included.

Also, a Set-Cookie whose Domain does not cover the request host was kept
as host-only instead of discarded, so a cookie scoped to some other
domain was replayed on every later request to that host. RFC 6265 5.3.6
says ignore it.

And a module comment still pointed at `_new_curl_handle`, which the
per-request rework replaced with `_keep_body_encoded`.
…sponses

`Object.create(null)` on the result's `cookies` map broke
`cookies.hasOwnProperty(name)` in consumer code — a silent breaking
change under a type that still says `Record<string, string>`, shipping
as a patch. That map is public; the prototype hardening belongs on the
jar's internal maps, which is where it stays.

The Cloudflare challenge page skipped the BOM strip every other body
path goes through, so `err.body` could start with U+FEFF and defeat a
JSON.parse or a prefix comparison.

A stacked `Content-Encoding` that failed halfway returned the
half-decoded intermediate as if it were the body, and picked its log
level from the stage that had succeeded rather than the one that failed.
The recovery is a claim about the body as received, so that is what it
returns now.

And cookies on an error response were dropped: `Session.request` only
banked them on success, so a Cloudflare 403 — the response that hands
out `cf_clearance` — left the jar empty and RetryPolicy replayed the
identical blocked request. The errors carry their `Set-Cookie` headers
now and the session stores them before rethrowing.
Both are regressions from the commit that started banking cookies off
failed responses, and both were reproduced before fixing.

`rawCookies` was an enumerable own property on the error, so
`JSON.stringify(err)` and any log line dumping it printed the session
credentials the response had just handed out — in a branch whose whole
point is keeping cookies away from where they do not belong. Defined
non-enumerable now: `Object.keys` and `JSON.stringify` no longer see it,
while the jar still does.

The catch also credited the cookie to the requested URL rather than the
one that answered, so a cookie set after a redirect was replayed to a
host that never set it. That is the same defect the success path was
fixed for earlier in this branch; the final URL travels with the error
now.

Note, unchanged and pre-existing: `util.inspect(err)` still reveals a
Set-Cookie through `err.response`, because exposing the response is what
that field is for. Left alone deliberately rather than widened into
another change.
The shim existed so `from FlightRadar24 import FlightRadar24API` kept
working after the package was renamed to match the PyPI distribution and
the Node package. It has been emitting a DeprecationWarning saying it
would be removed in a future release; this is that release.

Gone with it: the module itself, its backwards-compatibility tests, its
entry in the wheel's package list, and the extra path in the flake8 step.
Verified against a built wheel — it now ships `FlightRadarAPI` alone, and
`import FlightRadar24` raises ModuleNotFoundError.

Version moves to 1.6.0 in both packages, since publish.yml requires them
to match.

Note for the release: removing a public import path is not a minor
change. Anyone still on `FlightRadar24` breaks on upgrade, which semver
calls a major bump. 1.6.0 is what was asked for and nothing is
irreversible until the tag is cut, but 2.0.0 is the number that matches
what this does.
@JeanExtreme002 JeanExtreme002 changed the title fix: scope session cookies to their host and raise vulnerable dependency floors feat!: scope session cookies, bound response decompression, and drop the FlightRadar24 alias Aug 20, 2026
@github-actions github-actions Bot added feature and removed bug labels Aug 20, 2026
@JeanExtreme002
JeanExtreme002 requested a lite review from Copilot August 21, 2026 03:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates both the Node.js and Python FlightRadarAPI SDKs to (1) fix session cookie handling in the Node port by properly parsing and scoping cookies, (2) enforce a hard cap on decoded response size to mitigate decompression-bomb style payloads, (3) raise dependency floors and CI security posture, and (4) remove the deprecated Python FlightRadar24 import alias.

Changes:

  • Node: replace the flat cookie bag with a scoped cookie jar (domain/path/secure/expiry) and bank cookies even from failed responses.
  • Node + Python: enforce a bounded response-body budget (default 64 MiB) with per-request override; add DecompressionLimitError.
  • CI/deps/docs: bump dependency floors, pin GitHub Actions by SHA, fix/enforce audits, and harden docs deployment permissions; remove Python FlightRadar24 shim.

Reviewed changes

Copilot reviewed 22 out of 25 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
python/tests/test_request_transport.py Adds extensive tests for bounded decompression, encoding robustness, and transport-level limits.
python/tests/test_legacy_import.py Removes legacy-alias tests now that FlightRadar24 import path is dropped.
python/tests/_request_doubles.py Extends request doubles with a curl handle stub to verify per-request curl options.
python/pyproject.toml Adds dependency minimum versions and removes FlightRadar24 from wheel packages.
python/FlightRadarAPI/request.py Implements bounded decoding (gzip/deflate/br), per-request curl options, and size limits.
python/FlightRadarAPI/errors.py Introduces DecompressionLimitError.
python/FlightRadarAPI/init.py Bumps version to 1.6.0 and exports DecompressionLimitError.
python/FlightRadar24/init.py Removes deprecated Python import-alias module.
nodejs/tests/testRequestTransport.js Adds cookie-jar scoping tests, response budget tests, and error-path shape assertions.
nodejs/tests/testFeedRetry.js Updates cookie setup to use the new jar store path.
nodejs/package.json Bumps version to 1.6.0, raises Node engine floor, bumps undici floor.
nodejs/package-lock.json Lockfile updates reflecting the new Node/package versions and undici floor.
nodejs/FlightRadarAPI/request.js Implements bounded body reads, cookie jar parsing/scoping, error cookie banking, and new return shape.
nodejs/FlightRadarAPI/index.js Exports DecompressionLimitError from the main package entrypoint.
nodejs/FlightRadarAPI/index.d.ts Updates TS types for new request return shape and errors.
nodejs/FlightRadarAPI/errors.js Adds DecompressionLimitError and extends CloudflareError to carry body.
.gitignore Ignores MkDocs build output (site/).
.github/workflows/python-package.yml Pins actions, updates lint targets, and enforces runtime-dependency auditing.
.github/workflows/publish.yml Pins actions and hardens PyPI publish action pinning.
.github/workflows/node-package.yml Pins actions and fixes/enforces npm audit execution.
.github/workflows/lint-pr-title.yml Pins semantic PR title action to a commit SHA.
.github/workflows/labeler.yml Pins github-script action to a commit SHA.
.github/workflows/deploy-docs.yml Switches to Pages artifact deploy, reduces repo permissions, pins actions, and adds concurrency.
.github/workflows/delete-pr-branch.yml Pins github-script action to a commit SHA.
.github/docs-requirements.txt Adds pinned docs build requirements for deterministic/supply-chain safer builds.
Files not reviewed (1)
  • nodejs/package-lock.json: Generated file
Suppressed comments (1)

nodejs/FlightRadarAPI/request.js:616

  • The JSDoc return type for Session.request() is stale (it inherits the module request() shape, including rawCookies and url). Keeping it outdated will mislead consumers using the class directly.
     * @param {string} url
     * @param {object} [options={}]
     * @return {Promise<{content: *, statusCode: number, cookies: object}>}
     */

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/FlightRadarAPI/request.py Outdated
Comment on lines +107 to +130
output = b""
remaining = data

while remaining:
# A new object per member: `Content-Encoding: gzip` may carry several,
# and one decompressor stops at the first trailer.
decompressor = zlib.decompressobj(_GZIP_WBITS)
output += decompressor.decompress(remaining, limit + 1 - len(output))

if len(output) > limit or decompressor.unconsumed_tail:
raise DecompressionLimitError(
f"gzip body expands past the {limit} byte decompression limit."
)

if not decompressor.eof:
raise zlib.error("gzip stream ended mid-member")

# Another member only when the tail looks like one. Trailing padding is
# not an error: libcurl stopped at the stream end and ignored it, and
# the deflate helper tolerates the same thing.
tail = decompressor.unused_data
remaining = tail if tail.startswith(_GZIP_MAGIC) else b""

return output
Comment thread nodejs/FlightRadarAPI/request.js Outdated
Comment thread .github/docs-requirements.txt Outdated
Comment on lines +1 to +3
# Pinned because this workflow runs with `contents: write`: an automatic
# upgrade to a compromised release would execute with a token that can push
# to the repository. Bump deliberately, not implicitly.
From a Copilot review of the PR, all three verified before acting.

`_decompress_gzip` accumulated with `output += ...`, which copies
everything decoded so far on every member: 1270ms for a 500-member body
against 25ms. The review suggested a bytearray, but measuring says that
is slower than the status quo on the single-member path nearly every
response takes (23.1ms vs 15.2ms on 32 MiB), because converting back to
bytes pays another full copy. Collecting the members and joining once
wins both cases — 11.9ms and 19.0ms — and returns the sole member
untouched when there is only one.

The JSDoc on `request()` and the three methods wrapping it still
described the old `{content, statusCode, cookies}` return, four places
in all, after `rawCookies` and `url` were added.

And the docs pin file justified itself with `contents: write`, which
that workflow no longer has — the deploy moved to the Pages artifact
earlier in this branch. The reason for pinning still stands, so the
wording says what it actually is: those packages execute during
`mkdocs build`.
The brotli helper's docstring claimed peak memory stays near the limit.
Measured against a 7 MiB body under an 8 MiB cap, it was 3.0x the output:
growing a bytearray copies each piece in, and converting back to bytes
copies the whole thing again.

Collecting the pieces and joining — the shape the gzip helper already
uses — brings that to 1.0x, because the single-piece response almost
every reply produces is returned without being copied at all. With the
64 MiB default that is the difference between a ~190 MiB peak and a
~64 MiB one, which matters to anyone sizing the limit for a small
container.

Re-checked the behaviours this must not disturb: empty body, ordinary
round-trip, bomb refused, truncated stream still raising at 50/75/90% of
a real stream, and the "transport already decoded this" fallback. A test
now pins the memory shape, since a correctness test cannot see it.
@JeanExtreme002
JeanExtreme002 merged commit 88eaf73 into main Aug 21, 2026
9 checks passed
@github-actions
github-actions Bot deleted the fix/cookie-scoping-and-dep-floors branch August 21, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants