Skip to content

Commit 96d4abb

Browse files
authored
Merge pull request #10484 from nextcloud/backport/10460/stable-34.0
[stable-34.0] fix(macOS): Verify ability to access Local Network
2 parents ea0be35 + af01eb7 commit 96d4abb

14 files changed

Lines changed: 690 additions & 16 deletions

cmake/modules/MacOSXBundleInfo.plist.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
<true/>
4646
<key>NSRequiresAquaSystemAppearance</key>
4747
<false/>
48+
<key>NSLocalNetworkUsageDescription</key>
49+
<string>Nextcloud needs access to your local network to connect to Nextcloud servers hosted there.</string>
4850
<key>SUShowReleaseNotes</key>
4951
<false/>
5052
<key>SUPublicDSAKeyFile</key>

doc/local-network-permission.md

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: GPL-2.0-or-later
4+
-->
5+
6+
# Local Network Permission Handling
7+
8+
## Purpose
9+
10+
On macOS 15 and later, the user can deny an application access to devices on
11+
the local network. A connection attempt affected by this setting otherwise
12+
looks much like an unreachable server to the desktop client. The local network
13+
permission check lets the client replace a generic connection error with an
14+
actionable message:
15+
16+
> Local Network access is disabled. Enable it in System Settings → Privacy &
17+
> Security → Local Network.
18+
19+
This check is diagnostic rather than proactive. It runs after a server
20+
connection has already failed or timed out. It does not request permission and
21+
does not report a global permission state. Apple does not provide a general API
22+
for querying that state; instead, the check observes the path of a connection
23+
to the server the user entered. See
24+
[TN3179: Understanding local network privacy](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy).
25+
26+
The implementation was introduced in response to
27+
[nextcloud/desktop#10452](https://github.com/nextcloud/desktop/issues/10452).
28+
29+
## Architecture
30+
31+
### Platform boundary
32+
33+
`LocalNetworkPermission` exposes two functions from
34+
`src/gui/localnetworkpermission.h`:
35+
36+
- `checkDeniedForConnection()` reports through a callback whether local network
37+
permission denied a specific failed connection.
38+
- `deniedError()` returns the platform-appropriate error shown to the user.
39+
40+
CMake selects the implementation:
41+
42+
- On macOS, `src/gui/macOS/localnetworkpermission.mm` uses Network.framework.
43+
- On other platforms, `src/gui/localnetworkpermission.cpp` reports `false`,
44+
preserving the existing connection error.
45+
46+
This keeps platform conditionals out of `ConnectionValidator` and
47+
`AccountWizardController`. A `false` result means that local network denial was
48+
not established; it does not prove that permission is enabled.
49+
50+
### macOS connection probe
51+
52+
The macOS implementation is available on macOS 15 and later. It performs the
53+
following steps:
54+
55+
1. Extract the host and port from the failed URL. The default port is `443` for
56+
HTTPS and `80` otherwise.
57+
2. Create a Network.framework TCP connection to that endpoint. The probe does
58+
not perform an HTTP request or a TLS handshake.
59+
3. Set `prefer_no_proxy` on the connection parameters. This makes
60+
Network.framework try the direct path first, so a VPN-provided local proxy
61+
cannot immediately hide the local-network denial. Network.framework may
62+
still try a configured proxy if the direct attempt fails.
63+
4. Observe connection path and state updates on the main dispatch queue.
64+
5. Finish with `true` when an unsatisfied path reports
65+
`nw_path_unsatisfied_reason_local_network_denied`.
66+
6. Finish with `false` when the connection becomes ready.
67+
7. On a `waiting` or `failed` state, finish only if the current path explicitly
68+
reports local-network denial. A path can be temporarily inconclusive, so
69+
completing with `false` at this point would introduce a race with a later
70+
path update.
71+
8. After two seconds, inspect the path once more and finish. This bounds the
72+
diagnostic delay when the server is merely absent or unreachable.
73+
74+
`ConnectionProbe` owns the Network.framework connection and callback. Its
75+
`completed` flag ensures exactly-once completion. Finishing cancels and
76+
releases the connection before dispatching the result back through Qt.
77+
78+
The callback context is held as a `QPointer<QObject>`. The result is queued onto
79+
that context and is discarded if the context has been destroyed, preventing a
80+
callback into a deleted controller or validator.
81+
82+
### Consumers
83+
84+
`ConnectionValidator` invokes the check when:
85+
86+
- the status request fails; or
87+
- its connection job times out.
88+
89+
When denial is established, the permission message replaces the generic
90+
network error. The validator's status value is unchanged.
91+
92+
`AccountWizardController` invokes the check after its server connection fails.
93+
When denial is established, it displays the permission message and does not
94+
offer secure-connection recovery, such as retrying without TLS. Otherwise, the
95+
existing recovery flow continues.
96+
97+
The wizard also verifies that the account URL still matches the URL whose
98+
probe completed. This prevents a delayed result from an earlier attempt from
99+
changing the state of a newer attempt.
100+
101+
### Test seam
102+
103+
Both consumers store the permission check in a private `std::function`,
104+
initialized to `LocalNetworkPermission::checkDeniedForConnection`. Their test
105+
access classes are friends and replace that callable with a synchronous
106+
deterministic result.
107+
108+
This keeps the production constructors and public API unchanged. It also tests
109+
the behavior of each consumer without subclassing production classes or
110+
making one-line methods virtual solely for tests.
111+
112+
## Test plan
113+
114+
### Automated coverage
115+
116+
`AccountWizardControllerTest` covers:
117+
118+
- An invalid URL produces a non-denied result.
119+
- A denied result displays `deniedError()` and suppresses secure-connection
120+
recovery.
121+
- A non-denied result preserves the secure-connection recovery flow.
122+
123+
`ConnectionValidatorTest` covers:
124+
125+
- A denied result replaces the generic timeout text with `deniedError()`.
126+
127+
Build and run the focused tests from the repository root:
128+
129+
```sh
130+
cmake -S . -B build-testing
131+
cmake --build build-testing \
132+
--target AccountWizardControllerTest ConnectionValidatorTest
133+
ctest --test-dir build-testing --output-on-failure \
134+
-R '^(ConnectionValidator|AccountWizardController)Test$'
135+
```
136+
137+
### Manual coverage
138+
139+
The automated tests do not cover:
140+
141+
- macOS Local Network privacy enforcement or its System Settings toggle;
142+
- Network.framework path-update ordering and unsatisfied reasons;
143+
- behavior with a real VPN or system proxy;
144+
- the direct-path preference and proxy fallback;
145+
- code-signing identity and executable UUID tracking;
146+
- differences between launching from Finder, Terminal, Xcode, or another
147+
parent process;
148+
- the complete two-second probe against a real network.
149+
150+
These behaviors depend on operating-system privacy state, routing, signing,
151+
and the active network environment. Checking only that a Network.framework
152+
parameter was set would test an implementation detail, not the intended VPN
153+
behavior. The native path therefore requires the manual regression test below.
154+
155+
## Reproducing local-network denial
156+
157+
### Requirements
158+
159+
- macOS 15 or later.
160+
- A validly signed application with a stable Apple-issued identity.
161+
- A unique UUID in the main executable.
162+
- `NSLocalNetworkUsageDescription` in the application `Info.plist`.
163+
- A target address on a network directly attached through Wi-Fi or Ethernet.
164+
A private address routed elsewhere is not necessarily a local-network
165+
address for this privacy feature.
166+
167+
The target does not need to run a Nextcloud server. Using an unused address on
168+
the directly attached subnet is useful because it isolates privacy diagnosis
169+
from server behavior.
170+
171+
Verify the application before testing:
172+
173+
```sh
174+
codesign --verify --deep --strict --verbose=2 /path/to/Nextcloud.app
175+
/usr/bin/dwarfdump --uuid /path/to/Nextcloud.app/Contents/MacOS/Nextcloud
176+
```
177+
178+
Both commands must succeed, and the UUID output must not be empty.
179+
180+
### Launch the application correctly
181+
182+
Quit all running instances and launch the tested application by
183+
double-clicking its bundle in Finder.
184+
185+
Do not start the executable directly from Terminal or SSH. macOS automatically
186+
allows local-network access for command-line tools launched from those
187+
environments and for their child processes. In that situation,
188+
Network.framework can report the connection as ineligible for privacy
189+
enforcement even though the application's Local Network toggle is disabled.
190+
This produces a false-negative test.
191+
192+
For the same reason, Finder launch is preferred for this regression test over
193+
developer launch mechanisms whose responsible process may affect privacy
194+
attribution.
195+
196+
### Test procedure
197+
198+
1. Determine the Mac's Wi-Fi or Ethernet address and subnet.
199+
2. Choose an unused address on that same directly attached subnet.
200+
3. Open **System Settings → Privacy & Security → Local Network**.
201+
4. Disable Local Network access for Nextcloud.
202+
5. Quit Nextcloud, then launch the tested app bundle from Finder.
203+
6. In the account wizard, enter the unused address, for example
204+
`https://192.168.0.64`.
205+
7. Start the connection.
206+
207+
Expected result:
208+
209+
- The wizard reports that Local Network access is disabled.
210+
- It does not show the secure-connection recovery dialog.
211+
212+
Repeat with a VPN active. The expected result is the same. The permission probe
213+
should try the direct local route before a VPN-provided proxy can handle the
214+
connection.
215+
216+
As a comparison, enable Local Network access and repeat. Because the chosen
217+
address has no server, the result should now be an ordinary timeout or
218+
connection failure rather than the permission message.
219+
220+
## Troubleshooting
221+
222+
### The result is a timeout or TLS recovery dialog
223+
224+
Confirm all of the following:
225+
226+
- The application was launched from Finder, not by executing its binary in a
227+
shell.
228+
- The tested bundle has a valid signature.
229+
- The running process belongs to the bundle just verified.
230+
- The target address is on a directly attached Wi-Fi or Ethernet subnet.
231+
- The Local Network toggle for the tested application is disabled.
232+
233+
### Inspect Network.framework activity
234+
235+
The following command reads the relevant unified logs for a bounded test
236+
interval:
237+
238+
```sh
239+
/usr/bin/log show \
240+
--start '2026-07-27 22:20:45' \
241+
--end '2026-07-27 22:22:35' \
242+
--style compact --info --debug \
243+
--predicate 'process == "Nextcloud" AND subsystem BEGINSWITH "com.apple.network"'
244+
```
245+
246+
Replace the timestamps with the actual test interval. Useful evidence includes:
247+
248+
- `prefer no proxy`, confirming that the direct path preference is active;
249+
- `local network denied` or an unsatisfied local-network-denial reason;
250+
- a proxy endpoint such as `127.0.0.1`, showing proxy fallback;
251+
- `Privacy Stance: Not Eligible`, which indicates that the operation was not
252+
subject to normal Local Network privacy enforcement and commonly points to
253+
the launch or identity conditions described above.
254+
255+
### VPN interpretation
256+
257+
A VPN can install a system proxy even when the local target remains routed over
258+
Wi-Fi. `prefer_no_proxy` means “try direct first,” not “prohibit all proxies.”
259+
Seeing a later proxy attempt is therefore expected when the direct attempt
260+
fails. What matters for this feature is that macOS has an opportunity to
261+
evaluate the direct local path and report denial before proxy fallback masks
262+
the original condition.

doc/macOS-development.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,5 +179,6 @@ The way Transifex handles Xcode string catalogs creates a high risk of accidenta
179179

180180
- **Direct `mac-crafter` CLI usage / branding builds**[`admin/osx/mac-crafter/README.md`](../admin/osx/mac-crafter/README.md)
181181
- **Qt + macOS App Sandbox internals**[`doc/macOS-Sandbox-Qt.md`](./macOS-Sandbox-Qt.md)
182+
- **Local Network permission diagnostics and testing**[`doc/local-network-permission.md`](./local-network-permission.md)
182183
- **Finder integration (FinderSync) extension — verifying & troubleshooting loading**[`doc/macOS-FinderSync-extension.md`](./macOS-FinderSync-extension.md)
183184
- **NextcloudFileProviderKit Swift package**[`shell_integration/MacOSX/NextcloudFileProviderKit/README.md`](../shell_integration/MacOSX/NextcloudFileProviderKit/README.md)

src/gui/CMakeLists.txt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ set(client_SRCS
8686
conflictsolver.cpp
8787
connectionvalidator.h
8888
connectionvalidator.cpp
89+
localnetworkpermission.h
8990
editlocallyjob.h
9091
editlocallyjob.cpp
9192
editlocallymanager.h
@@ -294,6 +295,8 @@ IF( APPLE )
294295
list(APPEND client_SRCS cocoainitializer_mac.mm)
295296
list(APPEND client_SRCS systray_mac_common.mm)
296297
list(APPEND client_SRCS notificationsoundplayer_mac.mm)
298+
list(APPEND client_SRCS
299+
macOS/localnetworkpermission.mm)
297300
list(APPEND client_SRCS
298301
# macOS tray account popup: one type per header/implementation pair.
299302
# Shared foundation first, then the base hover view, rows, popups and the
@@ -391,7 +394,7 @@ IF( APPLE )
391394
endif()
392395
ENDIF()
393396
IF( NOT APPLE )
394-
list(APPEND client_SRCS trayaccountpopup_qt.cpp)
397+
list(APPEND client_SRCS trayaccountpopup_qt.cpp localnetworkpermission.cpp)
395398
ENDIF()
396399

397400
IF( NOT WIN32 AND NOT APPLE )
@@ -751,7 +754,7 @@ if (APPLE)
751754
else()
752755
target_link_libraries(nextcloudCore PUBLIC "-framework UserNotifications")
753756
endif()
754-
target_link_libraries(nextcloudCore PRIVATE "-framework AVFoundation" "-framework Foundation")
757+
target_link_libraries(nextcloudCore PRIVATE "-framework AVFoundation" "-framework Foundation" "-framework Network")
755758
target_compile_definitions(nextcloudCore PRIVATE NEXTCLOUD_HAS_NATIVE_SOUND_BACKEND)
756759
endif()
757760

src/gui/connectionvalidator.cpp

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include "userinfo.h"
1919
#include "networkjobs.h"
2020
#include "clientproxy.h"
21+
#include "localnetworkpermission.h"
2122
#include <creds/abstractcredentials.h>
2223
#include "systray.h"
2324

@@ -35,6 +36,7 @@ ConnectionValidator::ConnectionValidator(AccountStatePtr accountState, const QSt
3536
, _accountState(accountState)
3637
, _account(accountState->account())
3738
, _termsOfServiceChecker(_account)
39+
, _localNetworkPermissionCheck(LocalNetworkPermission::checkDeniedForConnection)
3840
{
3941
connect(&_termsOfServiceChecker, &TermsOfServiceChecker::done,
4042
this, &ConnectionValidator::termsOfServiceCheckDone);
@@ -160,25 +162,30 @@ void ConnectionValidator::slotNoStatusFound(QNetworkReply *reply)
160162
return;
161163
}
162164

165+
QString error;
163166
if (!_account->credentials()->stillValid(reply)) {
164167
// Note: Why would this happen on a status.php request?
165-
_errors.append(tr("Authentication error: Either username or password are wrong."));
168+
error = tr("Authentication error: Either username or password are wrong.");
166169
} else {
167170
//_errors.append(tr("Unable to connect to %1").arg(_account->url().toString()));
168-
_errors.append(job->errorString());
171+
error = job->errorString();
169172
}
170-
reportResult(StatusNotFound);
173+
174+
_localNetworkPermissionCheck(_account->url(), this, [this, error](const bool denied) {
175+
_errors.append(denied ? LocalNetworkPermission::deniedError() : error);
176+
reportResult(StatusNotFound);
177+
});
171178
}
172179

173180
void ConnectionValidator::slotJobTimeout(const QUrl &url)
174181
{
175-
Q_UNUSED(url);
176182
//_errors.append(tr("Unable to connect to %1").arg(url.toString()));
177-
_errors.append(tr("Timeout"));
178-
reportResult(Timeout);
183+
_localNetworkPermissionCheck(url, this, [this](const bool denied) {
184+
_errors.append(denied ? LocalNetworkPermission::deniedError() : tr("Timeout"));
185+
reportResult(Timeout);
186+
});
179187
}
180188

181-
182189
void ConnectionValidator::checkAuthentication()
183190
{
184191
AbstractCredentials *creds = _account->credentials();

src/gui/connectionvalidator.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
#include "accountfwd.h"
1616
#include "clientsideencryption.h"
1717

18+
#include <functional>
19+
1820
namespace OCC {
1921

2022
/**
@@ -157,6 +159,10 @@ protected slots:
157159
void termsOfServiceCheckDone();
158160

159161
private:
162+
using LocalNetworkPermissionCheck = std::function<void(const QUrl &, QObject *, std::function<void(bool)>)>;
163+
164+
friend class ConnectionValidatorTestAccess;
165+
160166
#ifndef TOKEN_AUTH_ONLY
161167
void reportConnected();
162168
#endif
@@ -177,6 +183,7 @@ protected slots:
177183
AccountStatePtr _accountState;
178184
AccountPtr _account;
179185
TermsOfServiceChecker _termsOfServiceChecker;
186+
LocalNetworkPermissionCheck _localNetworkPermissionCheck;
180187
bool _isCheckingServerAndAuth = false;
181188
};
182189
}

0 commit comments

Comments
 (0)