Since 1.1.2.1 is an other IP address belonging to the same system, there is no forwarding involved. The system received a packet and handled it for itself, as an incoming packet for local delivery rather than a packet to be routed to a 3rd party. This goes along with the general idea that on Linux IPs should be considered as part of a pool rather than belonging to an interface.
To prevent such packet to be accepted using iptables, you have to filter it in the INPUT chain rather than the FORWARD chain. If the two networks involved are /24, this would be for example:
iptables -I INPUT -s 1.1.1.0/24 -d 1.1.2.0/24 -j DROP
As this would also prevent the system to connect to itself in some cases (because this example doesn't use interface names in the rule), it should probably at least have this rule prepended to it:
iptables -I INPUT -i lo -j ACCEPT
Stating the interface name is sometimes helpful to organize rules and for security in some corner cases (not as often as thought, considering reverse path filtering is activated by default on most distributions).
UPDATE: as mentioned by OP, of course this example rule can be better written by using -s 1.1.1.0/24 -d ! 1.1.1.0/24 to only have one rule per LAN, rather than LAN^2 rules if all LANs should be "isolated" likewise.
UPDATE: as further requested, here's a much more elegant method using nftables with a fib (forwarding information base) expression (even documented as example in the man page): drop a destination address that is not local (nor broadcast nor multicast) to the interface where it was received. Since this expression doesn't require to state explicitly any IP (nor any interface), it can even work both for IPv4 and IPv6 at the same time, by using an inet address family table rather than an ip address family table.
# nft add table inet filter
# nft add chain inet filter prerouting '{ type filter hook prerouting priority -150; policy accept; }
# nft add rule inet filter prerouting fib daddr . iif type != { local, broadcast, multicast } counter drop
This alone will handle any number of LANs/interfaces (as long as you don't play and put multiple LANs on the same interface, which would probably defeat the rule above between those multiple LANs). counter is optional, just to see hits using for example nft list ruleset.
However, this rule, as is, allows DNAT to an other local IP defined on an other interface since it happens before any DNAT (with iptables it's registered at priority NF_IP_PRI_NAT_DST (=-100)) but at the same time prevents routing/forwarding (if OP chose to enable it back) or forwarding DNAT: either they are dropped because not local (for normal forwarding), or the return packets will hit this rule before "reverse DNAT" happens and will be dropped . If added in the hook input chain instead, the reverse will happen: no local DNAT to an other IP allowed but routed/forwarded DNAT works because it goes through the forward hook rather than input. This would be more useful, and for other rare cases, exceptions can be added. So in the end this should be used instead of above:
# nft add table inet filter
# nft add chain inet filter input '{ type filter hook input priority 0; policy accept; }
# nft add rule inet filter input fib daddr . iif type != { local, broadcast, multicast } counter drop
Also iptables and nftables can coexist peacefully (with the exception of nat where only one should attempt to register, not both). So these few nftables lines can play along any existing iptables rules.
The rule above can be written in script format (mostly output of nft list ruleset) instead for initialization at boot, like:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy accept;
fib daddr . iif type != { local, broadcast, multicast } counter drop
}
}
To be nitpicking, the only thing to check is when using the newer iptables over nftables API, which is for example default on Debian buster, flush ruleset would also flush iptables rules since they're actually stored using nftables, and since for now you can't instead flush only a non-existing chain "in advance" without an error. So this script should be run before any iptables script if this is the case.
Also note that a client in the 1.1.1.0/24 network while not able to connect to 1.1.2.1 is still able to infer its existance using ARP. Something like:
arping -I eth0 1.1.2.1
would get replies from 1.1.2.1 with the system's interface on the 1.1.1.0/24 network, even if 1.1.2.1 "belongs" to an other interface in an other network, with a different MAC address.
That's because, again, Linux by default, for better or for worse, will answer ARP requests received on any of its interfaces for any of its IPs. Details described this time there: arp_filter.
To avoid this, either mingle with arp_filter + ip rule or arp_announce + arp_ignore, or use arptables (or nftables with arp family) to filter ARP requests (might require one of the previous settings anyway).