Skip to content

Commit d061525

Browse files
authored
fix(upnp): Upnp issue fix (#2081)
UPnP security fix. The release build now applies and validates the local @runonflux/nat-upnp patch after npm installs, so the API artifact used by the test plugin includes the fix.
1 parent 509853c commit d061525

9 files changed

Lines changed: 604 additions & 3 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"disposition": "PATCH",
3+
"forecast_commit": "af00989e28fe2f2825ee8792544bcfa2c176c264",
4+
"reviewed_sha": "7d67f03cae08139cb767f69ac62d5b9de933ea26",
5+
"schema": "limetech.ai-review-marker.v2",
6+
"stage": "final",
7+
"unresolved_proportionality_findings": []
8+
}

api/scripts/build.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ const WORKSPACE_PACKAGES_TO_VENDOR = {
2929
'unraid-api-plugin-connect': 'packages/unraid-api-plugin-connect',
3030
} as const;
3131

32+
const REPOSITORY_ROOT = resolve('..');
33+
34+
const LOCAL_PATCHED_DEPENDENCIES = [
35+
{
36+
packageName: '@runonflux/nat-upnp',
37+
patchPath: join(REPOSITORY_ROOT, 'patches/@runonflux__nat-upnp@1.0.2.patch'),
38+
validationPath: 'build/src/nat-upnp/client.js',
39+
validationMarker: 'No trusted gateway addresses found',
40+
},
41+
] as const;
42+
3243
/**
3344
* Packs a workspace package and installs it as a tarball dependency.
3445
*/
@@ -49,6 +60,34 @@ const packAndInstallWorkspacePackage = async (pkgName: string, pkgPath: string,
4960
await $`npm install ${tarballPattern}`;
5061
};
5162

63+
const applyLocalPatchesToProductionDependencies = async () => {
64+
for (const dependency of LOCAL_PATCHED_DEPENDENCIES) {
65+
const dependencyPath = resolve('node_modules', dependency.packageName);
66+
67+
if (!existsSync(dependencyPath)) {
68+
throw new Error(`Patched dependency ${dependency.packageName} was not installed`);
69+
}
70+
71+
if (!existsSync(dependency.patchPath)) {
72+
throw new Error(`Patch file not found: ${dependency.patchPath}`);
73+
}
74+
75+
console.log(`Applying local patch to ${dependency.packageName}...`);
76+
const reverseCheck =
77+
await $`GIT_DIR=/dev/null git -C ${dependencyPath} apply --reverse --check --no-index ${dependency.patchPath}`
78+
.nothrow()
79+
.quiet();
80+
if (reverseCheck.exitCode !== 0) {
81+
await $`GIT_DIR=/dev/null git -C ${dependencyPath} apply --no-index ${dependency.patchPath}`;
82+
}
83+
84+
const patchedSource = await readFile(join(dependencyPath, dependency.validationPath), 'utf-8');
85+
if (!patchedSource.includes(dependency.validationMarker)) {
86+
throw new Error(`Patch validation failed for ${dependency.packageName}`);
87+
}
88+
}
89+
};
90+
5291
/**------------------------------------------------------------------------
5392
* Build Script
5493
*
@@ -138,6 +177,8 @@ try {
138177
}
139178
}
140179

180+
await applyLocalPatchesToProductionDependencies();
181+
141182
// Clean the release directory
142183
await $`rm -rf ../release/*`;
143184

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { parseDefaultGatewayAddresses } from '@app/upnp/default-gateway.js';
4+
5+
describe('parseDefaultGatewayAddresses', () => {
6+
it('extracts unique IPv4 and IPv6 default gateways', () => {
7+
const output = [
8+
'default via 192.168.1.1 dev eth0 proto dhcp src 192.168.1.50 metric 100',
9+
'default via fe80::1 dev eth0 proto ra metric 1024',
10+
'default via 192.168.1.1 dev eth1 metric 200',
11+
].join('\n');
12+
13+
expect(parseDefaultGatewayAddresses(output)).toEqual(['192.168.1.1', 'fe80::1']);
14+
});
15+
16+
it('ignores routes without a gateway and invalid gateway values', () => {
17+
const output = [
18+
'default dev eth0 scope link',
19+
'default via not-an-ip dev eth1',
20+
'10.0.0.0/8 via 10.0.0.1 dev eth0',
21+
].join('\n');
22+
23+
expect(parseDefaultGatewayAddresses(output)).toEqual([]);
24+
});
25+
});

api/src/upnp/default-gateway.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { isIP } from 'node:net';
2+
3+
import { execa } from 'execa';
4+
5+
const IP_COMMAND = '/sbin/ip';
6+
const ROUTE_FAMILIES = ['-4', '-6'] as const;
7+
8+
export function parseDefaultGatewayAddresses(output: string): string[] {
9+
const addresses = output.split(/\r?\n/).flatMap((line) => {
10+
const match = line.match(/^\s*default\s+via\s+(\S+)/);
11+
return match ? [match[1]] : [];
12+
});
13+
14+
return [...new Set(addresses.filter((address) => isIP(address) !== 0))];
15+
}
16+
17+
export async function getDefaultGatewayAddresses(): Promise<string[]> {
18+
const results = await Promise.allSettled(
19+
ROUTE_FAMILIES.map((family) =>
20+
execa(IP_COMMAND, [family, 'route', 'show', 'default'], { reject: false })
21+
)
22+
);
23+
24+
return [
25+
...new Set(
26+
results.flatMap((result) =>
27+
result.status === 'fulfilled' && result.value.exitCode === 0
28+
? parseDefaultGatewayAddresses(result.value.stdout)
29+
: []
30+
)
31+
),
32+
];
33+
}

api/src/upnp/helpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { Client } from '@runonflux/nat-upnp';
22

33
import { THIRTY_SECONDS_MS } from '@app/consts.js';
44
import { IS_DOCKER } from '@app/environment.js';
5+
import { getDefaultGatewayAddresses } from '@app/upnp/default-gateway.js';
56
import { MockUpnpClient } from '@app/upnp/mock-upnp-client.js';
67

78
// If we're in docker mode, load the mock client
89
export const upnpClient = IS_DOCKER
910
? new MockUpnpClient({ timeout: THIRTY_SECONDS_MS })
1011
: new Client({
1112
timeout: THIRTY_SECONDS_MS,
13+
gatewayAddresses: getDefaultGatewayAddresses,
1214
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import EventEmitter from 'node:events';
2+
3+
import type { ISsdp, SsdpEmitter } from '@runonflux/nat-upnp';
4+
import { Client, Device } from '@runonflux/nat-upnp';
5+
import { describe, expect, it } from 'vitest';
6+
7+
const DEVICE_TYPE = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1';
8+
const COMPRESSED_GATEWAY = '2001:db8::1';
9+
const EXPANDED_GATEWAY = '2001:0db8:0:0:0:0:0:1';
10+
11+
const createSsdp = (locationAddress: string, gatewayAddress: string): ISsdp => ({
12+
search: () => {
13+
const emitter = new EventEmitter() as SsdpEmitter;
14+
queueMicrotask(() =>
15+
emitter.emit(
16+
'device',
17+
{
18+
location: `http://[${locationAddress}]/root.xml`,
19+
st: DEVICE_TYPE,
20+
},
21+
gatewayAddress,
22+
'192.0.2.2'
23+
)
24+
);
25+
return emitter;
26+
},
27+
close: () => undefined,
28+
});
29+
30+
describe('nat-upnp IPv6 address matching', () => {
31+
it('accepts equivalent IPv6 forms in the gateway allow-list', async () => {
32+
const client = new Client({
33+
gatewayAddresses: [EXPANDED_GATEWAY],
34+
ssdp: createSsdp(COMPRESSED_GATEWAY, COMPRESSED_GATEWAY),
35+
timeout: 100,
36+
});
37+
38+
await expect(client.getGateway()).resolves.toMatchObject({
39+
gatewayAddress: COMPRESSED_GATEWAY,
40+
});
41+
client.close();
42+
});
43+
44+
it('accepts equivalent IPv6 forms in the device address check', () => {
45+
expect(
46+
() =>
47+
new Device(`http://[${EXPANDED_GATEWAY}]/root.xml`, {
48+
allowedAddress: COMPRESSED_GATEWAY,
49+
})
50+
).not.toThrow();
51+
});
52+
});

0 commit comments

Comments
 (0)