Skip to content

Security hardening: improve file handling and socket permissions#1

Open
assisted-by-ai wants to merge 1 commit intoKicksecure:masterfrom
assisted-by-ai:claude/security-audit-5BmJL
Open

Security hardening: improve file handling and socket permissions#1
assisted-by-ai wants to merge 1 commit intoKicksecure:masterfrom
assisted-by-ai:claude/security-audit-5BmJL

Conversation

@assisted-by-ai
Copy link
Copy Markdown

Summary

This PR improves security and robustness across the privleap codebase by hardening file permission handling, refining socket creation with proper umask management, and fixing several edge cases in configuration parsing and PAM authentication.

Key Changes

File and Socket Permission Hardening

  • Set restrictive umask (0o177) when binding UNIX sockets to prevent world-readable socket creation
  • Changed state and communication directory permissions from 0o755 to 0o711 to restrict directory listing while maintaining execute access
  • Improved socket cleanup logic to distinguish between missing files (warning) and actual errors (error level)

Configuration Parsing Improvements

  • Refactored config directory permission checking to use file descriptor-based validation instead of path-based checks
  • Added proper error handling for directory opening failures
  • Fixed config file name validation to use only the filename instead of full path
  • Ensured file descriptors are properly closed with try/finally blocks

PAM Authentication Fix

  • Separated account management PAM session from credential initialization PAM session
  • Account management now uses a dedicated pam_acct_obj instance
  • Credential initialization uses a fresh pam_obj instance with proper user context
  • This prevents credential state from being mixed between different PAM operations

Regex Pattern Refinements

  • Removed path separators (. and /) from config file regex pattern to prevent directory traversal
  • Added anchor (\Z) to uid_regex for stricter matching
  • These changes prevent malformed or malicious filenames from being processed

Notable Implementation Details

  • Socket binding now properly restores the original umask after creation
  • Configuration directory validation uses os.open() with O_RDONLY | O_DIRECTORY flags for safer file descriptor operations
  • Exception handling is more granular, distinguishing between FileNotFoundError and other exceptions for appropriate logging levels

https://claude.ai/code/session_01W6kXMiJ5ULwNV9cega66Dx

- Fix TOCTOU race in socket creation by setting umask 0177 before bind()
- Fix config directory permission check to use file descriptor instead of path
- Fix config_file_regex to reject path traversal characters (. and /)
- Fix uid_regex missing \Z end anchor allowing trailing garbage
- Tighten state_dir and comm_dir permissions from 0755 to 0711
- Fix TOCTOU race in socket cleanup by removing exists() check
- Use separate PAM handles for account check vs session to prevent policy leak

https://claude.ai/code/session_01W6kXMiJ5ULwNV9cega66Dx
Copy link
Copy Markdown

@ArrayBolt3 ArrayBolt3 left a comment

Choose a reason for hiding this comment

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

Partially accepted in ArrayBolt3@763ba07. Notes below.

Comment on lines -1051 to +1055
self.backend_socket.bind(str(PrivleapCommon.control_path))
old_umask = os.umask(0o177)
try:
self.backend_socket.bind(str(PrivleapCommon.control_path))
finally:
os.umask(old_umask)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I don't see this as being a problem. If the socket setup fails, maybe it will leave a socket with bad permissions on disk, but the application won't use it because socket setup failed. If the socket setup succeeds, the ownership and permissions are set properly, so the umask isn't needed. privleapd already sets a restrictive umask anyway.

Comment on lines -1078 to +1086
self.backend_socket.bind(str(socket_path))
old_umask = os.umask(0o177)
try:
self.backend_socket.bind(str(socket_path))
finally:
os.umask(old_umask)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ditto.

Comment on lines -1229 to +1237
config_file_regex: re.Pattern[str] = re.compile(r"[-A-Za-z0-9_./]+\.conf\Z")
config_file_regex: re.Pattern[str] = re.compile(r"[-A-Za-z0-9_]+\.conf\Z")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Accepted.

Comment on lines -1231 to +1239
uid_regex: re.Pattern[str] = re.compile(r"[0-9]+")
uid_regex: re.Pattern[str] = re.compile(r"[0-9]+\Z")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Accepted.

Comment on lines -429 to +445
if socket_path.exists():
try:
socket_path.unlink()
except Exception as e:
## Probably just a TOCTOU issue, i.e. someone already
## removed the socket. Most likely caused by the user
## fiddling with things, no big deal.
logging.error(
"Destroying comm socket for account '%s', failed to "
"delete UNIX socket at '%s'",
real_user_name,
str(socket_path),
exc_info=e,
)
else:
try:
socket_path.unlink()
except FileNotFoundError:
logging.warning(
"Destroying comm socket for account '%s', no UNIX socket "
"to delete at '%s'",
real_user_name,
str(socket_path),
)
except Exception as e:
logging.error(
"Destroying comm socket for account '%s', failed to "
"delete UNIX socket at '%s'",
real_user_name,
str(socket_path),
exc_info=e,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Accepted.

Comment on lines -1401 to +1417
if not PrivleapCommon.check_secure_file_permissions(str(config_dir)):
try:
config_dir_fd: int = os.open(
str(config_dir), os.O_RDONLY | os.O_DIRECTORY
)
except OSError as e:
logging.warning(
"Config directory '%s' exists but has insecure permissions, "
"ignoring all files in this directory.",
"Config directory '%s' could not be opened, skipping.",
str(config_dir),
exc_info=e,
)
continue
try:
if not PrivleapCommon.check_secure_file_permissions(config_dir_fd):
logging.warning(
"Config directory '%s' exists but has insecure "
"permissions, ignoring all files in this directory.",
str(config_dir),
)
continue
finally:
os.close(config_dir_fd)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I get the reasoning here, but disagree with it. The original code is easier to read, and while it is arguably susceptible to a TOCTOU, in practice a TOCTOU doesn't matter here. If an attacker can change the permissions from secure to insecure, swap out the directory, etc. between the check and the open, they already have the ability to write anything they want into the configuration directory and compromise the system, or set up the permissions to be correct before the swap. This patch does plug the TOCTOU, but in practice that does nothing for security.

Comment on lines -1442 to +1452
str(config_file), PrivleapValidateType.CONFIG_FILE
config_file.name, PrivleapValidateType.CONFIG_FILE
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Accepted.

Comment on lines -1489 to +1499
PrivleapCommon.state_dir.chmod(0o755)
PrivleapCommon.state_dir.chmod(0o711)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This will break the ability to list the directory contents. I don't think being able to list the contents is a risk.

Comment on lines -1507 to +1517
PrivleapCommon.comm_dir.chmod(0o755)
PrivleapCommon.comm_dir.chmod(0o711)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ditto. (This would have the advantage of hiding the list of active users from others, but most of that info can be gotten using loginctl, so I don't think that's particularly valuable.)

Comment on lines -80 to +95
pam_obj: Any = PAM.pam()
pam_obj.start("privleapd")
pam_obj.set_item(PAM.PAM_USER, calling_user)
pam_obj.set_item(PAM.PAM_RUSER, calling_user)
pam_acct_obj: Any = PAM.pam()
pam_acct_obj.start("privleapd")
pam_acct_obj.set_item(PAM.PAM_USER, calling_user)
pam_acct_obj.set_item(PAM.PAM_RUSER, calling_user)
try:
pam_obj.acct_mgmt()
pam_acct_obj.acct_mgmt()
except PAM.error as e:
if e.args[1] == PAM.PAM_NEW_AUTHTOK_REQD:
pass
else:
sys.exit(255)

pam_obj: Any = PAM.pam()
pam_obj.start("privleapd")
pam_obj.set_item(PAM.PAM_USER, target_user)
pam_obj.set_item(PAM.PAM_RUSER, calling_user)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I don't understand why this is useful. The PAM documentation doesn't seem to hint at why this might be desirable, and sudo doesn't seem to make separate PAM sessions for this scenario, so rejecting for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants