Skip to content

Commit 89bbbf3

Browse files
Reject symlinks in default SCP send callback
1 parent ba09b58 commit 89bbbf3

5 files changed

Lines changed: 141 additions & 38 deletions

File tree

scripts/scp.test

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ 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" \
60+
"$scp_symlink_out" "$scp_symlink_log"
5761
}
5862

5963
do_trap() {
@@ -150,6 +154,58 @@ if test $RESULT -eq 0; then
150154
exit 1
151155
fi
152156

157+
echo "Test that the server refuses to follow a symlink (server to local)"
158+
# This exercises the single-file send path (WOLFSSH_SCP_SINGLE_FILE_REQUEST).
159+
# The recursive directory-traversal sink in ScpProcessEntry uses the same shared
160+
# WS_IsSymlink() helper but cannot be driven from here: the wolfSSH example
161+
# client (wolfSSH_SCP_from) only ever issues "scp -f <path>", never "scp -f -r",
162+
# so the server's recursive request state is never reached by this harness.
163+
# WS_IsSymlink itself is additionally exercised through the SFTP confinement
164+
# unit test (test_wolfSSH_SFTP_Confinement), so the detection logic shared by
165+
# both sinks is covered even though the SCP recursive wiring is not driven e2e.
166+
scp_secret=$PWD/scripts/scp_secret_$$
167+
scp_symlink=$PWD/scp_symlink_$$
168+
scp_symlink_out=$PWD/scp_symlink_out_$$
169+
scp_symlink_log=$PWD/scp_symlink_log_$$
170+
echo "TOP SECRET SYMLINK TARGET" > $scp_secret
171+
# A symlink whose target exists would, without the fix, be opened and its
172+
# contents streamed to the client. The fix must reject it instead.
173+
ln -s $scp_secret $scp_symlink
174+
if test -L $scp_symlink; then
175+
# capture the server log so we can confirm the SSH session was actually
176+
# established (the client prints little; "Keying Complete" is logged by the
177+
# echoserver once key exchange finishes, proving the SCP request reached it)
178+
./examples/echoserver/echoserver -1 -R $ready_file > $scp_symlink_log 2>&1 &
179+
server_pid=$!
180+
create_port
181+
./examples/scpclient/wolfscp -u jill -P upthehill -p $port -S $scp_symlink:$scp_symlink_out
182+
remove_ready_file
183+
184+
# Confirm the client actually connected and issued the request, so a PASS
185+
# means the symlink was specifically refused - not that the client failed
186+
# before ever reaching the server. The client exit code is not asserted (as
187+
# with the other -S cases it does not propagate a server-side SCP abort).
188+
if ! grep -q "Keying Complete" $scp_symlink_log; then
189+
cat $scp_symlink_log
190+
rm -f $scp_symlink_out $scp_secret $scp_symlink $scp_symlink_log
191+
echo -e "\n\nclient never connected; symlink case did not run"
192+
do_cleanup
193+
exit 1
194+
fi
195+
196+
# The server must not deliver the symlink target: no local output file may
197+
# be produced from the refused request.
198+
if test -e $scp_symlink_out; then
199+
rm -f $scp_symlink_out $scp_secret $scp_symlink $scp_symlink_log
200+
echo -e "\n\nserver followed a symlink, confinement bypass"
201+
do_cleanup
202+
exit 1
203+
fi
204+
else
205+
echo "symlink not supported on this filesystem, skipping symlink test"
206+
fi
207+
rm -f $scp_secret $scp_symlink $scp_symlink_out $scp_symlink_log
208+
153209
echo -e "\nALL Tests Passed"
154210

155211
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: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2616,6 +2616,17 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
26162616
}
26172617
}
26182618

2619+
#ifdef WOLFSSH_HAVE_SYMLINK
2620+
/* GetFileStats classifies via WSTAT, which follows symlinks; a planted link
2621+
* to a file outside the traversed tree would be sent transparently. Reject
2622+
* the entry here, before descending into it or opening it, so neither the
2623+
* directory-descent nor the file-open sink below follows the link. */
2624+
if (ret == WS_SUCCESS && WS_IsSymlink(filePath)) {
2625+
WLOG(WS_LOG_ERROR, "scp: symlink entry rejected, aborting transfer");
2626+
ret = WS_SCP_ABORT;
2627+
}
2628+
#endif /* WOLFSSH_HAVE_SYMLINK */
2629+
26192630
if (ret == WS_SUCCESS) {
26202631

26212632
if (ScpFileIsDir(sendCtx)) {
@@ -2652,7 +2663,10 @@ static int ScpProcessEntry(WOLFSSH* ssh, char* fileName, word64* mTime,
26522663
}
26532664

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

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

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

28292854
if (ScpDirStackIsEmpty(sendCtx)) {
28302855

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

28342869
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)