The SFTP server answers SSH_FX_OK to an lsetstat@openssh.com request carrying
SSH_FILEXFER_ATTR_PERMISSIONS and changes neither the link's mode nor the target's, on any
platform where os.chmod does not support follow_symlinks=False (Linux, notably — there is no
lchmod, and fchmodat(AT_SYMLINK_NOFOLLOW) answers ENOTSUP).
A client that asked not to follow the link is told the permission change happened. It did not, and
nothing in the response distinguishes that from success.
This is narrow: it is one flag, not the extension. ACMODTIME over the same request works
correctly — the link's own timestamp really changes — so lsetstat itself is fine.
Reproducer
asyncssh client against asyncssh server, one process, no third-party code.
import asyncio, os, stat, tempfile
from pathlib import Path
import asyncssh
class NoAuthServer(asyncssh.SSHServer):
def begin_auth(self, username):
return False
def mode_of(path, *, follow):
return format(stat.S_IMODE((os.stat if follow else os.lstat)(path).st_mode), "04o")
async def main():
root = Path(tempfile.mkdtemp())
target, link = root / "target", root / "link"
target.write_bytes(b"payload")
os.chmod(target, 0o600)
os.symlink(target, link)
server = await asyncssh.create_server(
NoAuthServer, "127.0.0.1", 0,
server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")],
sftp_factory=True,
)
port = server.sockets[0].getsockname()[1]
async with asyncssh.connect(
"127.0.0.1", port, username="probe", known_hosts=None
) as conn, conn.start_sftp_client() as sftp:
print(f"before: link {mode_of(link, follow=False)} target {mode_of(target, follow=True)}")
await sftp.chmod(str(link), 0o640, follow_symlinks=False)
print("server answered SSH_FX_OK (no exception raised)")
print(f"after: link {mode_of(link, follow=False)} target {mode_of(target, follow=True)}")
# The control: the same request shape with ACMODTIME instead, which works.
before = os.lstat(link).st_mtime_ns, os.stat(target).st_mtime_ns
await sftp.utime(str(link), (1_000_000_000, 1_000_000_000), follow_symlinks=False)
print(f"link mtime changed: {os.lstat(link).st_mtime_ns != before[0]}")
print(f"target mtime changed: {os.stat(target).st_mtime_ns != before[1]}")
server.close()
await server.wait_closed()
asyncio.run(main())
Output, asyncssh 2.24.0, CPython 3.13.15, Linux 6.12 aarch64:
before: link 0777 target 0600
server answered SSH_FX_OK (no exception raised)
after: link 0777 target 0600
link mtime changed: True
target mtime changed: False
(0777 is what a Linux symlink always reports; the point is that neither it nor the target moved.)
Where it comes from
SFTPServer.lsetstat calls _setstat(..., follow_symlinks=False), and that function swallows the
exception CPython raises — asyncssh/sftp.py:632-637 in 2.24.0, unchanged on develop at
a787030:
if attrs.permissions is not None:
try:
os.chmod(path, stat.S_IMODE(attrs.permissions),
follow_symlinks=follow_symlinks)
except NotImplementedError: # pragma: no cover
pass
On Linux, os.chmod not in os.supports_follow_symlinks, so that call raises
NotImplementedError: chmod: follow_symlinks unavailable on this platform and the request is
discarded. _process_lsetstat then returns SSH_FX_OK, because as far as it can tell the handler
succeeded.
The two neighbouring clauses (os.chown, os.utime) have the same except NotImplementedError: pass, but both of those are in os.supports_follow_symlinks on Linux, so they never fire —
which is why ACMODTIME works and only PERMISSIONS is dropped.
SSH_FILEXFER_ATTR_UIDGID is untested here: an unprivileged reproducer can only chown a file to
the uid it already has, so "nothing moved" is the expected result either way.
Why it seems worth fixing rather than documenting
A caller passing follow_symlinks=False is making a security decision — do not act on whatever
this link points at. Three outcomes are possible and SSH_FX_OK names none of them: the link's
mode changed, the target's changed (the link was followed, which is the thing being refused), or
nothing changed. There is no way to tell them apart without statting both files afterwards.
OpenSSH's sftp-server refuses the same request on the same kernel, with SSH_FX_FAILURE. So two
servers advertising the same extension give opposite answers, and the one that says yes is the one
that did nothing.
Suggested fix
SSH_FX_OP_UNSUPPORTED is what the protocol has for this, and asyncssh already produces it —
SFTPServerHandler._process_packet maps NotImplementedError to FX_OP_UNSUPPORTED
(sftp.py:6058). The swallow one layer down is the only thing preventing it.
Removing just that try/except (nothing else) gives the client:
SFTPOpUnsupported code=8 reason='Operation not supported: lsetstat'
Whether the other two clauses should also stop swallowing is a judgement call — on Linux they are
unreachable, but the same silent-success shape is available to them on a platform where those
os.* calls lack follow_symlinks support.
Worth noting that the # pragma: no cover on the clause means no test exercises it, which is
presumably why this has gone unnoticed.
Versions
- asyncssh 2.24.0 (latest release), and
develop at a787030 is unchanged
- CPython 3.13.15
- Linux 6.12.76 aarch64
The SFTP server answers
SSH_FX_OKto anlsetstat@openssh.comrequest carryingSSH_FILEXFER_ATTR_PERMISSIONSand changes neither the link's mode nor the target's, on anyplatform where
os.chmoddoes not supportfollow_symlinks=False(Linux, notably — there is nolchmod, andfchmodat(AT_SYMLINK_NOFOLLOW)answersENOTSUP).A client that asked not to follow the link is told the permission change happened. It did not, and
nothing in the response distinguishes that from success.
This is narrow: it is one flag, not the extension.
ACMODTIMEover the same request workscorrectly — the link's own timestamp really changes — so
lsetstatitself is fine.Reproducer
asyncssh client against asyncssh server, one process, no third-party code.
Output, asyncssh 2.24.0, CPython 3.13.15, Linux 6.12 aarch64:
(
0777is what a Linux symlink always reports; the point is that neither it nor the target moved.)Where it comes from
SFTPServer.lsetstatcalls_setstat(..., follow_symlinks=False), and that function swallows theexception CPython raises —
asyncssh/sftp.py:632-637in 2.24.0, unchanged ondevelopata787030:On Linux,
os.chmod not in os.supports_follow_symlinks, so that call raisesNotImplementedError: chmod: follow_symlinks unavailable on this platformand the request isdiscarded.
_process_lsetstatthen returnsSSH_FX_OK, because as far as it can tell the handlersucceeded.
The two neighbouring clauses (
os.chown,os.utime) have the sameexcept NotImplementedError: pass, but both of those are inos.supports_follow_symlinkson Linux, so they never fire —which is why
ACMODTIMEworks and onlyPERMISSIONSis dropped.SSH_FILEXFER_ATTR_UIDGIDis untested here: an unprivileged reproducer can onlychowna file tothe uid it already has, so "nothing moved" is the expected result either way.
Why it seems worth fixing rather than documenting
A caller passing
follow_symlinks=Falseis making a security decision — do not act on whateverthis link points at. Three outcomes are possible and
SSH_FX_OKnames none of them: the link'smode changed, the target's changed (the link was followed, which is the thing being refused), or
nothing changed. There is no way to tell them apart without statting both files afterwards.
OpenSSH's
sftp-serverrefuses the same request on the same kernel, withSSH_FX_FAILURE. So twoservers advertising the same extension give opposite answers, and the one that says yes is the one
that did nothing.
Suggested fix
SSH_FX_OP_UNSUPPORTEDis what the protocol has for this, and asyncssh already produces it —SFTPServerHandler._process_packetmapsNotImplementedErrortoFX_OP_UNSUPPORTED(
sftp.py:6058). The swallow one layer down is the only thing preventing it.Removing just that
try/except(nothing else) gives the client:Whether the other two clauses should also stop swallowing is a judgement call — on Linux they are
unreachable, but the same silent-success shape is available to them on a platform where those
os.*calls lackfollow_symlinkssupport.Worth noting that the
# pragma: no coveron the clause means no test exercises it, which ispresumably why this has gone unnoticed.
Versions
developata787030is unchanged