FortiGate Hair-pinning - 夜莺博客

FortiGate Hair-pinning

原文:FortiGate Hair-pinning — theDXT (Daniel Keer)

I have been playing around with Policy mode on the FortiGate and an issue that I’ve ran into a few times is if you have something hosted internally that also needs to be accessed externally it doesn’t work internally when you use the external address, for example a reverse proxy.

In my setup I use a reverse proxy in front of my WordPress Docker containers. Due to this they are running on random ports. When I need to access them I need to use the external address not the LAN address. A half workaround that I was doing, was using CloudFlare proxied mode which did work but I wanted to fix it without needing to do that.

This is the classic hairpin — or NAT loopback — problem, and it is one of those faults that looks like a DNS issue, a server issue and a firewall issue all at once depending on which log you read first. The fix is a single policy, but understanding why the packet is dropped in the first place is what stops you from re-introducing the problem the next time you tidy up your rulebase.

What Hair-pinning Actually Is

Hair-pinning (also called NAT loopback, NAT reflection or U-turn traffic) describes a flow that leaves a network and comes straight back to it. A laptop on the guest VLAN resolves www.example.com, gets the public WAN address of your reverse proxy, and sends the packet to that public address. The FortiGate receives it, applies Destination NAT from the VIP's external IP to the internal server, and forwards it back down the LAN interface the client came from.

Without NAT, this traffic would simply route out to the internet and never return to your own site, so a DNAT rule is mandatory. The complication is that the FortiGate has to accept a flow whose source and destination are both inside the corporate estate, and it has to do so on an interface that a policy might reasonably be expected to block.

Why a Policy-Mode FortiGate Drops It

Virtual IPs (VIPs) change the order in which the FortiGate evaluates things. When a packet arrives for a VIP, DNAT is applied before the firewall policy lookup, so by the time policies are evaluated the destination is no longer the public address you see in your DNS record — it is the internal mapped address. A policy that says “guest to LAN is denied”, or even one that says “guest to any is allowed”, will not match the translated flow the way you expect.

There is one more layer: FortiOS has a match-vip setting on a policy. Historically disabled by default, it was changed to enabled by default from v7.2.3 onward. Whether the DNAT translation is matched against the policy's destination address determines which rule catches your packet, and that single setting explains why a fix copied from a 2021 forum post sometimes does nothing on a current build.

Reading the Deny Logs

I was looking at my deny logs in my FortiGate I noticed that my guest network was trying to talk to one of the websites that is hosted internally, however I noticed that the destination was the WAN IP but it also had the NAT IP and it showed that the destination Interface was my LAN not my WAN.

Image 2

That single log line tells the whole story. The destination address in the header is the public WAN IP, the NAT column shows the translated internal address, and the destination interface is the LAN. In other words the FortiGate did exactly the right thing with the translation, and then dropped the packet because no policy permitted a flow from Guest to LAN.

If you want to dig into the deny log format itself, FortiGate Deny Logs walks through the fields in detail, and the CLI equivalents are collected in FortiGate FortiOS CLI Troubleshooting: Cheat Sheet.

I realized that the rule I was missing in my FortiGate was a Hair Pin rule. Which makes it so that when it goes to leave the FortiGate externally it comes back but it comes back on your LAN interface so it shows up as my guest network is trying to talk to my LAN.

Prerequisites

  • A FortiGate running in policy mode. If your device is in profile mode, the same rules apply but several settings live in different places; see FortiGate Policy Mode vs Profile Mode and FotiGate Enable Policy Mode.
  • An existing VIP (port forwarding rule) for the internal service, with the mapped IP pointing at the real server or reverse proxy.
  • The public IP address that internal clients are resolving to.
  • Logging enabled on the relevant policies so you can confirm your fix rather than assume it.
  • A maintenance window if the service is in production — you will be adding and possibly reordering policies.

Option 1: The Minimal Hairpin Policy

To correct this I made a rule that allows traffic from my Guest network to the NAT IP on my LAN to be allowed and boom problem solved. The cleanest way to express that is with the VIP as the destination address in an internal-to-internal policy. Set the VIP's external interface to any so the translation is not tied to a single WAN interface, then allow the internal source to reach the VIP object.

config firewall vip
    edit "Web-Public-VIP"
        set extip 203.0.113.10
        set extintf "any"
        set mappedip "10.10.10.10"
    next
end
config firewall policy
    edit 0
        set name "Guest-to-Internal-Web-Hairpin"
        set srcintf "Guest"
        set dstintf "Internal"
        set srcaddr "Guest-Subnet"
        set dstaddr "Web-Public-VIP"
        set action accept
        set schedule "always"
        set service "HTTP" "HTTPS"
        set nat enable
        set logtraffic all
    next
end

Two details in that policy are easy to miss and both matter. NAT enable is what makes the return path work: the server sees the FortiGate's internal interface address as the source rather than the guest client's address, so its replies come back through the firewall instead of being sent straight to the client with a source address the client never asked for. Without it you get an asymmetric flow and a working TCP handshake that goes nowhere — a particularly annoying failure to debug from the client side. Logging gives you the evidence that the flow is now hitting the hairpin rule and not the implicit deny.

Option 2: Using match-vip Instead

The alternative, and the one that scales better when you have several services, is a single policy with the destination set to all and match-vip enabled. This lets one rule cover every VIP on the platform rather than one rule per service.

config firewall policy
    edit 0
        set name "Internal-to-VIP-Hairpin"
        set srcintf "Guest"
        set dstintf "Internal"
        set srcaddr "Guest-Subnet"
        set dstaddr "all"
        set action accept
        set schedule "always"
        set service "HTTP" "HTTPS"
        set nat enable
        set match-vip enable
    next
end

Even though the packet is destined to an external address, it is never forwarded to the Internet — the translation happens locally and the traffic is delivered on the internal interface. Note the version dependency: on FortiOS v7.2.3 and later match-vip defaults to enabled, so a policy with destination all may already be catching VIP traffic you did not intend it to catch. If you have a deny policy that is supposed to block a VIP, check this setting before assuming the rule is broken.

Option 3: Split-Horizon DNS (The Boring Answer)

Hair-pinning works, but it is a fix for a DNS design problem. The reason the guest laptop is going to the public address at all is that the internal DNS resolver returns the public record for an internal service. A split-horizon (or split-brain) DNS setup that answers with the internal address for internal clients removes the hairpin entirely: the client talks directly to the reverse proxy on the LAN, no DNAT is involved, no extra policy is needed, and no firewall resource is consumed by looping traffic.

The trade-off is honest: split DNS means maintaining two views of the same zone, and it breaks the moment a client is using a resolver you do not control — which is exactly the situation with a guest network and its own or a public resolver. In that case hair-pinning is the only option, which is why the policy above is worth having even if you also fix DNS. If you want to enforce which resolver guests use, that is a separate policy decision; Policy Based Forwarding covers steering traffic by source, and Cisco IOS NAT: Static NAT, PAT Overload and Port Forward is a useful cross-vendor reference for the translation side.

Verifying the Fix

Do not rely on the browser. Confirm the flow with packet-level tools.

diagnose debug reset
diagnose debug disable
diagnose debug flow filter addr 203.0.113.10
diagnose debug flow filter port 443
diagnose debug flow trace start 20
diagnose debug enable

The trace output should show the packet being received on the Guest interface, matched by your hairpin policy, translated by the VIP, and forwarded out the Internal interface. When you are finished, always turn the debug off so you do not leave a busy firewall writing trace output:

diagnose debug disable
diagnose debug reset

A sniffer gives you the complementary view — you should see the packet twice, once with the public destination and once with the internal destination:

diagnose sniffer packet any 'host 203.0.113.10 and port 443' 4 0 l

Finally, check Log & Report > Forward Traffic for the new session and confirm that it is the hairpin policy, with the correct source interface, destination interface and translated address, that accepted it.

Rolling Back

Every element of this change is a discrete object, which makes a rollback trivial and safe.

config firewall policy
    delete <policy-id>
end

config firewall vip
    delete "Web-Public-VIP"
end

If you reordered existing policies to place the hairpin rule above a deny rule, remember to restore the original order — an accidental reorder is the most common way a hairpin change turns into an unrelated outage. Keep a copy of the policy table before you begin, and for anything beyond a couple of rules consider a proper configuration backup so the rollback is a restore rather than a manual edit.

FAQ

Do I need a second policy for the return traffic? No. FortiGate session state handles both directions of the flow once the initial packet matches an accept policy; a common mistake is creating a mirrored rule that then conflicts with the first one.

Why do the logs show the public IP as destination when policies see the private one? Because DNAT is applied before the policy lookup. The log preserves the original header information for the session while the policy engine evaluates the translated address.

Should I use NAT enable? Yes whenever the internal server and the internal client are in the same routing domain, which is the normal hairpin case. It forces the reply path back through the FortiGate so the un-translation happens correctly.

Is hair-pinning expensive? It doubles the firewall's work for those sessions — every hairpinned connection is inspected twice on the same device — so avoid using it as the default access method for large internal populations and prefer split DNS where you control resolution.

You can read more about FortiGate Hair-pinning here https://docs.fortinet.com/document/fortigate/5.4.0/cookbook/856642/configuring-hair-pinning-on-a-fortigate