Fetching a page or a logo: a capture address can forge a log line, NAT64 addresses pass the public-only check, and the size caps apply after the whole body is read #321
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#321
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 while re-checking #249's dismissed CodeQL findings against
f097f1591, by a reviewer trying to break them rather than confirm them. None of these is in #249's table, and CodeQL flags none of them: it does not treat a django-ninja payload as a source, and it does not modelhttpx's buffering.1. A capture address can write a forged line to the console log (CWE-117, low)
plugins/registry.py:383("Capture source %r failed on %s", url),:408("Could not list the places on %s", url) andplugins/builtin/__init__.py:446("Filling gaps with %s failed on %s") log the address with%s.POST /api/v1/capturesand…/captures/previewpassPageIn.url(api/api.py:185) intoremembered.read_pageandregistry.parse_page. The only check iscore/addresses.py:44-63, which looks at the scheme and returns the value unchanged, newline included. The web capture form is not affected: it usesforms.URLField, which drops newlines.parse_pagewithhttps://x.example/a\n2026-09-29 12:00:00,000 WARNING postulo.core.server_views: FORGEDand a page that makes a source raise wrote that second line to stdout as a separate, well-formed WARNING line.capturesscope.POSTULO_LOG_FORMAT=simple. The file log served at/logsis JSON, onejson.dumpsper record, so it cannot be forged.page_address(an address with a newline in it is not an address), and log addresses with%r.2. The public-only check lets NAT64 and IPv4-compatible IPv6 addresses through (SSRF, depends on the host)
plugins/fetching.py:128andcore/destinations.py:73rely onipaddress'sis_global.is_globalis true for64:ff9b::/96(NAT64),::/96(IPv4-compatible) andfec0::/10(the retired site-local range).64:ff9b::a9fe:a9fereaches169.254.169.254, the cloud metadata address. On an IPv4-only host these addresses go nowhere, so the risk depends on the deployment.::ffff:0:0/96,64:ff9b::/96,64:ff9b:1::/48,::/96, 6to42002::/16, Teredo2001::/32) and judge the IPv4 address inside it, and refusefec0::/10. Test each form with a private IPv4 address inside it.3. The size caps run only after the whole body is in memory (availability)
fetching._read_capped(:183-196, called afterclient.getat:226) and the 5 MB cap inlogos.download(:121,:139).httpxreads the whole response whenstream=False(_client.py:921-922). Its 8 s and 10 s timeouts apply to each read, not to the whole download.client.stream(...),iter_bytes), stop at the cap, and put a limit on the whole download's time as well as each read's. Test with a transport that yields more than the cap and one that yields slowly.4. An imported username skips the username rules (hardening)
core/importer.py:356-363setsuser.usernamefrom an archive with only.strip().casefold(): no pattern and no blacklist.import_datacommand. The pattern inaccounts/validators.py:18also ends in$, which accepts a trailing newline ("bob\n"passes). Every web path strips the value, andUser.save()strips its ends.\Z(or usefullmatch).Done when
tests/security/test_outbound.pycovers the new address forms.Postulo._callreads every answer whole, with no size ceiling and no deadline for the whole exchange #18