Forum teuk.org

🦉 MB692 — Give Channels a Safe Native RSS Owl Post

in Mediabot · started by TeuK · yesterday

TeuK · yesterday

“The owls are not what they seem… but at least these ones validate DNS, preserve UTF-8, and do not flood your IRC channel.” 🪄

A new owl has left the Mediabot tower.

MB692 introduces the foundations of native per-channel RSS/Atom support in Mediabot v3, with a strong focus on security, durability, clean IRC output, and real-world runtime validation.

This was not a “write the code, run the tests, ship it” change.

The feature was exercised on the live DEV instance, against a real public RSS feed, and that live gate caught several issues that static validation alone did not expose. Every one of them was fixed before the commit was allowed to leave the castle.


✨ What MB692 adds

Mediabot now has a dedicated RSS command family:

m rss list [#channel]
m rss info [#channel] <feed>

m rss add [#channel] <feed name> <url> [interval=30] [max=5]
m rss del [#channel] <feed name>
m rss set [#channel] <feed name> <interval|max|enabled> <value>

m rss probe <url>
m rss show [#channel] <feed name>

Feed names may contain spaces.

For example:

m rss add Journal du Geek https://example.org/feed interval=30 max=5

The command parser uses the first http:// or https:// token as the boundary between the human-readable feed name and the URL.

Mutating commands require either:

global Administrator

or:

channel level >= 400

The existing news command remains completely separate and unchanged.


🗄️ Durable RSS storage

MB692 adds two new database tables:

RSS_FEED
RSS_ITEM

RSS_FEED stores per-channel subscriptions and their runtime metadata:

  • feed label
  • URL and URL hash
  • enabled state
  • polling interval
  • maximum announcement count
  • ETag
  • Last-Modified
  • last poll / success / error timestamps
  • error state
  • creator metadata

RSS_ITEM provides durable item identity and future deduplication state:

  • feed reference
  • stable item key
  • title
  • URL
  • publication text
  • first-seen timestamp
  • announced timestamp

The migration is:

install/migrations/20260822_rss_feeds.sql

It was applied to the DEV database, then applied a second time to verify idempotence.

Result:

RSS_FEED : OK
RSS_ITEM : OK

No automatic polling is enabled yet. That remains deliberately reserved for MB693.


🧙 A stricter migration contract

While integrating the RSS migration, an older test still relied on a hard-coded migration count:

18 SQL files

That spell was too fragile.

The migration contract now verifies exact parity between:

install/migrations/*.sql

and the authoritative:

Current migration order

documented in:

install/migrations/README.md

This removes the magic number while making the check stronger.

The public migration documentation was also updated in:

docs/DB_MIGRATIONS.md

🛡️ RSS fetching is SSRF-aware by design

The fetch path is intentionally strict.

Only:

http://
https://

are accepted.

The implementation rejects:

  • credentials embedded in URLs
  • non-web ports
  • localhost
  • loopback addresses
  • private IPv4 ranges
  • link-local destinations
  • reserved destinations
  • IPv6 ULA
  • IPv6 loopback
  • IPv6 link-local
  • IPv6 multicast
  • unsafe IPv4-mapped IPv6 addresses
  • DTD / ENTITY input

DNS is resolved before the HTTP request.

Every resolved address must pass the public-address policy.

Redirects are not delegated blindly to the HTTP library. They are followed manually, with the destination revalidated on every hop.

The redirect limit is:

3

and the response body limit is:

2 MiB

TLS verification remains enabled.

Environment HTTP proxies are disabled for this fetch path.


🔒 Closing the DNS rebinding window

The first implementation validated DNS correctly, but the real live review exposed a subtle remaining issue.

After validating the DNS answer, this:

HTTP::Tiny->get($url)

could still resolve the hostname again during the actual connection.

That opened a small validate-then-resolve-again window.

MB692 now pins the connection to an already validated peer:

peer => $validated_ip

The original hostname is still retained for:

  • HTTP Host
  • TLS SNI
  • certificate validation

No unvalidated peer may be used.

Multiple validated peers are supported, with transport fallback only after an HTTP::Tiny 599 transport failure.

IPv4 peers are preferred when available, which also avoids a dead IPv6 route masking a valid IPv4 path.


🧩 The IPv6 bug we only found because we tested for real

The live test used:

https://korben.info/feed

The hostname correctly resolved to Cloudflare IPv4 and IPv6 addresses.

The IPv4 addresses were classified as public.

The IPv6 addresses were incorrectly rejected.

The culprit was not Cloudflare.

It was a Perl precedence trap around grep.

The original logic effectively relied on expressions shaped like:

!grep { ... } @bytes[...] && ...

The result was not grouped the way the code intended.

The fix now explicitly evaluates the scalar result of grep:

(grep { ... } @bytes[...]) == 0

After the correction:

2606:4700:20::ac43:4873 => PUBLIC
2606:4700:20::681a:35e  => PUBLIC
2606:4700:20::681a:25e  => PUBLIC

::1                     => BLOCKED
::ffff:127.0.0.1        => BLOCKED
::ffff:8.8.8.8          => PUBLIC

Exactly what we wanted.


⚡ Async commands that actually answer

m rss probe and m rss show perform network work and therefore run through Mediabot’s asynchronous command infrastructure.

The first real DEV test produced this clue:

CommandAsync: 'rss probe' completed ... (0 line(s))

The worker had executed.

The response had simply disappeared.

The problem was that CommandAsync captured output through:

Mediabot::Helpers::bot*
Mediabot::UserCommands::bot*

but not the path used by:

Context->reply()

which ultimately calls:

Mediabot::botPrivmsg
Mediabot::botNotice
Mediabot::botAction

MB692 extends the async collector to capture that path as well.

After the fix, the same real command returned correctly on IRC.


🌍 UTF-8: no more cursed runes

The next live test worked functionally, but revealed this:

vérolé
Â
â...

That was not acceptable.

HTTP::Tiny returns response content as bytes, and the RSS parser was consuming those bytes without first decoding the feed charset.

The fix now decodes feed content before parsing using, in order:

  1. UTF-8 / UTF-16 BOM
  2. HTTP Content-Type charset
  3. XML declaration encoding
  4. UTF-8 as the XML default

Windows-1252 is also handled.

Invalid or unknown encodings fail closed instead of silently replacing characters.

The final real feed test produced:

Geekom retire enfin un pilote que Windows Defender signalait comme vérolé depuis 2024

oMLX – Faites tourner vos agents IA en local sur votre Mac

Surfshark lance une protection anti-scam SMS et enterre son moteur de recherche - La vie est faite de choix

No mojibake.

No cursed runes.

Hermione approves. 📚


🎨 IRC output

The display style is inspired by the long-running Eggdrop RSS setup that motivated the feature:

ACTION - news : [Source] Title - URL

For example:

* mediabotv3 - news : [Les news de Korben] Geekom retire enfin un pilote que Windows Defender signalait comme vérolé depuis 2024 - https://...

Feed lists also retain the compact IRC-oriented style rather than turning the bot into a wall of text.


🧪 Live DEV validation

The final DEV runtime gate exercised the real feature, not a mocked substitute.

The live sequence included:

m rss probe https://korben.info/feed
m rss add MB692 Test https://korben.info/feed interval=30 max=3
m rss show MB692 Test
m rss del MB692 Test

This validated:

  • command routing
  • authentication / ACL
  • MariaDB insert
  • MariaDB lookup
  • MariaDB delete
  • asynchronous execution
  • DNS validation
  • pinned HTTP connection
  • TLS
  • real RSS parsing
  • IPv6 handling
  • UTF-8 decoding
  • final IRC formatting

The temporary feed was removed afterward.

The DEV process remained stable.

Undernet was not restarted or modified.


🧪 Regression coverage

MB692 adds:

893_mb692_rss_foundation.t
894_mb692_rss_commands_repository.t
895_mb692_rss_live_async_peer_pin.t
896_mb692_rss_public_ipv6.t
897_mb692_rss_feed_encoding.t

These contracts cover, among other things:

  • RSS / Atom parsing
  • DTD rejection
  • stable item keys
  • URL validation
  • schema and migration presence
  • repository operations
  • ACL behaviour
  • async output replay
  • SSRF protection
  • DNS peer pinning
  • IPv6 classification
  • IPv4-mapped IPv6
  • multi-peer transport fallback
  • UTF-8
  • BOM handling
  • XML charset declarations
  • Windows-1252
  • invalid encoding rejection
  • preservation of the existing news command

✅ Final validation

Before the commit was allowed to fly:

Encoding sentinels : 97/97 PASS
Targeted RSS/news  : 389/389 PASS
Fast lane          : 6049/6049 PASS
Full suite         : 15818/15818 PASS

Full suite:

780/780 files
15818/15818 tests
214 seconds

Runtime state at commit:

DEV PID       : 3714279
DEV NRestarts : 0
Undernet PID  : 3699450

The historical DEV schema drift was also preserved exactly:

31 issue(s)
SHA256:
9d1672072bbc8bf7132c97c8b5510184165a2d9e0f37e4239d5bf113aaddb77c

MB692 did not silently “repair” unrelated historical schema differences.


🦉 The commit

8a94ea4e6b444b9cbe7fd9500cb194454ab2034b

Commit message:

🦉 Give Channels a Safe Native RSS Owl Post

Version:

3.4dev-20260822_180846

The commit was pushed successfully to master.


🔮 What comes next: MB693

MB692 deliberately stops before automatic polling.

The next spell will focus on automation:

MB693

Planned work:

  • native Scheduler integration
  • periodic per-feed polling
  • ETag / Last-Modified
  • silent first-fetch baseline
  • durable deduplication through RSS_ITEM
  • per-feed concurrency protection
  • isolated error handling
  • announcement limits
  • anti-flood / controlled replay
  • runtime observability
  • no historical flood when a feed is first added

The most important rule is already decided:

The first poll must establish a baseline silently.

Adding an existing feed must never dump its entire history into an IRC channel.

Only genuinely new items discovered afterward should be announced.


🪄 Closing words

MB692 ended up being larger than the first sketch suggested, but for a good reason.

A purely test-driven pass would have shipped something that looked green while still containing:

  • a silent async response path
  • a DNS rebinding window
  • a public IPv6 false positive
  • broken UTF-8 display

The real DEV gate caught all four.

That is exactly the kind of trouble worth discovering before the owl reaches production.

Now the owl can fly. 🦉✨

You must be logged in to reply.