-
Notifications
You must be signed in to change notification settings - Fork 30
Expand file tree
/
Copy pathAddSri.java
More file actions
216 lines (196 loc) · 9.34 KB
/
Copy pathAddSri.java
File metadata and controls
216 lines (196 loc) · 9.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
import java.nio.file.*;
import java.security.*;
import java.util.*;
import java.util.regex.*;
import java.util.stream.*;
/** Add Subresource Integrity hashes to every script and stylesheet reference in a built webroot.
*
* java AddSri.java <webroot> rewrite every page in place
* java AddSri.java <webroot> -check verify only, non zero exit if any page is out of date
*
* Each reference also gets a ?v= stamp taken from the same hash. Without it a client can hold a
* stale bundle from the ten minute Cache-Control while fetching a fresh index.html, and under SRI
* that skew is a dead page rather than a subtle bug. StaticHandler routes on the path alone, so
* the query needs nothing on the server side.
*
* References are resolved on disk relative to the page holding them, which is what the sandbox
* needs: StaticHandler serves a generated subdomain's /foo.js out of apps/sandbox/foo.js, so the
* URL and the file layout differ there, but the page relative path is right either way.
*/
public class AddSri {
private static final Pattern TAG = Pattern.compile("(?i)<(script|link)(?=[\\s>])");
private static final Pattern INTEGRITY = Pattern.compile("(?i)\\s+integrity\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s>]+)");
private static final List<String> report = new ArrayList<>();
private static final List<String> problems = new ArrayList<>();
private static int hashed = 0, external = 0;
public static void main(String[] a) throws Exception {
if (a.length < 1 || (a.length > 1 && ! a[1].equals("-check"))) {
System.err.println("usage: AddSri <webroot> [-check]");
System.exit(2);
}
Path webroot = Paths.get(a[0]).toAbsolutePath().normalize();
boolean checkOnly = a.length > 1;
List<Path> pages;
try (Stream<Path> tree = Files.walk(webroot)) {
pages = tree.filter(p -> p.toString().endsWith(".html")).sorted().collect(Collectors.toList());
}
List<String> stale = new ArrayList<>();
for (Path page : pages) {
String original = Files.readString(page);
String updated = process(page, webroot, original);
if (updated.equals(original))
continue;
if (checkOnly)
stale.add(webroot.relativize(page).toString());
else
Files.writeString(page, updated);
}
for (String line : report)
System.out.println((checkOnly ? "AddSri stale: " : "AddSri: ") + line);
for (String problem : problems)
System.err.println("AddSri: " + problem);
System.out.println("AddSri: " + pages.size() + " pages, " + hashed + " references hashed, "
+ external + " external skipped, " + problems.size() + " unresolved"
+ (checkOnly ? ", " + stale.size() + " pages out of date" : ""));
if (! problems.isEmpty() || ! stale.isEmpty())
System.exit(1);
}
/** Rewrite every managed tag in one page, leaving the rest of the file byte for byte alone. */
private static String process(Path page, Path webroot, String src) throws Exception {
List<int[]> comments = comments(src);
StringBuilder out = new StringBuilder();
Matcher tags = TAG.matcher(src);
int copied = 0;
while (tags.find()) {
int start = tags.start();
if (start < copied || isInside(comments, start))
continue;
int gt = endOfTagHead(src, start);
if (gt < 0)
break;
out.append(src, copied, start)
.append(rewrite(src.substring(start, gt), tags.group(1).toLowerCase(), page, webroot));
copied = gt;
}
return out.append(src, copied, src.length()).toString();
}
private static String rewrite(String head, String tag, Path page, Path webroot) throws Exception {
String name = tag.equals("script") ? "src" : managedLink(head) ? "href" : null;
if (name == null)
return head;
Attr ref = attribute(head, name);
if (ref == null)
return head; // an inline script, or a link with no href
String value = ref.value.trim();
if (value.isEmpty())
return head;
if (value.startsWith("//") || value.matches("(?i)^[a-z][a-z0-9+.-]*:.*")) {
external++;
return head; // needs crossorigin and CORS on the far end, and our CSP blocks it anyway
}
String path = value.split("[?#]", 2)[0];
Path target = (path.startsWith("/") ? webroot.resolve(path.substring(1)) : page.getParent().resolve(path))
.normalize();
String where = webroot.relativize(page) + " -> " + path;
if (! target.startsWith(webroot)) {
problems.add(where + " escapes the webroot");
return head;
}
if (! Files.isRegularFile(target)) {
problems.add(where + " does not exist");
return head;
}
byte[] digest = MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(target));
String want = "sha256-" + Base64.getEncoder().encodeToString(digest);
Attr existing = attribute(head, "integrity");
if (existing == null || ! existing.value.equals(want))
report.add(where + " " + (existing == null ? want : existing.value + " -> " + want));
hashed++;
String stripped = INTEGRITY.matcher(head).replaceAll("");
Attr ours = attribute(stripped, name);
String stamped = stripped.substring(0, ours.valueStart) + stamp(value, hex(digest).substring(0, 8))
+ stripped.substring(ours.valueEnd);
Attr moved = attribute(stamped, name);
return stamped.substring(0, moved.end) + " integrity=\"" + want + "\"" + stamped.substring(moved.end);
}
/** SRI only covers stylesheets, module preloads, and preloads of a script or a stylesheet. */
private static boolean managedLink(String head) {
Attr rel = attribute(head, "rel");
if (rel == null)
return false;
List<String> tokens = Arrays.asList(rel.value.toLowerCase().trim().split("\\s+"));
if (tokens.contains("stylesheet") || tokens.contains("modulepreload"))
return true;
Attr as = attribute(head, "as");
return tokens.contains("preload") && as != null
&& (as.value.equalsIgnoreCase("script") || as.value.equalsIgnoreCase("style"));
}
/** Set v= to the content hash, replacing any earlier stamp so a rerun stays a no-op. */
private static String stamp(String value, String version) {
int hash = value.indexOf('#');
String fragment = hash < 0 ? "" : value.substring(hash);
String rest = hash < 0 ? value : value.substring(0, hash);
int question = rest.indexOf('?');
String path = question < 0 ? rest : rest.substring(0, question);
List<String> params = new ArrayList<>();
if (question >= 0)
for (String param : rest.substring(question + 1).split("&"))
if (! param.isEmpty() && ! param.equals("v") && ! param.startsWith("v="))
params.add(param);
params.add("v=" + version);
return path + "?" + String.join("&", params) + fragment;
}
private static String hex(byte[] data) {
StringBuilder res = new StringBuilder();
for (byte b : data)
res.append(String.format("%02x", b));
return res.toString();
}
private static class Attr {
final String value;
final int valueStart, valueEnd; // the value itself, inside its quotes if it has any
final int end; // just past the attribute, where a new one can be inserted
Attr(String value, int valueStart, int valueEnd, int end) {
this.value = value;
this.valueStart = valueStart;
this.valueEnd = valueEnd;
this.end = end;
}
}
private static Attr attribute(String head, String name) {
Matcher m = Pattern.compile("(?i)(?<=\\s)" + name + "\\s*=\\s*(\"([^\"]*)\"|'([^']*)'|([^\\s>]+))")
.matcher(head);
if (! m.find())
return null;
int group = m.group(2) != null ? 2 : m.group(3) != null ? 3 : 4;
return new Attr(m.group(group), m.start(group), m.end(group), m.end());
}
/** The '>' closing the tag opened at from, ignoring any inside a quoted attribute value. */
private static int endOfTagHead(String html, int from) {
char quote = 0;
for (int i = from; i < html.length(); i++) {
char c = html.charAt(i);
if (quote != 0) {
if (c == quote)
quote = 0;
} else if (c == '"' || c == '\'')
quote = c;
else if (c == '>')
return i;
}
return -1;
}
private static List<int[]> comments(String html) {
List<int[]> res = new ArrayList<>();
for (int i = html.indexOf("<!--"); i >= 0; i = html.indexOf("<!--", i + 4)) {
int end = html.indexOf("-->", i + 4);
res.add(new int[]{i, end < 0 ? html.length() : end + 3});
if (end < 0)
break;
}
return res;
}
private static boolean isInside(List<int[]> ranges, int index) {
return ranges.stream().anyMatch(r -> index >= r[0] && index < r[1]);
}
}