Skip to content

Commit af01eb7

Browse files
committed
docs(macOS): Add doc for local network permissions on macOS
Signed-off-by: Claudio Cambra <developer@claudiocambra.com>
1 parent 3844c36 commit af01eb7

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

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)

0 commit comments

Comments
 (0)