diff --git a/README.md b/README.md index 8561a9c..dc80847 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ heroku config:add PATH_PREFIX=/awesome/symbols Now the symbol server URL can be `http://pepto-symbol.gadgetron.com`. +### Optional S3 request signing + +When running against a private S3 origin, the server can sign outgoing `GET` requests. + +- Signing is enabled automatically when both `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are present. +- `AWS_REGION` is required when signing is enabled. +- `AWS_SESSION_TOKEN` is optional, you can use it for [temporary credentials](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html). + ### Running locally To run Pepto Symbol locally on port 5000: diff --git a/package.json b/package.json index b2a5fa7..b103618 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "node": ">=14" }, "dependencies": { + "aws4": "^1.13.2", "http-proxy": "^1.18.1", "lru-cache": "^6.0.0", "uuid": "^8.3.2" @@ -16,6 +17,7 @@ "start:dev": "PATH_PREFIX=/atom-shell/symbols S3_BUCKET=gh-contractor-zcbenz npm start" }, "devDependencies": { + "@types/aws4": "^1.11.6", "@types/http-proxy": "^1.17.6", "@types/lru-cache": "^5.1.0", "@types/node": "^15.6.1", diff --git a/src/index.ts b/src/index.ts index c01c99a..5bb517e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import assert from 'assert'; +import aws4 from 'aws4'; import * as http from 'http'; import httpProxy from 'http-proxy'; import LRU from 'lru-cache'; @@ -39,6 +40,20 @@ const missingSymbolCache = new LRU({ max: 10000, }); +const signingAccessKeyId = process.env.AWS_ACCESS_KEY_ID; +const signingSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; +const signingSessionToken = process.env.AWS_SESSION_TOKEN; +const signingRegion = process.env.AWS_REGION; +const shouldSignS3Requests = Boolean(signingAccessKeyId && signingSecretAccessKey); + +assert(!shouldSignS3Requests || signingRegion, 'AWS_REGION is defined when S3 request signing is enabled'); + +type ProxyRequest = http.IncomingMessage & { + proxyPath?: string; + cacheKey?: string; + proxyMethod?: string; +}; + function incomingPathToProxyPath(path: string): string { // symstore.exe and symsrv.dll don't always agree on the case of the path to a // given symbol file. Since our artifact URLs are case-sensitive, this causes symbol @@ -58,11 +73,54 @@ function incomingPathToProxyPath(path: string): string { // The symbols may be hosted a deeper path in the artifact store // so we prefix the incoming path with that prefix - return `${PATH_PREFIX || ''}${newPath}`; + if (!PATH_PREFIX) { + return newPath; + } + + // Handle cases where PATH_PREFIX or newPath may or may not have a + // leading/trailing slash to avoid double slashes or missing slashes + if (PATH_PREFIX.endsWith('/') && newPath.startsWith('/')) { + return `${PATH_PREFIX.slice(0, -1)}${newPath}`; + } + + // Handle cases where PATH_PREFIX may or may not have a trailing slash + // and newPath may or may not have a leading slash + if (!PATH_PREFIX.endsWith('/') && !newPath.startsWith('/')) { + return `${PATH_PREFIX}/${newPath}`; + } + + return `${PATH_PREFIX}${newPath}`; +} + +function signS3ProxyPath(path: string, method: string): string { + if (!shouldSignS3Requests) { + return path; + } + + const targetUrl = new url.URL(path, TARGET_URL); + const signedRequest = aws4.sign({ + host: TARGET_HOST!, + method, + service: 's3', + region: signingRegion!, + signQuery: true, + path: `${targetUrl.pathname}${targetUrl.search}`, + }, { + accessKeyId: signingAccessKeyId!, + secretAccessKey: signingSecretAccessKey!, + sessionToken: signingSessionToken, + }); + + return signedRequest.path || `${targetUrl.pathname}${targetUrl.search}`; } proxy.on('proxyReq', (proxyReq, request, response, options) => { - proxyReq.path = incomingPathToProxyPath(proxyReq.path); + const req = request as ProxyRequest; + const proxyPath = req.proxyPath ?? incomingPathToProxyPath(proxyReq.path); + const cacheKey = req.cacheKey ?? incomingPathToProxyPath(proxyReq.path); + const proxyMethod = req.proxyMethod ?? proxyReq.method; + + proxyReq.path = proxyPath; // AZ CDN determines the bucket from the Host header proxyReq.setHeader('Host', TARGET_HOST); @@ -75,12 +133,17 @@ proxy.on('proxyReq', (proxyReq, request, response, options) => { // convert 403s to 404s so symsrv.dll doesn't freak out. const originalWriteHead = response.writeHead; response.writeHead = (...args: [number, any]) => { - if (args[0] == 403) { - missingSymbolCache.set(proxyReq.path, true); + if (proxyMethod === 'GET') { + if (args[0] == 403) { + missingSymbolCache.set(cacheKey, true); + args[0] = 404; + } else { + missingSymbolCache.set(cacheKey, false); + } + } else if (args[0] == 403) { args[0] = 404; - } else { - missingSymbolCache.set(proxyReq.path, false); } + return originalWriteHead.apply(response, args); }; }); @@ -104,16 +167,21 @@ http.createServer((req, res) => { } const cacheKey = incomingPathToProxyPath(parsed.pathname + parsed.search); + const requestMethod = (req.method || 'GET').toUpperCase(); const userAgent = req.headers['user-agent']; const isSentryRequest = userAgent && userAgent.startsWith('symbolicator/'); + let signedProxyPath: string; + try { + signedProxyPath = signS3ProxyPath(cacheKey, requestMethod); + } catch (error) { + const errorId = uuid.v4(); + console.error('Signing Error:', errorId, 'Request:', req.url, error); + return res.writeHead(500).end(`Failed to sign S3 request. If this happens consistently please report to https://github.com/electron/symbol-server with this error ID: "${errorId}"`); + } + if (isSentryRequest || req.headers['x-electron-symbol-redirect'] === '1') { - res.setHeader('Location', url.format({ - protocol: 'https:', - slashes: true, - host: TARGET_HOST, - pathname: cacheKey, - })); + res.setHeader('Location', `${TARGET_URL}${signedProxyPath}`); return res.writeHead(302).end(); } @@ -121,6 +189,11 @@ http.createServer((req, res) => { return res.writeHead(404).end(); } + const proxyReq = req as ProxyRequest; + proxyReq.cacheKey = cacheKey; + proxyReq.proxyPath = signedProxyPath; + proxyReq.proxyMethod = requestMethod; + proxy.web(req, res, { target: TARGET_URL }); }).listen(process.env.PORT || 8080); diff --git a/yarn.lock b/yarn.lock index e0eb57b..e018456 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9,10 +9,12 @@ __metadata: version: 0.0.0-use.local resolution: "@electron/symbol-server@workspace:." dependencies: + "@types/aws4": "npm:^1.11.6" "@types/http-proxy": "npm:^1.17.6" "@types/lru-cache": "npm:^5.1.0" "@types/node": "npm:^15.6.1" "@types/uuid": "npm:^8.3.0" + aws4: "npm:^1.13.2" http-proxy: "npm:^1.18.1" lru-cache: "npm:^6.0.0" typescript: "npm:^4.3.2" @@ -20,6 +22,15 @@ __metadata: languageName: unknown linkType: soft +"@types/aws4@npm:^1.11.6": + version: 1.11.6 + resolution: "@types/aws4@npm:1.11.6" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/abb84d0dad0b791124389b1bfd2c4c2813077af162cac128f1eb0793f0d4fe3084aa1168f62b41e39f80730aa45b95a9a0034f85c254bebf2194721fe02bc9ad + languageName: node + linkType: hard + "@types/http-proxy@npm:^1.17.6": version: 1.17.6 resolution: "@types/http-proxy@npm:1.17.6" @@ -57,6 +68,13 @@ __metadata: languageName: node linkType: hard +"aws4@npm:^1.13.2": + version: 1.13.2 + resolution: "aws4@npm:1.13.2" + checksum: 10c0/c993d0d186d699f685d73113733695d648ec7d4b301aba2e2a559d0cd9c1c902308cc52f4095e1396b23fddbc35113644e7f0a6a32753636306e41e3ed6f1e79 + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.0": version: 4.0.4 resolution: "eventemitter3@npm:4.0.4"