Skip to content

Commit 5c696f9

Browse files
Reject symlinks in default SCP send callback
1 parent ba09b58 commit 5c696f9

5 files changed

Lines changed: 129 additions & 38 deletions

File tree

scripts/scp.test

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ do_cleanup() {
5454
kill -9 $server_pid
5555
fi
5656
remove_ready_file
57+
# remove symlink-test artifacts so an early exit (e.g. create_port failure)
58+
# does not leave a planted secret/symlink behind in the source tree
59+
[ -n "$scp_secret" ] && rm -f "$scp_secret" "$scp_symlink" "$scp_symlink_out"
5760
}
5861

5962
do_trap() {
@@ -150,6 +153,46 @@ if test $RESULT -eq 0; then
150153
exit 1
151154
fi
152155

156+
echo "Test that the server refuses to follow a symlink (server to local)"
157+
# This exercises the single-file send path (WOLFSSH_SCP_SINGLE_FILE_REQUEST).
158+
# The recursive directory-traversal sink in ScpProcessEntry uses the same shared
159+
# WS_IsSymlink() helper but cannot be driven from here: the wolfSSH example
160+
# client (wolfSSH_SCP_from) only ever issues "scp -f <path>", never "scp -f -r",
161+
# so the server's recursive request state is never reached by this harness.
162+
# WS_IsSymlink itself is additionally exercised through the SFTP confinement
163+
# unit test (test_wolfSSH_SFTP_Confinement), so the detection logic shared by
164+
# both sinks is covered even though the SCP recursive wiring is not driven e2e.
165+
scp_secret=$PWD/scripts/scp_secret_$$
166+
scp_symlink=$PWD/scp_symlink_$$
167+
scp_symlink_out=$PWD/scp_symlink_out_$$
168+
echo "TOP SECRET SYMLINK TARGET" > $scp_secret
169+
# A symlink whose target exists would, without the fix, be opened and its
170+
# contents streamed to the client. The fix must reject it instead.
171+
ln -s $scp_secret $scp_symlink
172+
if test -L $scp_symlink; then
173+
./examples/echoserver/echoserver -1 -R $ready_file &
174+
server_pid=$!
175+
create_port
176+
./examples/scpclient/wolfscp -u jill -P upthehill -p $port -S $scp_symlink:$scp_symlink_out
177+
remove_ready_file
178+
179+
# The server must not deliver the symlink target: no local output file may
180+
# be produced from the refused request. The client exit code is not asserted
181+
# (as with the other -S cases it does not propagate a server-side SCP abort);
182+
# connectivity in this environment is already proven by the "basic copy from
183+
# server to local" case above, which uses the same mechanism and aborts the
184+
# whole script on failure, so this cannot pass vacuously.
185+
if test -e $scp_symlink_out; then
186+
rm -f $scp_symlink_out $scp_secret $scp_symlink
187+
echo -e "\n\nserver followed a symlink, confinement bypass"
188+
do_cleanup
189+
exit 1
190+
fi
191+
else
192+
echo "symlink not supported on this filesystem, skipping symlink test"
193+
fi
194+
rm -f $scp_secret $scp_symlink $scp_symlink_out
195+
153196
echo -e "\nALL Tests Passed"
154197

155198
exit 0

src/port.c

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,44 @@ int WS_DeleteFileA(const char* fileName, void* heap)
527527

528528
#endif /* USE_WINDOWS_API WOLFSSH_SFTP WOLFSSH_SCP */
529529

530+
#if defined(WOLFSSH_HAVE_SYMLINK) && \
531+
(defined(WOLFSSH_SFTP) || defined(WOLFSSH_SCP))
532+
/* Returns 1 if path is a symbolic link (POSIX) or a reparse point such as a
533+
* symlink or junction (Windows), otherwise 0. A NULL or non-existent path
534+
* (stat fails) is reported as not-a-link. Shared by the SFTP path-confinement
535+
* check and the SCP send guards: both must reject a link before a file
536+
* operation follows it, and one implementation keeps that security-relevant
537+
* test from drifting between the two. */
538+
int WS_IsSymlink(const char* path)
539+
{
540+
int isLink = 0;
541+
#ifdef USE_WINDOWS_API
542+
WIN32_FILE_ATTRIBUTE_DATA attrs;
543+
#else
544+
WSTAT_T lst;
545+
#endif
546+
547+
if (path == NULL)
548+
return 0;
549+
550+
#ifdef USE_WINDOWS_API
551+
/* GetFileAttributesEx reports the link's own attributes; it does not follow
552+
* the reparse point. WS_GetFileAttributesExA trims any leading slash from
553+
* the SFTP-canonical "/C:/..." form and uses the wide-char API like every
554+
* other Windows file op here. */
555+
if (WS_GetFileAttributesExA(path, &attrs, NULL) != 0 &&
556+
(attrs.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
557+
isLink = 1;
558+
}
559+
#else
560+
if (WLSTAT(NULL, path, &lst) == 0 && S_ISLNK(lst.st_mode)) {
561+
isLink = 1;
562+
}
563+
#endif
564+
return isLink;
565+
}
566+
#endif /* WOLFSSH_HAVE_SYMLINK && (WOLFSSH_SFTP || WOLFSSH_SCP) */
567+
530568
#if !defined(NO_FILESYSTEM) && \
531569
defined(WOLFSSH_ZEPHYR) && (defined(WOLFSSH_SFTP) || defined(WOLFSSH_SCP))
532570

src/wolfscp.c

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2610,6 +2610,18 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
26102610
WSTRNCPY(fileName, sendCtx->entry->d_name,
26112611
DEFAULT_SCP_FILE_NAME_SZ);
26122612
#endif
2613+
#ifdef WOLFSSH_HAVE_SYMLINK
2614+
/* filePath is now fully built. Reject a planted symlink here,
2615+
* before GetFileStats (which classifies via WSTAT and follows
2616+
* links) or any later descend/open follows it - the whole point is
2617+
* to refuse the link before any file operation traverses it. */
2618+
if (ret == WS_SUCCESS && WS_IsSymlink(filePath)) {
2619+
WLOG(WS_LOG_ERROR,
2620+
"scp: symlink entry rejected, aborting transfer");
2621+
ret = WS_SCP_ABORT;
2622+
}
2623+
#endif /* WOLFSSH_HAVE_SYMLINK */
2624+
26132625
if (ret == WS_SUCCESS) {
26142626
ret = GetFileStats(ssh->fs, sendCtx, filePath, mTime, aTime, fileMode);
26152627
}
@@ -2652,7 +2664,10 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
26522664
}
26532665

26542666
} else {
2655-
if (ret != WS_NEXT_ERROR) {
2667+
/* WS_SCP_ABORT entries (e.g. a rejected symlink) were already logged at
2668+
* their source, so only the generic, unexpected-error case is noted
2669+
* here to avoid a misleading second log line. */
2670+
if (ret != WS_NEXT_ERROR && ret != WS_SCP_ABORT) {
26562671
WLOG(WS_LOG_ERROR, "scp: ret does not equal WS_NEXT_ERROR, abort");
26572672
ret = WS_SCP_ABORT;
26582673
}
@@ -2771,8 +2786,19 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
27712786
break;
27722787

27732788
case WOLFSSH_SCP_SINGLE_FILE_REQUEST:
2774-
if ((sendCtx == NULL) || WFOPEN(ssh->fs, &(sendCtx->fp), peerRequest,
2775-
"rb") != 0) {
2789+
#ifdef WOLFSSH_HAVE_SYMLINK
2790+
/* WFOPEN follows symlinks; refuse to open one so its target is not
2791+
* streamed to the peer. */
2792+
if (WS_IsSymlink(peerRequest)) {
2793+
WLOG(WS_LOG_ERROR, "scp: refusing to open symlink, abort");
2794+
wolfSSH_SetScpErrorMsg(ssh, "unable to open file for reading");
2795+
ret = WS_BAD_FILE_E;
2796+
}
2797+
#endif /* WOLFSSH_HAVE_SYMLINK */
2798+
2799+
if (ret == WS_SUCCESS &&
2800+
((sendCtx == NULL) || WFOPEN(ssh->fs, &(sendCtx->fp),
2801+
peerRequest, "rb") != 0)) {
27762802

27772803
WLOG(WS_LOG_ERROR, "scp: unable to open file, abort");
27782804
wolfSSH_SetScpErrorMsg(ssh, "unable to open file for reading");
@@ -2828,7 +2854,17 @@ int wsScpSendCallback(WOLFSSH* ssh, int state, const char* peerRequest,
28282854

28292855
if (ScpDirStackIsEmpty(sendCtx)) {
28302856

2831-
/* first request, keep track of request directory */
2857+
/* first request, keep track of request directory. The
2858+
* peer-named recursive root is intentionally not run through
2859+
* WS_IsSymlink here: unlike SFTP there is no library-level
2860+
* trusted base path to contain a resolved root against (SCP
2861+
* confinement in wolfsshd is enforced by OS chroot, inside
2862+
* which the kernel already prevents a symlink from escaping),
2863+
* and wolfSSH_RealPath does not resolve links so a "stays under
2864+
* the root" check is not possible. Symlinks discovered while
2865+
* traversing below the root are still rejected by
2866+
* ScpProcessEntry, which is the planted-link threat this guards
2867+
* against. */
28322868
ret = ScpPushDir(ssh->fs, sendCtx, peerRequest, ssh->ctx->heap);
28332869

28342870
if (ret == WS_SUCCESS) {

src/wolfsftp.c

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1783,39 +1783,6 @@ int wolfSSH_SFTP_CreateStatus(WOLFSSH* ssh, word32 status, word32 reqId,
17831783
}
17841784

17851785

1786-
#ifdef WOLFSSH_HAVE_SYMLINK
1787-
/* Returns 1 if path is a symbolic link (POSIX) or a reparse point such as a
1788-
* symlink or junction (Windows), otherwise 0. A non-existent path (stat
1789-
* fails) is reported as not-a-link so that create requests for a new leaf are
1790-
* still permitted by the caller. */
1791-
static int SFTP_IsSymlink(const char* path)
1792-
{
1793-
int isLink = 0;
1794-
#ifdef USE_WINDOWS_API
1795-
WIN32_FILE_ATTRIBUTE_DATA attrs;
1796-
1797-
/* GetAndCleanPath produces an SFTP-canonical "/C:/..." path. Route it
1798-
* through WS_GetFileAttributesExA, which trims the leading slash and uses
1799-
* the wide-char API like every other Windows file op here; calling
1800-
* GetFileAttributesA on the raw "/C:/..." form would always fail and
1801-
* silently disable link detection. GetFileAttributesEx reports the link's
1802-
* own attributes (it does not follow the reparse point). */
1803-
if (WS_GetFileAttributesExA(path, &attrs, NULL) != 0 &&
1804-
(attrs.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
1805-
isLink = 1;
1806-
}
1807-
#else
1808-
WSTAT_T lst;
1809-
1810-
if (WLSTAT(NULL, path, &lst) == 0 && S_ISLNK(lst.st_mode)) {
1811-
isLink = 1;
1812-
}
1813-
#endif
1814-
return isLink;
1815-
}
1816-
#endif /* WOLFSSH_HAVE_SYMLINK */
1817-
1818-
18191786
/*
18201787
* This is a wrapper around the function wolfSSH_RealPath. Since it modifies
18211788
* the source path value, copy the path from the data stream into a local
@@ -1899,7 +1866,7 @@ static int GetAndCleanPath(const char* defaultPath,
18991866
}
19001867
saved = s[i];
19011868
s[i] = '\0';
1902-
if (SFTP_IsSymlink(s)) {
1869+
if (WS_IsSymlink(s)) {
19031870
ret = WS_PERMISSIONS;
19041871
}
19051872
s[i] = saved;

wolfssh/port.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,6 +1521,13 @@ extern "C" {
15211521
#define WOLFSSH_HAVE_SYMLINK
15221522
#endif
15231523

1524+
#if defined(WOLFSSH_HAVE_SYMLINK) && \
1525+
(defined(WOLFSSH_SFTP) || defined(WOLFSSH_SCP))
1526+
/* Shared, un-followed symlink/reparse-point check used by both the SFTP
1527+
* path-confinement guard and the SCP send guards. */
1528+
WOLFSSH_LOCAL int WS_IsSymlink(const char* path);
1529+
#endif
1530+
15241531
#ifndef WS_MAYBE_UNUSED
15251532
#if (defined(__GNUC__) && (__GNUC__ >= 4)) || defined(__clang__) || \
15261533
defined(__IAR_SYSTEMS_ICC__)

0 commit comments

Comments
 (0)