Skip to content

Commit d9940ea

Browse files
wolfsftp: reject symlink leaf in SFTP_RecvOpen
1 parent ba09b58 commit d9940ea

3 files changed

Lines changed: 102 additions & 6 deletions

File tree

src/wolfsftp.c

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1816,6 +1816,22 @@ static int SFTP_IsSymlink(const char* path)
18161816
#endif /* WOLFSSH_HAVE_SYMLINK */
18171817

18181818

1819+
/* Length of the confining prefix of an SFTP default path: its length with any
1820+
* trailing separators stripped (a lone "/" is kept). A result > 1 means the
1821+
* session is confined to something deeper than the filesystem root. Defined
1822+
* once here so the confinement gate stays consistent between GetAndCleanPath
1823+
* and SFTP_SessionConfined. Caller must pass a non-NULL path. */
1824+
static word32 SFTP_ConfinedPathLen(const char* defaultPath)
1825+
{
1826+
word32 dpLen = (word32)WSTRLEN(defaultPath);
1827+
1828+
while (dpLen > 1 && WOLFSSH_SFTP_IS_DELIM(defaultPath[dpLen - 1])) {
1829+
dpLen--;
1830+
}
1831+
return dpLen;
1832+
}
1833+
1834+
18191835
/*
18201836
* This is a wrapper around the function wolfSSH_RealPath. Since it modifies
18211837
* the source path value, copy the path from the data stream into a local
@@ -1845,11 +1861,7 @@ static int GetAndCleanPath(const char* defaultPath,
18451861
/* defaultPath is stored in canonical form by
18461862
* wolfSSH_SFTP_SetDefaultPath, so a direct prefix compare against the
18471863
* canonical resolved request path enforces confinement. */
1848-
dpLen = (word32)WSTRLEN(defaultPath);
1849-
/* strip trailing separator(s), but keep a lone "/" as-is */
1850-
while (dpLen > 1 && WOLFSSH_SFTP_IS_DELIM(defaultPath[dpLen - 1])) {
1851-
dpLen--;
1852-
}
1864+
dpLen = SFTP_ConfinedPathLen(defaultPath);
18531865
if (dpLen > 1) {
18541866
/* resolved path must equal the default path or be within its
18551867
* subtree. On Windows the filesystem is case-insensitive and the
@@ -1911,6 +1923,22 @@ static int GetAndCleanPath(const char* defaultPath,
19111923
}
19121924

19131925

1926+
#ifndef USE_WINDOWS_API
1927+
/* Returns 1 when the session is confined to a default path deeper than the
1928+
* filesystem root. This mirrors the gate GetAndCleanPath uses for its
1929+
* per-component symlink check, so the open-time symlink defenses stay limited
1930+
* to confined sessions and do not change behavior for servers that
1931+
* intentionally follow symlinks (the default, like OpenSSH's sftp-server). */
1932+
static int SFTP_SessionConfined(const char* defaultPath)
1933+
{
1934+
if (defaultPath == NULL) {
1935+
return 0;
1936+
}
1937+
return (SFTP_ConfinedPathLen(defaultPath) > 1) ? 1 : 0;
1938+
}
1939+
#endif /* !USE_WINDOWS_API */
1940+
1941+
19141942
/* Builds a status packet and queues it for send.
19151943
* Returns WS_SUCCESS on success; ssh takes ownership of the allocated buffer. */
19161944
static int SFTP_SendStatus(WOLFSSH* ssh, byte type, int reqId, const char* msg)
@@ -2271,6 +2299,7 @@ int wolfSSH_SFTP_RecvOpen(WOLFSSH* ssh, int reqId, byte* data, word32 maxSz)
22712299
int rc;
22722300
int fdOpened = 0;
22732301
int outOwnedBySsh = 0;
2302+
int confined;
22742303

22752304
word32 outSz = sizeof(WFD) + UINT32_SZ + WOLFSSH_SFTP_HEADER;
22762305
byte* out = NULL;
@@ -2290,6 +2319,10 @@ int wolfSSH_SFTP_RecvOpen(WOLFSSH* ssh, int reqId, byte* data, word32 maxSz)
22902319

22912320
WLOG(WS_LOG_SFTP, "Receiving WOLFSSH_FTP_OPEN");
22922321

2322+
/* only apply the symlink-follow defenses when the session is confined, so
2323+
* unconfined servers keep following symlinks as they did before */
2324+
confined = SFTP_SessionConfined(ssh->sftpDefaultPath);
2325+
22932326
#if defined(MICROCHIP_MPLAB_HARMONY) || defined(FREESCALE_MQX)
22942327
fd = WBADFILE;
22952328
#else
@@ -2369,7 +2402,8 @@ int wolfSSH_SFTP_RecvOpen(WOLFSSH* ssh, int reqId, byte* data, word32 maxSz)
23692402
WS_SFTP_FILEATRB fileAtr;
23702403
WMEMSET(&fileAtr, 0, sizeof(fileAtr));
23712404
if (SFTP_GetAttributes(ssh->fs,
2372-
dir, &fileAtr, 0, ssh->ctx->heap) == WS_SUCCESS) {
2405+
dir, &fileAtr, (byte)confined, ssh->ctx->heap)
2406+
== WS_SUCCESS) {
23732407
if ((fileAtr.per & FILEATRB_PER_MASK_TYPE)
23742408
!= FILEATRB_PER_FILE) {
23752409
ssh->error = WS_SFTP_NOT_FILE_E;
@@ -2391,6 +2425,13 @@ int wolfSSH_SFTP_RecvOpen(WOLFSSH* ssh, int reqId, byte* data, word32 maxSz)
23912425
atr.per = 0644;
23922426
}
23932427

2428+
/* when confined, refuse to follow a symlink leaf, closing the TOCTOU
2429+
* window between the type check above and the open below. No-op where
2430+
* the platform has no O_NOFOLLOW (WOLFSSH_O_NOFOLLOW is 0). */
2431+
if (confined) {
2432+
m |= WOLFSSH_O_NOFOLLOW;
2433+
}
2434+
23942435
#ifdef MICROCHIP_MPLAB_HARMONY
23952436
{
23962437
WFILE* f = &fd;

tests/api.c

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1702,6 +1702,16 @@ static void test_wolfSSH_SFTP_Confinement(void)
17021702
* run. */
17031703
char escSymlink[] = "confine_symlink";
17041704
char escSymThru[WOLFSSH_MAX_FILENAME];
1705+
/* an in-jail symlink whose leaf points at an in-jail regular file. The
1706+
* path resolves and stays inside the jail and the target is a real file.
1707+
* This block is guarded by WOLFSSH_HAVE_SYMLINK, so GetAndCleanPath's
1708+
* per-component link check is always present here and rejects the leaf
1709+
* symlink first; the assertion therefore verifies the end-to-end "open a
1710+
* symlinked file is refused" behavior. The new RecvOpen lstat/O_NOFOLLOW
1711+
* handling is defense-in-depth behind that check, closing the TOCTOU window
1712+
* between the type check and the open. */
1713+
char leafTarget[] = "confine_leaf_target";
1714+
char leafSymlink[] = "confine_leaf_symlink";
17051715
#endif
17061716
WFILE* fp = NULL;
17071717
int snLen;
@@ -1792,6 +1802,14 @@ static void test_wolfSSH_SFTP_Confinement(void)
17921802
/* stage an in-jail symlink pointing at the out-of-jail temp root */
17931803
WREMOVE(NULL, escSymlink);
17941804
AssertIntEQ(symlink(escRoot, escSymlink), 0);
1805+
1806+
/* stage an in-jail regular file and an in-jail symlink that targets it */
1807+
WREMOVE(NULL, leafTarget);
1808+
AssertIntEQ(WFOPEN(NULL, &fp, leafTarget, "wb"), 0);
1809+
AssertNotNull(fp);
1810+
WFCLOSE(NULL, fp);
1811+
WREMOVE(NULL, leafSymlink);
1812+
AssertIntEQ(symlink(leafTarget, leafSymlink), 0);
17951813
#endif
17961814
#endif
17971815

@@ -1954,6 +1972,32 @@ static void test_wolfSSH_SFTP_Confinement(void)
19541972
AssertNotNull(ls);
19551973
wolfSSH_SFTPNAME_list_free(ls);
19561974
ls = NULL;
1975+
1976+
/* Positive control: opening the in-jail regular file directly must still
1977+
* succeed, proving the symlink rejection below does not also block plain
1978+
* regular-file opens. */
1979+
handleSz = WOLFSSH_MAX_HANDLE;
1980+
ret = wolfSSH_SFTP_Open(ssh, leafTarget, WOLFSSH_FXF_READ, NULL,
1981+
handle, &handleSz);
1982+
AssertIntEQ(ret, WS_SUCCESS);
1983+
AssertIntEQ(wolfSSH_SFTP_Close(ssh, handle, handleSz), WS_SUCCESS);
1984+
ls = wolfSSH_SFTP_LS(ssh, curDir);
1985+
AssertNotNull(ls);
1986+
wolfSSH_SFTPNAME_list_free(ls);
1987+
ls = NULL;
1988+
1989+
/* opening a leaf that is itself a symlink to an in-jail regular file must
1990+
* be rejected, not silently followed to the target. On this build the
1991+
* GetAndCleanPath per-component check rejects it first; the RecvOpen
1992+
* lstat/O_NOFOLLOW path is the defense-in-depth backstop. */
1993+
handleSz = WOLFSSH_MAX_HANDLE;
1994+
ret = wolfSSH_SFTP_Open(ssh, leafSymlink, WOLFSSH_FXF_READ, NULL,
1995+
handle, &handleSz);
1996+
AssertIntNE(ret, WS_SUCCESS);
1997+
ls = wolfSSH_SFTP_LS(ssh, curDir);
1998+
AssertNotNull(ls);
1999+
wolfSSH_SFTPNAME_list_free(ls);
2000+
ls = NULL;
19572001
#endif
19582002

19592003
/* Positive case: a relative write op that resolves inside the jail must be
@@ -2005,6 +2049,8 @@ static void test_wolfSSH_SFTP_Confinement(void)
20052049
#if !defined(WOLFSSH_ZEPHYR) && !defined(USE_WINDOWS_API)
20062050
#ifdef WOLFSSH_HAVE_SYMLINK
20072051
WREMOVE(NULL, escSymlink);
2052+
WREMOVE(NULL, leafSymlink);
2053+
WREMOVE(NULL, leafTarget);
20082054
#endif
20092055
WRMDIR(NULL, escRoot);
20102056
/* escSibling is non-empty only if this run created it (see staging above),

wolfssh/port.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,6 +1484,9 @@ extern "C" {
14841484
#define WOLFSSH_O_CREAT O_CREAT
14851485
#define WOLFSSH_O_TRUNC O_TRUNC
14861486
#define WOLFSSH_O_EXCL O_EXCL
1487+
#ifdef O_NOFOLLOW
1488+
#define WOLFSSH_O_NOFOLLOW O_NOFOLLOW
1489+
#endif
14871490

14881491
#define WOPEN(fs,f,m,p) open((f),(m),(p))
14891492
#define WCLOSE(fs,fd) close((fd))
@@ -1505,6 +1508,12 @@ extern "C" {
15051508
#endif
15061509
#endif /* WOLFSSH_SFTP or WOLFSSH_SCP */
15071510

1511+
/* Platforms without an open(2)-style O_NOFOLLOW (or without a filesystem) get a
1512+
* harmless no-op so callers can OR it into the open flags unconditionally. */
1513+
#ifndef WOLFSSH_O_NOFOLLOW
1514+
#define WOLFSSH_O_NOFOLLOW 0
1515+
#endif
1516+
15081517
/* SFTP path confinement must reject symbolic links because wolfSSH_RealPath
15091518
* only normalizes the path string and does not resolve them. Define
15101519
* WOLFSSH_HAVE_SYMLINK on the filesystems that can actually store a link -

0 commit comments

Comments
 (0)