Skip to content

fix: preserve file permissions in safe_open()#1117

Merged
DavidePrincipi merged 44 commits intorel3181from
copilot/fix-file-permission-handling
Mar 13, 2026
Merged

fix: preserve file permissions in safe_open()#1117
DavidePrincipi merged 44 commits intorel3181from
copilot/fix-file-permission-handling

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 10, 2026

tempfile.mkstemp() unconditionally creates files with mode 0600, ignoring both the existing file's permissions and the process umask, causing every file written through safe_open() to end up with 0600.

Refs NethServer/dev#7915

renovate Bot and others added 30 commits March 2, 2026 15:48
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Narrow the validity period from 1 month to 24 hours.
- Automatically stop the support.service unit 24 hours after support
  begins.
- The auxiliary unit support-expire.timer can be stopped manually, to
  extend the support session duration as wanted.
- In any case support.service will die after 30 days.
Return the session expiry timestamp in ISO 8601 format. Standard session
duration is 24 hours, but it can be extended up to 30 days.
Co-authored-by: DavidePrincipi <2920838+DavidePrincipi@users.noreply.github.com>
Re-read per-user allowed networks from Redis in IdentityHandler
and check the client IP in Authorizator before the GET bypass,
so all HTTP methods are gated by the allowlist on every request.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Usernames matching 'cluster', 'node/*', or 'module/*' are NS8
agent credentials. Return loopback + VPN network (cluster/network)
as their forced allowlist so they cannot be used from outside the
cluster, regardless of any per-user networks entry in Redis.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After credentials are verified, check the client IP against the
per-user allowed networks (GetUserNetworks) before proceeding to
role/action authorization. Returns 403 if the IP is not allowed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Set Gin's trusted proxy list to 127.0.0.1 and ::1 so that
X-Forwarded-For is honoured only for connections coming from the
local Traefik instance. Direct connections (e.g. cluster agents
over the VPN) use the TCP peer address, preventing clients from
spoofing their source IP via forged headers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend user-attributes schema with an allowed_networks array of
IPv4/IPv6 addresses and CIDR networks. Add ipv6-cidr and ip-cidr
definitions to cluster.json.

add-user stores the array as a CSV string in
  HSET user/<username> allowed_networks <csv>
alter-user updates it when the key is present in the set block.
An empty array removes all IP restrictions.

Fix GetUserNetworks in api-server to read the allowed_networks
field instead of the old networks field name.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Test suite 61__allowed_networks_login.robot covers all three
enforcement points of the allowed_networks feature:

- JWT login denied when source IP not in allowed_networks (401)
- JWT login succeeds after restriction is removed (200)
- JWT rejected mid-session after restriction is re-applied (403)
- cluster/node/module agent credentials blocked from external IP (401)
- HTTP-Basic module endpoint blocked from external IP (403)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Agent credentials (cluster, node/*, module/*) are always restricted
to loopback + cluster VPN network by the API server. Regular user
accounts can optionally carry an allowed_networks restriction.

Document this in:
- core/api-server/README.md (new "Network access restrictions" section)
- docs/core/api_server.md (new "Network access control" section)
- docs/core/agents.md (note in the task-submission section)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pass a space-separated list of IPs/CIDRs to restrict which source
addresses the new user may log in from.

  add-user bob --role owner --allowed-networks 192.168.1.0/24 10.0.0.1

When the flag is omitted no IP restriction is applied.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Allow nethsupport cluster-admin access only from official Nethesis IP
addresses.  The loopback address is required for cluster-admin and SSH
access from the support VPN.
The client IP address must be read from X-Forwarded-For header, set by
the local, trusted Traefik proxy.
- Decrease session age hard limit from 30 to 7 days
- Extend SSH key expire period to 7 days
- Update subscription docs
The validation error protocol expects an array output.
Automatic stop of support.service unit
api-server: enforce per-user IP/network allowlist at login
Translate-URL: https://hosted.weblate.org/projects/ns8/core/it/
Translation: NS8/core

Co-authored-by: Davide Principi <davide.principi@nethesis.it>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

[skip ci]
@DavidePrincipi DavidePrincipi added the testing Start test suite label Mar 10, 2026
@DavidePrincipi DavidePrincipi force-pushed the copilot/fix-file-permission-handling branch from 13ca326 to c9192e8 Compare March 10, 2026 18:13
@DavidePrincipi DavidePrincipi added testing Start test suite and removed testing Start test suite labels Mar 10, 2026
@DavidePrincipi DavidePrincipi requested a review from Copilot March 12, 2026 14:53
@DavidePrincipi DavidePrincipi marked this pull request as ready for review March 12, 2026 14:53

This comment was marked as outdated.

Check the image existence with skopeo.

[skip ci]

This comment was marked as outdated.

@DavidePrincipi DavidePrincipi force-pushed the copilot/fix-file-permission-handling branch from 3007c7f to ccf044a Compare March 12, 2026 16:17
Revert environment file permissions to default mode 644.
- If the file is new, generate a unique temporary file name and write to
  it. Use os.open() to honor the process umask.

- If the file already exists, copy its permissions to a temporary file
  created with tempfile.mkstemp().
@DavidePrincipi DavidePrincipi force-pushed the copilot/fix-file-permission-handling branch from ccf044a to 8666106 Compare March 12, 2026 18:13
@DavidePrincipi DavidePrincipi requested a review from Copilot March 12, 2026 18:13
@DavidePrincipi DavidePrincipi removed testing Start test suite labels Mar 12, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

try:
# Try to create a new file on tmp_path. Kernel
# applies current process umask on file creation
fd = os.open(tmp_path, base_flags | os.O_CREAT | os.O_EXCL, 0o666)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the FileNotFoundError fallback branch, the temp file is created with os.open(..., base_flags | os.O_CREAT | os.O_EXCL, 0o666) but without os.O_CLOEXEC. Unlike tempfile.mkstemp() (which uses close-on-exec), this can leak the FD into any subsequently exec'd process before the context manager closes it. Consider adding os.O_CLOEXEC to the os.open flags (and/or using tempfile APIs that ensure CLOEXEC) to keep behavior consistent and avoid FD leaks.

Suggested change
fd = os.open(tmp_path, base_flags | os.O_CREAT | os.O_EXCL, 0o666)
fd = os.open(
tmp_path,
base_flags | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0),
0o666,
)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@DavidePrincipi DavidePrincipi Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://docs.python.org/3.11/library/os.html#inheritance-of-file-descriptors

FDs are not inheritable by default

[root@rl1 state]# strace python3 -c  'import agent
with agent.safe_open("x") as fd:
   print("x", file=fd)

'

...
openat(AT_FDCWD, "./.x.tmp-61119-0", O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC, 0666) = 3

@DavidePrincipi DavidePrincipi added the testing Start test suite label Mar 12, 2026
@DavidePrincipi DavidePrincipi linked an issue Mar 13, 2026 that may be closed by this pull request
@DavidePrincipi DavidePrincipi changed the base branch from main to rel3181 March 13, 2026 11:02
@DavidePrincipi DavidePrincipi merged commit cbe667f into rel3181 Mar 13, 2026
9 of 11 checks passed
@DavidePrincipi DavidePrincipi deleted the copilot/fix-file-permission-handling branch March 13, 2026 11:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

testing Start test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

safeio.py creates files with wrong permissions

6 participants