Nftables: Support xt_MASQUERADE compatibility target.

Added:
- `xt_MASQUERADE` target extension.
    Masq can dynamically rewrites the packet's source IP
    to match the outgoing interface's primary IP.
- Masq Revision 0 ABI.
- Unit and syscall tests.

Ref: net/netfilter/xt_masquerade.c
PiperOrigin-RevId: 965062213
This commit is contained in:
Parth Sarthi
2026-08-14 22:38:12 -07:00
committed by gVisor bot
parent f922aed8f2
commit 2000f9cdfe
6 changed files with 547 additions and 2 deletions
+1
View File
@@ -29,6 +29,7 @@ go_library(
"nft_route.go",
"nft_xt_addrtype.go",
"nft_xt_conntrack.go",
"nft_xt_masq.go",
"nft_xt_match.go",
"nft_xt_target.go",
"nftables.go",
+222
View File
@@ -0,0 +1,222 @@
// Copyright 2026 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// This file implements the "MASQUERADE" XTables compatibility target operation.
// Ref: net/netfilter/xt_masquerade.c
package nftables
import (
"fmt"
"math"
"gvisor.dev/gvisor/pkg/abi/linux"
"gvisor.dev/gvisor/pkg/marshal/primitive"
"gvisor.dev/gvisor/pkg/sentry/socket/netlink/nlmsg"
"gvisor.dev/gvisor/pkg/syserr"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
// masqTargetInfo stores precomputed port range parameters for masq target.
// Ref: net/netfilter/xt_masquerade.c
type masqTargetInfo struct {
// netProto is the expected network protocol number (IPv4 or IPv6).
netProto tcpip.NetworkProtocolNumber
// hasPortRange indicates if a custom port range was specified.
hasPortRange bool
// portsOrIdents specifies the port range to map.
portsOrIdents stack.PortOrIdentRange
}
// compatMASQTarget implements the xtables MASQUERADE target extension.
type compatMASQTarget struct {
// revision is the XTables MASQUERADE target extension revision.
revision uint32
// infoData is the raw netlink attribute payload; saved for dump.
infoData []byte
// info contains the port range parameters.
info masqTargetInfo
}
// evaluate implements operation.evaluate.
// Ref: net/netfilter/xt_masquerade.c:masquerade_tg()
func (op *compatMASQTarget) evaluate(regs *registerSet, evalCtx opEvalCtx) {
opInfo := &op.info
if evalCtx.pkt.NetworkProtocolNumber != opInfo.netProto {
return
}
pkt := evalCtx.pkt
portsOrIdents := stack.PortOrIdentRange{Start: 0, Size: 1}
// Determine the port range to masquerade.
if opInfo.hasPortRange {
portsOrIdents = opInfo.portsOrIdents
} else {
switch pkt.TransportProtocolNumber {
case header.UDPProtocolNumber:
if len(pkt.TransportHeader().Slice()) >= header.UDPMinimumSize {
portsOrIdents = targetPortRangeForTCPAndUDP(header.UDP(pkt.TransportHeader().Slice()).SourcePort())
}
case header.TCPProtocolNumber:
if len(pkt.TransportHeader().Slice()) >= header.TCPMinimumSize {
portsOrIdents = targetPortRangeForTCPAndUDP(header.TCP(pkt.TransportHeader().Slice()).SourcePort())
}
case header.ICMPv4ProtocolNumber, header.ICMPv6ProtocolNumber:
portsOrIdents = stack.PortOrIdentRange{Start: 0, Size: math.MaxUint16 + 1}
default:
portsOrIdents = stack.PortOrIdentRange{Start: 0, Size: 1}
}
}
// Configure NAT Masq.
if !pkt.ConfigureMasquerade(portsOrIdents, evalCtx.route, evalCtx.nftState.stack, true /* changePort */) {
regs.verdict.Code = VC(linux.NF_DROP)
return
}
regs.verdict = Verdict{Code: VC(linux.NF_ACCEPT)}
}
// checkCompatibility implements operation.checkCompatibility.
// Ref: net/netfilter/x_tables.c:xt_check_target
func (op *compatMASQTarget) checkCompatibility(cCtx *opCompatCtx) *syserr.AnnotatedError {
chain := cCtx.chain
if !chain.IsBaseChain() {
return nil
}
if chain.baseChainInfo.Hook != stack.NFPostrouting {
return syserr.NewAnnotatedError(syserr.ErrInvalidArgument, "masquerade target expression is only valid in postrouting hook")
}
if chain.baseChainInfo.BcType != BaseChainTypeNat {
return syserr.NewAnnotatedError(syserr.ErrInvalidArgument, "masquerade target expression is only valid in NAT chains")
}
return nil
}
// deepCopy implements operation.deepCopy.
func (op *compatMASQTarget) deepCopy() operation {
opCopy := *op
opCopy.infoData = append([]byte(nil), op.infoData...)
return &opCopy
}
// updateReferences implements operation.updateReferences.
func (op *compatMASQTarget) updateReferences(table *Table, sourceTable *Table, sourceOp operation) {}
// destroy implements operation.destroy.
func (op *compatMASQTarget) destroy() {}
// GetExprName implements operation.GetExprName.
func (op *compatMASQTarget) GetExprName() string {
return OpTypeTarget.String()
}
// Dump implements operation.Dump.
// Ref: net/netfilter/nft_compat.c:nft_target_dump()
func (op *compatMASQTarget) Dump() ([]byte, *syserr.AnnotatedError) {
m := &nlmsg.Message{}
m.PutAttrString(linux.NFTA_TARGET_NAME, TargetMASQUERADE)
m.PutAttr(linux.NFTA_TARGET_REV, nlmsg.PutU32(op.revision))
if len(op.infoData) > 0 {
m.PutAttr(linux.NFTA_TARGET_INFO, primitive.AsByteSlice(op.infoData))
}
return m.Buffer(), nil
}
func (info *masqTargetInfo) init(netProto tcpip.NetworkProtocolNumber, flags uint32, minProto, maxProto uint16) *syserr.AnnotatedError {
if flags&linux.NF_NAT_RANGE_MAP_IPS != 0 {
return syserr.NewAnnotatedError(syserr.ErrInvalidArgument, "masquerade does not support MAP_IPS")
}
info.netProto = netProto
info.portsOrIdents = stack.PortOrIdentRange{Start: 0, Size: 1}
if flags&linux.NF_NAT_RANGE_PROTO_SPECIFIED != 0 {
minPort := nlmsg.NetToHostU16(minProto)
maxPort := nlmsg.NetToHostU16(maxProto)
if minPort > maxPort {
return syserr.NewAnnotatedError(syserr.ErrInvalidArgument, fmt.Sprintf("invalid port range: min (%d) > max (%d)", minPort, maxPort))
}
info.hasPortRange = true
info.portsOrIdents = stack.PortOrIdentRange{
Start: minPort,
Size: uint32(maxPort) - uint32(minPort) + 1,
}
}
return nil
}
// unmarshalIPv4 parses nf_nat_ipv4_multi_range_compat and fills masqTargetInfo.
func (info *masqTargetInfo) unmarshalIPv4(infoData []byte) *syserr.AnnotatedError {
var mr linux.NfNATIPV4MultiRangeCompat
if len(infoData) < mr.SizeBytes() {
return syserr.NewAnnotatedError(syserr.ErrInvalidArgument, "masquerade info too small for IPv4")
}
mr.UnmarshalBytes(infoData[:mr.SizeBytes()])
if mr.RangeSize != 1 {
return syserr.NewAnnotatedError(syserr.ErrInvalidArgument, "masquerade requires rangesize to be 1")
}
return info.init(header.IPv4ProtocolNumber, mr.RangeIPV4.Flags, mr.RangeIPV4.MinPort, mr.RangeIPV4.MaxPort)
}
// unmarshalIPv6 parses nf_nat_range and fills masqTargetInfo.
func (info *masqTargetInfo) unmarshalIPv6(infoData []byte) *syserr.AnnotatedError {
var r linux.NFNATRange
if len(infoData) < r.SizeBytes() {
return syserr.NewAnnotatedError(syserr.ErrInvalidArgument, "masquerade info too small for IPv6")
}
r.UnmarshalBytes(infoData[:r.SizeBytes()])
return info.init(header.IPv6ProtocolNumber, r.Flags, r.MinProto, r.MaxProto)
}
const (
masqSupportedRevision = 0
)
// lookupMasqRevision validates if a masquerade target revision is supported
// and returns the best revision.
func lookupMasqRevision(rev uint32, family stack.AddressFamily) (uint32, *syserr.AnnotatedError) {
if family != stack.IP && family != stack.IP6 {
return 0, syserr.NewAnnotatedError(syserr.ErrNoSuchFile, fmt.Sprintf("masquerade target not supported for family %v", family))
}
if rev != masqSupportedRevision {
return 0, syserr.NewAnnotatedError(syserr.ErrNotSupported, fmt.Sprintf("masquerade revision %d not supported", rev))
}
return masqSupportedRevision, nil
}
// parseMasqTarget converts MASQUERADE target data into info data.
// Ref: net/netfilter/xt_masquerade.c:masquerade_tg_check()
func parseMasqTarget(tab *Table, rev uint32, infoData []byte) (*masqTargetInfo, *syserr.AnnotatedError) {
if tab.name != TableNameNAT {
return nil, syserr.NewAnnotatedError(syserr.ErrInvalidArgument, "masquerade only valid in nat table")
}
if rev != masqSupportedRevision {
return nil, syserr.NewAnnotatedError(syserr.ErrNoSuchFile, fmt.Sprintf("masquerade revision %d not supported", rev))
}
info := &masqTargetInfo{}
var err *syserr.AnnotatedError
switch tab.GetAddressFamily() {
case stack.IP:
err = info.unmarshalIPv4(infoData)
case stack.IP6:
err = info.unmarshalIPv6(infoData)
default:
return nil, syserr.NewAnnotatedError(syserr.ErrNoSuchFile, fmt.Sprintf("masquerade target unsupported address family: %v", tab.GetAddressFamily()))
}
if err != nil {
return nil, err
}
return info, nil
}
+9 -2
View File
@@ -32,6 +32,8 @@ import (
// and returns the best revision.
func lookupCompatTargetRevision(name string, rev uint32, family stack.AddressFamily) (uint32, *syserr.AnnotatedError) {
switch name {
case TargetMASQUERADE:
return lookupMasqRevision(rev, family)
default:
return 0, syserr.NewAnnotatedError(syserr.ErrNoSuchFile, fmt.Sprintf("target extension %s not supported", name))
}
@@ -71,10 +73,15 @@ func initTarget(tab *Table, exprInfo ExprInfo) (operation, *syserr.AnnotatedErro
if !ok {
return nil, syserr.NewAnnotatedError(syserr.ErrInvalidArgument, "NFTA_TARGET_INFO attribute is not found")
}
_ = rev
_ = infoAttr
infoData := []byte(infoAttr)
switch name {
case TargetMASQUERADE:
info, err := parseMasqTarget(tab, rev, infoData)
if err != nil {
return nil, err
}
return &compatMASQTarget{revision: rev, infoData: infoData, info: *info}, nil
default:
return nil, syserr.NewAnnotatedError(syserr.ErrNoSuchFile, fmt.Sprintf("target extension %s not supported", name))
}
+56
View File
@@ -5737,6 +5737,22 @@ func TestCompatOperationDeepCopy(t *testing.T) {
},
opts: cmp.AllowUnexported(compatAddrtypeMatch{}, addrTypeMatchInfo{}),
},
{
name: "masquerade target",
op: &compatMASQTarget{
revision: 0,
info: masqTargetInfo{
netProto: header.IPv4ProtocolNumber,
hasPortRange: true,
portsOrIdents: stack.PortOrIdentRange{
Start: 1024,
Size: 100,
},
},
infoData: []byte{1, 2, 3},
},
opts: cmp.AllowUnexported(compatMASQTarget{}, masqTargetInfo{}),
},
{
name: "noop match",
op: &compatNoopMatch{
@@ -5760,6 +5776,7 @@ func TestCompatOperationDeepCopy(t *testing.T) {
func TestCompatOpCompatibility(t *testing.T) {
addrtypeIn := &compatAddrtypeMatch{info: addrTypeMatchInfo{limitIfaceIn: true}}
addrtypeOut := &compatAddrtypeMatch{info: addrTypeMatchInfo{limitIfaceOut: true}}
masq := &compatMASQTarget{}
noop := &compatNoopMatch{name: "tcp"}
const nonBaseChainType = BaseChainType(-1)
@@ -5808,6 +5825,25 @@ func TestCompatOpCompatibility(t *testing.T) {
cCtx: makeCtx(BaseChainTypeFilter, stack.NFPrerouting),
wantErr: true,
},
// MASQUERADE
{
name: "MASQUERADE valid on POSTROUTING NAT",
op: masq,
cCtx: makeCtx(BaseChainTypeNat, stack.NFPostrouting),
},
{
name: "MASQUERADE invalid on PREROUTING NAT",
op: masq,
cCtx: makeCtx(BaseChainTypeNat, stack.NFPrerouting),
wantErr: true,
},
{
name: "MASQUERADE invalid on Filter chain",
op: masq,
cCtx: makeCtx(BaseChainTypeFilter, stack.NFPostrouting),
wantErr: true,
},
{
name: "noop match",
op: noop,
@@ -6177,6 +6213,26 @@ func TestCompatAddrtypeMatchEvaluation(t *testing.T) {
}
}
// TestCompatMASQTargetEvaluation tests evaluation of compatMASQTarget,
// verifying that packets whose network protocol doesn't match are passed through.
func TestCompatMASQTargetEvaluation(t *testing.T) {
t.Run("protocol mismatch skips masquerade", func(t *testing.T) {
op := &compatMASQTarget{
info: masqTargetInfo{
netProto: header.IPv6ProtocolNumber,
},
}
regs := &registerSet{verdict: Verdict{Code: VC(linux.NFT_CONTINUE)}}
pkt := makeArbitraryIPv4Packet()
defer pkt.DecRef()
op.evaluate(regs, opEvalCtx{pkt: pkt})
if regs.verdict.Code != VC(linux.NFT_CONTINUE) {
t.Errorf("expected verdict to remain NFT_CONTINUE on protocol mismatch, got %d", regs.verdict.Code)
}
})
}
// Tests that pass-through no-op compat matches (like tcp/udp) return NFT_CONTINUE.
func TestCompatNoopMatchEvaluation(t *testing.T) {
op := &compatNoopMatch{
+1
View File
@@ -765,6 +765,7 @@ var (
// xtables operations
_ operation = (*compatAddrtypeMatch)(nil)
_ operation = (*compatCTMatch)(nil)
_ operation = (*compatMASQTarget)(nil)
_ operation = (*compatNoopMatch)(nil)
)
@@ -6051,6 +6051,227 @@ INSTANTIATE_TEST_SUITE_P(
return info.param.test_name;
});
INSTANTIATE_TEST_SUITE_P(
CompatMasqTests, NetlinkNetfilterCompatTest,
::testing::Values(
CompatTestParam{
.test_name = "IPv4_NoFlags",
.family = NFPROTO_IPV4,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_ipv4_multi_range_compat{
.rangesize = 1,
.range = {{.flags = 0}},
}),
},
},
CompatTestParam{
.test_name = "IPv4_WithPortRange",
.family = NFPROTO_IPV4,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_ipv4_multi_range_compat{
.rangesize = 1,
.range = {{
.flags = NF_NAT_RANGE_PROTO_SPECIFIED,
.min = {.all = htons(1024)},
.max = {.all = htons(2048)},
}},
}),
},
},
CompatTestParam{
.test_name = "IPv6_NoFlags",
.family = NFPROTO_IPV6,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_range{
.flags = 0,
}),
},
},
CompatTestParam{
.test_name = "IPv6_WithPortRange",
.family = NFPROTO_IPV6,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_range{
.flags = NF_NAT_RANGE_PROTO_SPECIFIED,
.min_proto = {.all = htons(1024)},
.max_proto = {.all = htons(2048)},
}),
},
},
CompatTestParam{
.test_name = "IPv4_SizeTooSmall",
.family = NFPROTO_IPV4,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = std::vector<char>(
sizeof(nf_nat_ipv4_multi_range_compat) - 1, 0),
},
.expected_error = EINVAL,
},
CompatTestParam{
.test_name = "IPv6_SizeTooSmall",
.family = NFPROTO_IPV6,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = std::vector<char>(sizeof(nf_nat_range) - 1, 0),
},
.expected_error = EINVAL,
},
CompatTestParam{
.test_name = "InvalidTable",
.family = NFPROTO_IPV4,
.table_name = "filter_table",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_ipv4_multi_range_compat{
.rangesize = 1,
}),
},
.expected_error = EINVAL,
},
CompatTestParam{
.test_name = "UnsupportedRevision",
.family = NFPROTO_IPV4,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 1,
.info_data = StructToBytes(nf_nat_ipv4_multi_range_compat{
.rangesize = 1,
}),
},
.expected_error = ENOENT,
},
CompatTestParam{
.test_name = "InvalidRangeSize",
.family = NFPROTO_IPV4,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_ipv4_multi_range_compat{
.rangesize = 2,
}),
},
.expected_error = EINVAL,
},
CompatTestParam{
.test_name = "InvalidHookPrerouting",
.family = NFPROTO_IPV4,
.table_name = "nat",
.hook = NF_INET_PRE_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_ipv4_multi_range_compat{
.rangesize = 1,
}),
},
.expected_error = EINVAL,
},
CompatTestParam{
.test_name = "NonNatChainType",
.family = NFPROTO_IPV4,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "filter",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_ipv4_multi_range_compat{
.rangesize = 1,
}),
},
.expected_error = EINVAL,
},
CompatTestParam{
.test_name = "MapIPsNotSupported",
.family = NFPROTO_IPV4,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_ipv4_multi_range_compat{
.rangesize = 1,
.range = {{.flags = NF_NAT_RANGE_MAP_IPS}},
}),
},
.expected_error = EINVAL,
},
CompatTestParam{
.test_name = "ValidPortRangeZero",
.family = NFPROTO_IPV4,
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.target_opts =
CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_ipv4_multi_range_compat{
.rangesize = 1,
.range = {{
.flags = NF_NAT_RANGE_PROTO_SPECIFIED,
.min = {.all = htons(0)},
.max = {.all = htons(100)},
}},
}),
},
}),
[](const ::testing::TestParamInfo<CompatTestParam>& info) {
return info.param.test_name;
});
INSTANTIATE_TEST_SUITE_P(
CompatNoopMatchTests, NetlinkNetfilterCompatTest,
::testing::Values(
@@ -6197,6 +6418,43 @@ INSTANTIATE_TEST_SUITE_P(
}),
}},
.expected_expr_names = {"match"},
},
CompatRuleDumpTestParam{
.test_name = "ConntrackMatch",
.table_name = "filter",
.hook = NF_INET_PRE_ROUTING,
.chain_type = "filter",
.matches = {CompatMatchOptions{
.name = "conntrack",
.rev = 1,
.info_data = StructToBytes(xt_conntrack_mtinfo1{
.match_flags = XT_CONNTRACK_STATE,
.state_mask = XT_CONNTRACK_STATE_INVALID,
}),
}},
.expected_expr_names = {"match"},
},
CompatRuleDumpTestParam{
.test_name = "MatchAndTarget",
.table_name = "nat",
.hook = NF_INET_POST_ROUTING,
.chain_type = "nat",
.matches = {CompatMatchOptions{
.name = "conntrack",
.rev = 1,
.info_data = StructToBytes(xt_conntrack_mtinfo1{
.match_flags = XT_CONNTRACK_STATE,
.state_mask = XT_CONNTRACK_STATE_INVALID,
}),
}},
.targets = {CompatTargetOptions{
.name = "MASQUERADE",
.rev = 0,
.info_data = StructToBytes(nf_nat_ipv4_multi_range_compat{
.rangesize = 1,
}),
}},
.expected_expr_names = {"match", "target"},
}),
[](const ::testing::TestParamInfo<CompatRuleDumpTestParam>& info) {
return info.param.test_name;