Skip to content

Commit 87077f9

Browse files
authored
Merge pull request #1 from php-soap/feature/attachment-security
Prove attachment security against WSS4J in all four directions
2 parents 3a08669 + 4bf1598 commit 87077f9

5 files changed

Lines changed: 1671 additions & 1 deletion

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package org.phpsoap.interop;
2+
3+
import org.apache.wss4j.common.ext.Attachment;
4+
import org.apache.wss4j.common.ext.AttachmentRequestCallback;
5+
import org.apache.wss4j.common.ext.AttachmentResultCallback;
6+
7+
import javax.security.auth.callback.Callback;
8+
import javax.security.auth.callback.CallbackHandler;
9+
import javax.security.auth.callback.UnsupportedCallbackException;
10+
import java.util.ArrayList;
11+
import java.util.LinkedHashMap;
12+
import java.util.List;
13+
import java.util.Map;
14+
15+
/**
16+
* Hands WSS4J the message's attachments and collects the ones it hands back.
17+
*
18+
* <p>This is the piece the harness was missing. WSS4J does not read a multipart body itself: signing,
19+
* verifying, encrypting and decrypting an attachment all go through these two callbacks, so without a handler
20+
* that answers them WSS4J simply has no attachment to work on and reports nothing.
21+
*
22+
* <p>{@link AttachmentRequestCallback} asks for the attachments, either all of them or one by id.
23+
* {@link AttachmentResultCallback} delivers a transformed one back: the ciphertext after encryption, the
24+
* plaintext after decryption. The results are kept in insertion order so a caller can report them.
25+
*
26+
* <p>Ids are normalised on the way in and out. WSS4J works with the bare Content-ID while the SOAP part
27+
* addresses it as {@code cid:<id>} and a MIME header writes it as {@code <id>}, so all three forms are
28+
* accepted for a lookup and the bare form is what is stored.
29+
*/
30+
final class AttachmentCallbackHandler implements CallbackHandler {
31+
32+
private final List<Attachment> attachments;
33+
private final Map<String, Attachment> results = new LinkedHashMap<>();
34+
35+
AttachmentCallbackHandler(List<Attachment> attachments) {
36+
this.attachments = new ArrayList<>(attachments);
37+
}
38+
39+
/** The attachments WSS4J produced, in the order it produced them. Empty until it has done work. */
40+
List<Attachment> results() {
41+
return new ArrayList<>(results.values());
42+
}
43+
44+
@Override
45+
public void handle(Callback[] callbacks) throws UnsupportedCallbackException {
46+
for (Callback callback : callbacks) {
47+
if (callback instanceof AttachmentRequestCallback request) {
48+
request.setAttachments(requested(request.getAttachmentId()));
49+
} else if (callback instanceof AttachmentResultCallback result) {
50+
Attachment attachment = result.getAttachment();
51+
results.put(normalise(result.getAttachmentId()), attachment);
52+
} else {
53+
throw new UnsupportedCallbackException(callback, "Unsupported attachment callback");
54+
}
55+
}
56+
}
57+
58+
/**
59+
* WSS4J's part list names {@code cid:Attachments} to mean "every attachment on this message", and passes
60+
* that through {@code AttachmentUtils.getAttachmentId}, which strips the scheme. So what arrives here is
61+
* the literal id {@code Attachments}, and treating it as a Content-ID to look up finds nothing: the
62+
* signature comes out with no attachment reference at all and nothing reports a problem.
63+
*
64+
* An absent or empty id means the same thing.
65+
*/
66+
private static final String ALL = "Attachments";
67+
68+
private List<Attachment> requested(String id) {
69+
String wanted = normalise(id);
70+
if (wanted.isEmpty() || ALL.equals(wanted)) {
71+
List<Attachment> all = new ArrayList<>();
72+
for (Attachment candidate : attachments) {
73+
all.add(current(normalise(candidate.getId()), candidate));
74+
}
75+
76+
return all;
77+
}
78+
79+
List<Attachment> matching = new ArrayList<>();
80+
for (Attachment candidate : attachments) {
81+
String candidateId = normalise(candidate.getId());
82+
if (candidateId.equals(wanted)) {
83+
matching.add(current(candidateId, candidate));
84+
}
85+
}
86+
87+
return matching;
88+
}
89+
90+
/**
91+
* The latest form of an attachment: whatever a previous callback delivered back, or the original.
92+
*
93+
* This matters for one message per protection order. Verifying a sign-then-encrypt message asks twice:
94+
* the EncryptedKey is processed first and delivers the decrypted attachment, then the signature asks for
95+
* the attachment to digest. Answering the second question with the ciphertext makes every attachment
96+
* digest fail, which surfaces as a bare signature failure and says nothing about why.
97+
*/
98+
private Attachment current(String id, Attachment original) {
99+
return results.getOrDefault(id, original);
100+
}
101+
102+
private static String normalise(String id) {
103+
if (id == null) {
104+
return "";
105+
}
106+
107+
String bare = id;
108+
if (bare.startsWith("cid:")) {
109+
bare = bare.substring(4);
110+
}
111+
if (bare.startsWith("<") && bare.endsWith(">")) {
112+
bare = bare.substring(1, bare.length() - 1);
113+
}
114+
115+
return bare;
116+
}
117+
}

0 commit comments

Comments
 (0)