signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
98 lines • 4.61 kB
JavaScript
;
/*
* Copyright 2026 Signal K project contributors
*
* 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.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ssrfSafeLookup = exports.BlockedHostError = void 0;
exports.isBlockedAddress = isBlockedAddress;
exports.assertAllowedHost = assertAllowedHost;
const node_net_1 = require("node:net");
const node_dns_1 = __importDefault(require("node:dns"));
// Special-use destinations that the remote-connection endpoints must never
// reach. These are not valid Signal K peers and are the SSRF targets of value:
// the cloud metadata service (169.254.169.254), loopback-bound services, and
// other non-routable ranges. Private LAN ranges (RFC1918 / IPv6 ULA) are
// deliberately NOT blocked: connecting to another Signal K server on the boat's
// own network is the legitimate purpose of these endpoints.
const blockedRanges = new node_net_1.BlockList();
blockedRanges.addSubnet('0.0.0.0', 8, 'ipv4'); // "this host" / this-network
blockedRanges.addSubnet('127.0.0.0', 8, 'ipv4'); // loopback
blockedRanges.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local incl. cloud metadata
blockedRanges.addSubnet('224.0.0.0', 4, 'ipv4'); // multicast
blockedRanges.addSubnet('240.0.0.0', 4, 'ipv4'); // reserved + broadcast
blockedRanges.addAddress('::', 'ipv6'); // unspecified
blockedRanges.addAddress('::1', 'ipv6'); // loopback
blockedRanges.addSubnet('::', 96, 'ipv6'); // deprecated IPv4-compatible (::a.b.c.d)
blockedRanges.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known prefix (RFC 6052) - reaches embedded IPv4 in NAT64 networks
blockedRanges.addSubnet('fe80::', 10, 'ipv6'); // link-local
blockedRanges.addSubnet('ff00::', 8, 'ipv6'); // multicast
class BlockedHostError extends Error {
constructor(host) {
super(`Connection to ${host} is not allowed`);
this.name = 'BlockedHostError';
}
}
exports.BlockedHostError = BlockedHostError;
// Classifies an IP literal as a blocked special-use address. IPv4-mapped IPv6
// forms (e.g. ::ffff:169.254.169.254) are normalised onto the IPv4 ranges by
// BlockList.check, closing that bypass vector.
function isBlockedAddress(address) {
const family = (0, node_net_1.isIP)(address);
if (family === 0) {
return false;
}
return blockedRanges.check(address, family === 4 ? 'ipv4' : 'ipv6');
}
// Throws if host is a literal IP in a blocked range. Hostnames pass through here
// and are validated later by ssrfSafeLookup once resolved. This synchronous
// check is required because Node skips the lookup option when the host is
// already an IP literal and connects to it directly.
function assertAllowedHost(host) {
if (isBlockedAddress(host)) {
throw new BlockedHostError(host);
}
}
// A drop-in replacement for the dns.lookup used by http(s).request. It resolves
// the host, rejects the connection if any resolved address is a blocked
// special-use destination, and hands the validated address back to the socket.
// Validating here (rather than before the request) means the connection is
// pinned to an address we checked, so a hostname cannot resolve to a safe
// address during validation and a blocked one at connect time (DNS rebinding).
const ssrfSafeLookup = (hostname, options, callback) => {
node_dns_1.default.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) {
callback(err, '', 0);
return;
}
for (const { address, family } of addresses) {
if (isBlockedAddress(address)) {
callback(new BlockedHostError(hostname), address, family);
return;
}
}
if (options.all) {
;
callback(null, addresses);
}
else {
callback(null, addresses[0].address, addresses[0].family);
}
});
};
exports.ssrfSafeLookup = ssrfSafeLookup;
//# sourceMappingURL=ssrfGuard.js.map