Skip to content

Commit

Permalink
feat: Use CIDR format for connection-manager allow/deny lists (#2783)
Browse files Browse the repository at this point in the history
Updates the connection manager to treat multiaddrs in the allow/deny lists using the
standard IP CIDR format (e.g. `/ip4/52.55.0.0/ipcidr/16`) rather than string prefixes
(e.g. `/ip4/52.55`). This allows us to validate multiaddrs accurately and ensures
better control over IP address matching.

---------

Co-authored-by: Alex Potsides <alex@achingbrain.net>
  • Loading branch information
acul71 and achingbrain authored Dec 9, 2024
1 parent 9665411 commit 48e9cfa
Show file tree
Hide file tree
Showing 7 changed files with 318 additions and 17 deletions.
1 change: 1 addition & 0 deletions packages/libp2p/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
},
"dependencies": {
"@chainsafe/is-ip": "^2.0.2",
"@chainsafe/netmask": "^2.0.0",
"@libp2p/crypto": "^5.0.7",
"@libp2p/interface": "^2.2.1",
"@libp2p/interface-internal": "^2.1.1",
Expand Down
10 changes: 6 additions & 4 deletions packages/libp2p/src/connection-manager/connection-pruner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { PeerMap } from '@libp2p/peer-collections'
import { safelyCloseConnectionIfUnused } from '@libp2p/utils/close'
import { MAX_CONNECTIONS } from './constants.js'
import { multiaddrToIpNet } from './utils.js'
import type { IpNet } from '@chainsafe/netmask'
import type { Libp2pEvents, Logger, ComponentLogger, TypedEventTarget, PeerStore, Connection } from '@libp2p/interface'
import type { ConnectionManager } from '@libp2p/interface-internal'
import type { Multiaddr } from '@multiformats/multiaddr'
Expand Down Expand Up @@ -29,13 +31,13 @@ export class ConnectionPruner {
private readonly maxConnections: number
private readonly connectionManager: ConnectionManager
private readonly peerStore: PeerStore
private readonly allow: Multiaddr[]
private readonly allow: IpNet[]
private readonly events: TypedEventTarget<Libp2pEvents>
private readonly log: Logger

constructor (components: ConnectionPrunerComponents, init: ConnectionPrunerInit = {}) {
this.maxConnections = init.maxConnections ?? defaultOptions.maxConnections
this.allow = init.allow ?? defaultOptions.allow
this.allow = (init.allow ?? []).map(ma => multiaddrToIpNet(ma))
this.connectionManager = components.connectionManager
this.peerStore = components.peerStore
this.events = components.events
Expand Down Expand Up @@ -107,8 +109,8 @@ export class ConnectionPruner {
for (const connection of sortedConnections) {
this.log('too many connections open - closing a connection to %p', connection.remotePeer)
// check allow list
const connectionInAllowList = this.allow.some((ma) => {
return connection.remoteAddr.toString().startsWith(ma.toString())
const connectionInAllowList = this.allow.some((ipNet) => {
return ipNet.contains(connection.remoteAddr.nodeAddress().address)
})

// Connections in the allow list should be excluded from pruning
Expand Down
18 changes: 10 additions & 8 deletions packages/libp2p/src/connection-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { ConnectionPruner } from './connection-pruner.js'
import { DIAL_TIMEOUT, INBOUND_CONNECTION_THRESHOLD, MAX_CONNECTIONS, MAX_DIAL_QUEUE_LENGTH, MAX_INCOMING_PENDING_CONNECTIONS, MAX_PARALLEL_DIALS, MAX_PEER_ADDRS_TO_DIAL } from './constants.js'
import { DialQueue } from './dial-queue.js'
import { ReconnectQueue } from './reconnect-queue.js'
import { multiaddrToIpNet } from './utils.js'
import type { IpNet } from '@chainsafe/netmask'
import type { PendingDial, AddressSorter, Libp2pEvents, AbortOptions, ComponentLogger, Logger, Connection, MultiaddrConnection, ConnectionGater, TypedEventTarget, Metrics, PeerId, PeerStore, Startable, PendingDialStatus, PeerRouting, IsDialableOptions } from '@libp2p/interface'
import type { ConnectionManager, OpenConnectionOptions, TransportManager } from '@libp2p/interface-internal'
import type { JobStatus } from '@libp2p/utils/queue'
Expand Down Expand Up @@ -176,8 +178,8 @@ export interface DefaultConnectionManagerComponents {
export class DefaultConnectionManager implements ConnectionManager, Startable {
private started: boolean
private readonly connections: PeerMap<Connection[]>
private readonly allow: Multiaddr[]
private readonly deny: Multiaddr[]
private readonly allow: IpNet[]
private readonly deny: IpNet[]
private readonly maxIncomingPendingConnections: number
private incomingPendingConnections: number
private outboundPendingConnections: number
Expand Down Expand Up @@ -216,8 +218,8 @@ export class DefaultConnectionManager implements ConnectionManager, Startable {
this.onDisconnect = this.onDisconnect.bind(this)

// allow/deny lists
this.allow = (init.allow ?? []).map(ma => multiaddr(ma))
this.deny = (init.deny ?? []).map(ma => multiaddr(ma))
this.allow = (init.allow ?? []).map(str => multiaddrToIpNet(str))
this.deny = (init.deny ?? []).map(str => multiaddrToIpNet(str))

this.incomingPendingConnections = 0
this.maxIncomingPendingConnections = init.maxIncomingPendingConnections ?? defaultOptions.maxIncomingPendingConnections
Expand All @@ -237,7 +239,7 @@ export class DefaultConnectionManager implements ConnectionManager, Startable {
logger: components.logger
}, {
maxConnections: this.maxConnections,
allow: this.allow
allow: init.allow?.map(a => multiaddr(a))
})

this.dialQueue = new DialQueue(components, {
Expand Down Expand Up @@ -575,7 +577,7 @@ export class DefaultConnectionManager implements ConnectionManager, Startable {
async acceptIncomingConnection (maConn: MultiaddrConnection): Promise<boolean> {
// check deny list
const denyConnection = this.deny.some(ma => {
return maConn.remoteAddr.toString().startsWith(ma.toString())
return ma.contains(maConn.remoteAddr.nodeAddress().address)
})

if (denyConnection) {
Expand All @@ -584,8 +586,8 @@ export class DefaultConnectionManager implements ConnectionManager, Startable {
}

// check allow list
const allowConnection = this.allow.some(ma => {
return maConn.remoteAddr.toString().startsWith(ma.toString())
const allowConnection = this.allow.some(ipNet => {
return ipNet.contains(maConn.remoteAddr.nodeAddress().address)
})

if (allowConnection) {
Expand Down
37 changes: 35 additions & 2 deletions packages/libp2p/src/connection-manager/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolvers } from '@multiformats/multiaddr'
import { multiaddr, resolvers, type Multiaddr, type ResolveOptions } from '@multiformats/multiaddr'
import { convertToIpNet } from '@multiformats/multiaddr/convert'
import type { IpNet } from '@chainsafe/netmask'
import type { LoggerOptions } from '@libp2p/interface'
import type { Multiaddr, ResolveOptions } from '@multiformats/multiaddr'

/**
* Recursively resolve DNSADDR multiaddrs
Expand Down Expand Up @@ -28,3 +29,35 @@ export async function resolveMultiaddrs (ma: Multiaddr, options: ResolveOptions

return output
}

/**
* Converts a multiaddr string or object to an IpNet object.
* If the multiaddr doesn't include /ipcidr, it will encapsulate with the appropriate CIDR:
* - /ipcidr/32 for IPv4
* - /ipcidr/128 for IPv6
*
* @param {string | Multiaddr} ma - The multiaddr string or object to convert.
* @returns {IpNet} The converted IpNet object.
* @throws {Error} Throws an error if the multiaddr is not valid.
*/
export function multiaddrToIpNet (ma: string | Multiaddr): IpNet {
try {
let parsedMa: Multiaddr
if (typeof ma === 'string') {
parsedMa = multiaddr(ma)
} else {
parsedMa = ma
}

// Check if /ipcidr is already present
if (!parsedMa.protoNames().includes('ipcidr')) {
const isIPv6 = parsedMa.protoNames().includes('ip6')
const cidr = isIPv6 ? '/ipcidr/128' : '/ipcidr/32'
parsedMa = parsedMa.encapsulate(cidr)
}

return convertToIpNet(parsedMa)
} catch (error) {
throw new Error(`Can't convert to IpNet, Invalid multiaddr format: ${ma}`)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,44 @@ describe('connection-pruner', () => {
expect(shortestLivedWithLowestTagSpy).to.have.property('callCount', 1)
})

it('should correctly parse and store allow list as IpNet objects in ConnectionPruner', () => {
const mockInit = {
allow: [
multiaddr('/ip4/83.13.55.32/ipcidr/32'),
multiaddr('/ip4/83.13.55.32'),
multiaddr('/ip4/192.168.1.1/ipcidr/24'),
multiaddr('/ip6/2001::0/ipcidr/64')
]
}

// Create a ConnectionPruner instance
const pruner = new ConnectionPruner(components, mockInit)

// Expected IpNet objects for comparison
const expectedAllowList = [
{
mask: new Uint8Array([255, 255, 255, 255]),
network: new Uint8Array([83, 13, 55, 32])
},
{
mask: new Uint8Array([255, 255, 255, 255]),
network: new Uint8Array([83, 13, 55, 32])
},
{
mask: new Uint8Array([255, 255, 255, 0]),
network: new Uint8Array([192, 168, 1, 0])
},
{
mask: new Uint8Array([255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0]),
network: new Uint8Array([32, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
}
]

// Verify that the allow list in the pruner matches the expected IpNet objects
// eslint-disable-next-line @typescript-eslint/dot-notation
expect(pruner['allow']).to.deep.equal(expectedAllowList)
})

it('should not close connection that is on the allowlist when pruning', async () => {
const max = 2
const remoteAddr = multiaddr('/ip4/83.13.55.32/tcp/59283')
Expand All @@ -241,6 +279,7 @@ describe('connection-pruner', () => {
for (let i = 0; i < max; i++) {
const connection = stubInterface<Connection>({
remotePeer: peerIdFromPrivateKey(await generateKeyPair('Ed25519')),
remoteAddr: multiaddr('/ip4/127.0.0.1/tcp/12345'),
streams: []
})
const spy = connection.close
Expand Down Expand Up @@ -269,7 +308,6 @@ describe('connection-pruner', () => {
const value = 0
const spy = connection.close
spies.set(value, spy)

// Tag that allowed peer with lowest value
components.peerStore.get.withArgs(connection.remotePeer).resolves(stubInterface<Peer>({
tags: new Map([['test-tag', { value }]])
Expand Down
Loading

0 comments on commit 48e9cfa

Please sign in to comment.