fix(service-worker): several misc fixes for corner cases

This commit fixes several issues discovered through use in real apps.

* The sha1() function operated on text content, causing issues for binary-format files.
  A sha1Binary() function which operates on unparsed data now avoids any encoding issues.
* The characters '?' and '+' were not escaped in Glob-to-regex conversion previously, but
  are now.
* URLs from the browser contain the full origin, but were checked against the table of
  hashes from the manifest which only has the path for URLs from the same origin. Now the
  origin is checked and URLs are relativized to the domain root before comparison if
  appropriate.
* ngsw: prefix was missing from data groups, is now added.
* Occasionally servers will return a redirected response for an asset, and caching it could
  cause errors for navigation requests. The SW now handles this by detecting such responses
  and following the redirect manually, to avoid caching a redirected response.
* The request for known assets is now created from scratch from the URL before fetching from
  the network, in order to sanitize it and avoid carrying any special modes or headers that
  might result in opaque responses.
* Debugging log for troubleshooting.
* Avoid creating errors by returning 504 responses on error.
* Fix bug where idle queue doesn't run in some circumstances.
* Add tests for the above.
This commit is contained in:
Alex Rickabaugh
2017-10-02 15:59:57 -07:00
parent b804d4bde5
commit f10f8db5fb
26 changed files with 768 additions and 230 deletions

View File

@ -15,5 +15,6 @@
export interface Filesystem {
list(dir: string): Promise<string[]>;
read(file: string): Promise<string>;
hash(file: string): Promise<string>;
write(file: string, contents: string): Promise<void>;
}

View File

@ -10,7 +10,6 @@ import {parseDurationToMs} from './duration';
import {Filesystem} from './filesystem';
import {globToRegex} from './glob';
import {Config} from './in';
import {sha1} from './sha1';
/**
* Consumes service worker configuration files and processes them into control files.
@ -49,7 +48,7 @@ export class Generator {
// Add the hashes.
await plainFiles.reduce(async(previous, file) => {
await previous;
const hash = sha1(await this.fs.read(file));
const hash = await this.fs.hash(file);
hashTable[joinUrls(this.baseHref, file)] = hash;
}, Promise.resolve());

View File

@ -9,6 +9,13 @@
const WILD_SINGLE = '[^\\/]+';
const WILD_OPEN = '(?:.+\\/)?';
const TO_ESCAPE = [
{replace: /\./g, with: '\\.'},
{replace: /\?/g, with: '\\?'},
{replace: /\+/g, with: '\\+'},
{replace: /\*/g, with: WILD_SINGLE},
];
export function globToRegex(glob: string): string {
const segments = glob.split('/').reverse();
let regex: string = '';
@ -20,9 +27,9 @@ export function globToRegex(glob: string): string {
} else {
regex += '.*';
}
continue;
} else {
const processed = segment.replace(/\./g, '\\.').replace(/\*/g, WILD_SINGLE);
const processed = TO_ESCAPE.reduce(
(segment, escape) => segment.replace(escape.replace, escape.with), segment);
regex += processed;
if (segments.length > 0) {
regex += '\\/';

View File

@ -1,196 +0,0 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/**
* Compute the SHA1 of the given string
*
* see http://csrc.nist.gov/publications/fips/fips180-4/fips-180-4.pdf
*
* WARNING: this function has not been designed not tested with security in mind.
* DO NOT USE IT IN A SECURITY SENSITIVE CONTEXT.
*
* Borrowed from @angular/compiler/src/i18n/digest.ts
*/
export function sha1(str: string): string {
const utf8 = str;
const words32 = stringToWords32(utf8, Endian.Big);
const len = utf8.length * 8;
const w = new Array(80);
let [a, b, c, d, e]: number[] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0];
words32[len >> 5] |= 0x80 << (24 - len % 32);
words32[((len + 64 >> 9) << 4) + 15] = len;
for (let i = 0; i < words32.length; i += 16) {
const [h0, h1, h2, h3, h4]: number[] = [a, b, c, d, e];
for (let j = 0; j < 80; j++) {
if (j < 16) {
w[j] = words32[i + j];
} else {
w[j] = rol32(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1);
}
const [f, k] = fk(j, b, c, d);
const temp = [rol32(a, 5), f, e, k, w[j]].reduce(add32);
[e, d, c, b, a] = [d, c, rol32(b, 30), a, temp];
}
[a, b, c, d, e] = [add32(a, h0), add32(b, h1), add32(c, h2), add32(d, h3), add32(e, h4)];
}
return byteStringToHexString(words32ToByteString([a, b, c, d, e]));
}
function add32(a: number, b: number): number {
return add32to64(a, b)[1];
}
function add32to64(a: number, b: number): [number, number] {
const low = (a & 0xffff) + (b & 0xffff);
const high = (a >>> 16) + (b >>> 16) + (low >>> 16);
return [high >>> 16, (high << 16) | (low & 0xffff)];
}
function add64([ah, al]: [number, number], [bh, bl]: [number, number]): [number, number] {
const [carry, l] = add32to64(al, bl);
const h = add32(add32(ah, bh), carry);
return [h, l];
}
function sub32(a: number, b: number): number {
const low = (a & 0xffff) - (b & 0xffff);
const high = (a >> 16) - (b >> 16) + (low >> 16);
return (high << 16) | (low & 0xffff);
}
// Rotate a 32b number left `count` position
function rol32(a: number, count: number): number {
return (a << count) | (a >>> (32 - count));
}
// Rotate a 64b number left `count` position
function rol64([hi, lo]: [number, number], count: number): [number, number] {
const h = (hi << count) | (lo >>> (32 - count));
const l = (lo << count) | (hi >>> (32 - count));
return [h, l];
}
enum Endian {
Little,
Big,
}
function fk(index: number, b: number, c: number, d: number): [number, number] {
if (index < 20) {
return [(b & c) | (~b & d), 0x5a827999];
}
if (index < 40) {
return [b ^ c ^ d, 0x6ed9eba1];
}
if (index < 60) {
return [(b & c) | (b & d) | (c & d), 0x8f1bbcdc];
}
return [b ^ c ^ d, 0xca62c1d6];
}
function stringToWords32(str: string, endian: Endian): number[] {
const words32 = Array((str.length + 3) >>> 2);
for (let i = 0; i < words32.length; i++) {
words32[i] = wordAt(str, i * 4, endian);
}
return words32;
}
function byteAt(str: string, index: number): number {
return index >= str.length ? 0 : str.charCodeAt(index) & 0xff;
}
function wordAt(str: string, index: number, endian: Endian): number {
let word = 0;
if (endian === Endian.Big) {
for (let i = 0; i < 4; i++) {
word += byteAt(str, index + i) << (24 - 8 * i);
}
} else {
for (let i = 0; i < 4; i++) {
word += byteAt(str, index + i) << 8 * i;
}
}
return word;
}
function words32ToByteString(words32: number[]): string {
return words32.reduce((str, word) => str + word32ToByteString(word), '');
}
function word32ToByteString(word: number): string {
let str = '';
for (let i = 0; i < 4; i++) {
str += String.fromCharCode((word >>> 8 * (3 - i)) & 0xff);
}
return str;
}
function byteStringToHexString(str: string): string {
let hex: string = '';
for (let i = 0; i < str.length; i++) {
const b = byteAt(str, i);
hex += (b >>> 4).toString(16) + (b & 0x0f).toString(16);
}
return hex.toLowerCase();
}
// based on http://www.danvk.org/hex2dec.html (JS can not handle more than 56b)
function byteStringToDecString(str: string): string {
let decimal = '';
let toThePower = '1';
for (let i = str.length - 1; i >= 0; i--) {
decimal = addBigInt(decimal, numberTimesBigInt(byteAt(str, i), toThePower));
toThePower = numberTimesBigInt(256, toThePower);
}
return decimal.split('').reverse().join('');
}
// x and y decimal, lowest significant digit first
function addBigInt(x: string, y: string): string {
let sum = '';
const len = Math.max(x.length, y.length);
for (let i = 0, carry = 0; i < len || carry; i++) {
const tmpSum = carry + +(x[i] || 0) + +(y[i] || 0);
if (tmpSum >= 10) {
carry = 1;
sum += tmpSum - 10;
} else {
carry = 0;
sum += tmpSum;
}
}
return sum;
}
function numberTimesBigInt(num: number, b: string): string {
let product = '';
let bToThePower = b;
for (; num !== 0; num = num >>> 1) {
if (num & 1) product = addBigInt(product, bToThePower);
bToThePower = addBigInt(bToThePower, bToThePower);
}
return product;
}

View File

@ -33,6 +33,7 @@ export function main() {
versionedFiles: [],
urls: [
'/absolute/**',
'/some/url?with+escaped+chars',
'relative/*.txt',
]
}
@ -62,7 +63,11 @@ export function main() {
'installMode': 'prefetch',
'updateMode': 'prefetch',
'urls': ['/test/index.html', '/test/foo/test.html'],
'patterns': ['\\/absolute\\/.*', '\\/test\\/relative\\/[^\\/]+\\.txt']
'patterns': [
'\\/absolute\\/.*',
'\\/some\\/url\\?with\\+escaped\\+chars',
'\\/test\\/relative\\/[^\\/]+\\.txt',
]
}],
'dataGroups': [{
'name': 'other',

View File

@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {sha1} from '../../cli/sha1';
import {Filesystem} from '../src/filesystem';
export class MockFilesystem implements Filesystem {
@ -21,5 +22,7 @@ export class MockFilesystem implements Filesystem {
async read(path: string): Promise<string> { return this.files.get(path) !; }
async hash(path: string): Promise<string> { return sha1(this.files.get(path) !); }
async write(path: string, contents: string): Promise<void> { this.files.set(path, contents); }
}