Project

General

Profile

proposal-major-security-1.txt

Sergey Ivanovskiy, 08/14/2026 05:13 AM

Download (8.92 KB)

 
1
================================================================================
2
PROPOSAL major-security-1
3
Unvalidated client-supplied filename reaches a file-write sink (path traversal)
4
================================================================================
5

    
6
Branch      : 11645a (task #11645), trunk base r16695
7
Files        : src/com/goldencode/p2j/ui/client/gui/DragDropHelper.java        (sink)
8
               src/com/goldencode/p2j/ui/client/gui/driver/web/GuiWebDriver.java
9
               src/com/goldencode/p2j/ui/client/gui/driver/web/GuiWebSocket.java
10
Severity     : MAJOR as a defect in its own right; scoped MINOR for THIS diff
11
               because the sink is pre-existing and untouched here
12
Category     : pre-existing defect, NOT introduced by this branch
13

    
14

    
15
1. SCOPE NOTE - READ FIRST
16
--------------------------------------------------------------------------------
17
This is not a defect in the 11645 changes. The vulnerable sink and its two twins
18
predate the branch entirely and are untouched by it; the branch only reworks the
19
MSG_PARTIAL reassembly that happens to be the delivery route. It surfaced during
20
this review and is written up here so it is not lost.
21

    
22
Recommendation: raise as its own ticket and fix outside this task's branch. Do
23
not bundle it into 11645 - it changes a method signature used by three call sites
24
and deserves its own review and test pass.
25

    
26

    
27
2. PROBLEM
28
--------------------------------------------------------------------------------
29
A client-supplied filename flows verbatim into a file-write sink with no
30
normalization, no canonicalization and no containment check, giving arbitrary file
31
write on the host running the spawned web client.
32

    
33
Verified chain (all in the post-diff tree):
34

    
35
  1. WebClientProtocol.java:2230
36
       AppendMessageTask.run() on the last piece calls processChannel(channel).
37

    
38
  2. GuiWebSocket.java:2419-2441
39
       messageType == MSG_FILE_UPLOADING
40
       fileName = readMessageText(...)                  <- verbatim from the peer
41
       callbacks.saveDropTargetFile(fileName, channel, fileSize)
42
     No validation. Also no gating: the handler does not require a pending file
43
     dialog or an active drop target. (Contrast chooseFiles / FileUploadInfo at
44
     :2383-2394, which the HTTP upload path does consult.)
45

    
46
  3. GuiWebDriver.java:1935
47
       DragDropHelper.readDataAndSaveAsFile(tmpDir.concat(name), channel, length)
48
     tmpDir is set unconditionally for every GUI client (ThinClient.java:4327 ->
49
     AbstractGuiDriver.java:399) and ENDS WITH File.separator
50
     (Utils.getOrCreateTemporaryDirectory, Utils.java:4487) - so concat yields a
51
     traversable prefix, not a mangled sibling name.
52

    
53
  4. DragDropHelper.java:254-261
54
       Paths.get(name) -> Files.newByteChannel(fileToWrite, OPEN_OPTIONS)
55
     No normalize(), no getFileName(), no startsWith(tmpDir) check.
56

    
57
A crafted peer sending a UTF-16 name field of
58
"../../../../home/<user>/.bashrc" writes attacker-chosen bytes to that path.
59

    
60
Two more sinks share the flaw, both pre-existing:
61
  - GuiWebSocket.java:3540 (small-message MSG_FILE_UPLOADING) ->
62
    GuiWebDriver.java:1918 -> DragDropHelper.saveFile:214
63
  - GuiWebDriver.startDropTarget:1890, which also concatenates an unvalidated name
64

    
65

    
66
3. WHY THIS CROSSES A PRIVILEGE BOUNDARY
67
--------------------------------------------------------------------------------
68
The obvious objection - "that is the client JVM the end user already owns" - does
69
not hold for the web GUI driver. The GuiWebSocket / GuiWebDriver process is the
70
spawned FWD web client running SERVER-SIDE: WebDriverHandler authenticates, spawns
71
the client, and the browser is then redirected to that process's embedded Jetty
72
(VirtualDesktopWebHandler:98). That websocket endpoint is registered with
73
GenericWebSocketCreator at the fixed AJAX_TARGET path with NO Origin check
74
(GenericWebServer.addWebSocketHandler:938-955).
75

    
76
The OS account it runs as comes from WebDriverHandler.java:986-1006: with
77
OS_USER_OVERRIDE set, or with no SSO OS-user mapping, every user's client runs as
78
the single shared DEFAULT_OS_USER.
79

    
80
So an authenticated FWD *application* user - who normally has no shell and no
81
filesystem rights on the application server - gains arbitrary file write on the
82
server host as that frequently-shared OS account, which also reaches other users'
83
sessions. That is a real boundary crossing.
84

    
85
Mitigating factors (why not CRITICAL):
86
  - It is gated behind authentication; the spawned client only exists post-login.
87
  - A stock browser cannot produce a File.name containing path separators
88
    (p2j.socket.js:1746 createFileUploadingMessage just forwards file.name), so
89
    the trigger needs a crafted websocket peer or injected JS, not an ordinary
90
    drag-and-drop.
91

    
92

    
93
4. PROPOSED FIX
94
--------------------------------------------------------------------------------
95
FWD already contains the correct pattern - the HTTP multipart upload path does
96
exactly the missing check:
97

    
98
    UploadHandler.java:231-233
99
        Paths.get(filename);
100
        file = file.getFileName();
101
        uploadDir.resolve(file);
102

    
103
Apply the same containment at the SINK so all three callers are covered by one
104
change, rather than patching each caller.
105

    
106
  Step 1 - change DragDropHelper to take the directory and the untrusted name as
107
  separate arguments instead of a pre-concatenated string. This is the crux: as
108
  long as the API takes one joined string, every caller is one concat away from
109
  reintroducing the bug.
110

    
111
      private static Path resolveInside(String dir, String untrustedName)
112
      throws IOException
113
      {
114
         Path base   = Paths.get(dir).toAbsolutePath().normalize();
115
         Path name   = Paths.get(untrustedName).getFileName();
116

    
117
         if (name == null)
118
         {
119
            throw new IOException("Illegal upload file name: " + untrustedName);
120
         }
121

    
122
         Path target = base.resolve(name).normalize();
123

    
124
         if (!target.startsWith(base))
125
         {
126
            throw new IOException("Upload path escapes the target directory: "
127
                                  + untrustedName);
128
         }
129

    
130
         return target;
131
      }
132

    
133
  Notes on the implementation:
134
    - Paths.get(untrustedName) can itself throw InvalidPathException on a NUL byte
135
      or an illegal Windows name; catch it and convert to the same IOException so
136
      callers see one failure mode.
137
    - getFileName() alone defeats "../" traversal; the startsWith check is kept as
138
      belt-and-braces and to catch platform-specific surprises (e.g. Windows
139
      alternate data streams, drive-relative paths).
140
    - Reject or rewrite an empty result and the reserved names "." and "..".
141

    
142
  Step 2 - route readDataAndSaveAsFile and saveFile through resolveInside, and
143
  update the three call sites (GuiWebDriver:1918, :1890, :1935) to pass tmpDir and
144
  the raw name separately instead of concatenating.
145

    
146
  Step 3 - add the missing gate in GuiWebSocket.processChannel: only accept
147
  MSG_FILE_UPLOADING when a file dialog or drop target is actually pending, the
148
  way the HTTP path consults FileUploadInfo. Defence in depth - it removes the
149
  unsolicited-upload primitive entirely, independent of the name handling.
150

    
151
  Step 4 - decide the policy for a name collision inside tmpDir (overwrite vs
152
  unique-ify). Current behaviour overwrites; preserve it unless the team wants a
153
  change, but make it explicit rather than incidental.
154

    
155

    
156
5. ALTERNATIVES CONSIDERED
157
--------------------------------------------------------------------------------
158
(i)  Sanitize in GuiWebSocket.processChannel where the name is read.
159
     Rejected as the primary fix: it leaves the sink dangerous for the other two
160
     callers and for any future one. Do it in addition if cheap, not instead.
161

    
162
(ii) Reject any name containing a separator or "..".
163
     Weaker than getFileName() (encoding tricks, platform quirks) and rejects
164
     names a browser can legitimately produce on some platforms. Prefer
165
     normalization to blacklisting.
166

    
167

    
168
6. RISK
169
--------------------------------------------------------------------------------
170
Low functionally, but it is a signature change across three call sites, so it must
171
compile-and-run clean on both the GUI and CHUI web drivers. Behavioural change to
172
be aware of: names that previously wrote outside tmpDir (only reachable
173
maliciously) now fail with an IOException - confirm the error surfaces sensibly to
174
the user rather than killing the drawing thread.
175

    
176

    
177
7. TEST PLAN
178
--------------------------------------------------------------------------------
179
1. Ordinary drag-and-drop file upload in the web GUI client: unchanged, file lands
180
   in tmpDir.
181
2. Ordinary file-dialog upload (both the websocket and the HTTP multipart path):
182
   unchanged.
183
3. Crafted websocket frame with name "../../../../tmp/pwned": expect an IOException
184
   and no file written outside tmpDir. Verify via the filesystem, not just the log.
185
4. Name with a NUL byte, an empty name, "." and "..": expect clean rejection, no
186
   crash.
187
5. Unsolicited MSG_FILE_UPLOADING with no pending dialog or drop target: expect
188
   rejection once Step 3 is in.
189
6. Windows and Linux both, since the path semantics differ.