Conversation
…, function or class Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.OpenSSF Scorecard
Scanned Files |
|
There was a problem hiding this comment.
Pull request overview
Adds a request security layer to the LoopBack app by enforcing an API key + payload-hash check, and introduces response caching keyed by request + payload hash to allow replaying identical requests without re-executing handlers.
Changes:
- Added
requestSecurityMiddlewareto validateapi_keyandx-payload-hash, and to cache/replay responses. - Updated application bootstrap to register the new middleware and to configure CORS origins/headers.
- Removed the
/logscontroller and added acceptance coverage for the new middleware behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/middleware/request-security.middleware.ts | Implements API key + payload hash verification and response caching/replay. |
| src/application.ts | Registers the new middleware and adds CORS configuration driven by CORS_ORIGIN. |
| src/controllers/logs.controller.ts | Removes the /logs endpoint implementation. |
| src/tests/unit/api-information.controller.unit.ts | Minor unit test structure/format changes. |
| src/tests/acceptance/request-security.acceptance.ts | Adds acceptance tests for request security + caching. |
| ...options, | ||
| rest: { | ||
| cors: { | ||
| origin: [corsOrigins], |
There was a problem hiding this comment.
cors.origin is being set to [corsOrigins], which produces a nested array (string[][]). LoopBack/Express CORS expects a string or string[], so this will likely prevent origin matching and break CORS. Use origin: corsOrigins (or a single string) instead.
| origin: [corsOrigins], | |
| origin: corsOrigins, |
| this.middleware(requestSecurityMiddleware, { | ||
| group: 'requestSecurity', | ||
| upstreamGroups: RestMiddlewareGroups.PARSE_PARAMS, | ||
| downstreamGroups: RestMiddlewareGroups.INVOKE_METHOD, | ||
| }); |
There was a problem hiding this comment.
The request security middleware is registered unconditionally for the whole app, but requestSecurityMiddleware throws 500 when REQUEST_API_KEY/REQUEST_SALT are not set. Existing acceptance tests (and local dev) start the app without those env vars (see setupApplication()), so all requests will fail. Consider enabling this middleware only when the env vars are present / in specific environments, or updating the shared test bootstrap to set the required env vars.
| this.middleware(requestSecurityMiddleware, { | |
| group: 'requestSecurity', | |
| upstreamGroups: RestMiddlewareGroups.PARSE_PARAMS, | |
| downstreamGroups: RestMiddlewareGroups.INVOKE_METHOD, | |
| }); | |
| if (process.env.REQUEST_API_KEY && process.env.REQUEST_SALT) { | |
| this.middleware(requestSecurityMiddleware, { | |
| group: 'requestSecurity', | |
| upstreamGroups: RestMiddlewareGroups.PARSE_PARAMS, | |
| downstreamGroups: RestMiddlewareGroups.INVOKE_METHOD, | |
| }); | |
| } |
| if (result === undefined) { | ||
| const contentType = getContentType(response.getHeader('content-type')); | ||
|
|
||
| storeCachedResponse(cacheKey, { | ||
| type: 'raw', | ||
| statusCode: response.statusCode, | ||
| contentType, | ||
| payload: concatChunks(responseChunks), | ||
| }); | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| storeCachedResponse(cacheKey, { | ||
| type: 'result', | ||
| result, | ||
| }); |
There was a problem hiding this comment.
Caching result when it is non-undefined is unsafe because many controllers return the Express Response object (after calling res.send()), which is tied to a specific request and cannot be reused. This will cache and later return a stale Response instance instead of replaying bytes, breaking cached responses for endpoints that return Promise<Response>. Prefer caching/replaying the captured raw response when the response has been written/ended (e.g., response.headersSent / response.writableEnded or result === response) and avoid storing Response objects in the cache.
| if (!expectedApiKey) { | ||
| throw new HttpErrors.InternalServerError(); | ||
| } | ||
|
|
||
| if (!rootstockSalt) { | ||
| throw new HttpErrors.InternalServerError(); | ||
| } | ||
|
|
||
| const providedApiKey = request.header(API_KEY_HEADER); | ||
| if (!providedApiKey) { | ||
| throw new HttpErrors.Unauthorized(); | ||
| } | ||
|
|
||
| if (!secureStringCompare(providedApiKey, expectedApiKey)) { | ||
| throw new HttpErrors.Unauthorized(); | ||
| } | ||
|
|
||
| const providedPayloadHash = request.header(PAYLOAD_HASH_HEADER); | ||
| if (!providedPayloadHash) { | ||
| throw new HttpErrors.Unauthorized(); | ||
| } | ||
|
|
||
| const normalizedPayloadHash = providedPayloadHash.toLowerCase(); | ||
| const payload = getRequestPayload(request); | ||
| const expectedPayloadHash = sha256(sha256(payload + rootstockSalt)); | ||
|
|
||
| if (!secureStringCompare(normalizedPayloadHash, expectedPayloadHash)) { | ||
| throw new HttpErrors.Unauthorized(); | ||
| } |
There was a problem hiding this comment.
All Unauthorized/InternalServerError errors are thrown without messages. The new acceptance tests assert specific messages (e.g., missing api_key vs invalid api key), and without explicit messages LoopBack will return the generic "Unauthorized" text. Provide explicit error messages (and ideally differentiate missing vs invalid headers) so clients/tests can reliably understand failures.
| appendChunk(chunk, args[0] as BufferEncoding | undefined); | ||
| return originalWrite(chunk as never, ...(args as never[])); | ||
| }) as typeof response.write; | ||
|
|
||
| response.end = ((chunk?: unknown, ...args: unknown[]) => { | ||
| appendChunk(chunk, args[0] as BufferEncoding | undefined); |
There was a problem hiding this comment.
The response.write/response.end wrappers assume args[0] is always a BufferEncoding, but Node allows passing a callback as the second argument (e.g., res.end('ok', cb)). In that case Buffer.from(chunk, encoding) will receive a function and can throw. Parse the overloads safely (only treat args[0] as encoding when it’s a string) before calling Buffer.from.
| appendChunk(chunk, args[0] as BufferEncoding | undefined); | |
| return originalWrite(chunk as never, ...(args as never[])); | |
| }) as typeof response.write; | |
| response.end = ((chunk?: unknown, ...args: unknown[]) => { | |
| appendChunk(chunk, args[0] as BufferEncoding | undefined); | |
| const encoding = | |
| typeof args[0] === 'string' ? (args[0] as BufferEncoding) : undefined; | |
| appendChunk(chunk, encoding); | |
| return originalWrite(chunk as never, ...(args as never[])); | |
| }) as typeof response.write; | |
| response.end = ((chunk?: unknown, ...args: unknown[]) => { | |
| let actualChunk = chunk; | |
| let encoding: BufferEncoding | undefined; | |
| // Handle res.end(cb) overload: first argument is a callback, no body. | |
| if (typeof actualChunk === 'function') { | |
| actualChunk = undefined; | |
| } else if (typeof args[0] === 'string') { | |
| encoding = args[0] as BufferEncoding; | |
| } | |
| appendChunk(actualChunk, encoding); |
|
|
||
| it('replays cached response for repeated /logs payload hash', async () => { | ||
| const body = { | ||
| type: 'error', | ||
| operation: 'peginNative', | ||
| location: 'frontend register', | ||
| error: { | ||
| message: 'wallet timeout', | ||
| code: 504, | ||
| }, | ||
| }; | ||
| const payloadHash = hashJsonPayload(body); | ||
|
|
||
| await client | ||
| .post('/logs') | ||
| .set('api_key', apiKey) | ||
| .set('x-payload-hash', payloadHash) | ||
| .send(body) | ||
| .expect(200); | ||
|
|
||
| await client | ||
| .post('/logs') | ||
| .set('api_key', apiKey) | ||
| .set('x-payload-hash', payloadHash) | ||
| .send(body) | ||
| .expect(200) | ||
| .expect('x-payload-hash-cache', 'HIT'); | ||
| }); |
There was a problem hiding this comment.
This test posts to /logs, but src/controllers/logs.controller.ts has been removed in this PR, so the route will return 404 and this caching assertion won’t be valid. Either restore/replace the /logs endpoint or update/remove this test to target an existing route.
| it('replays cached response for repeated /logs payload hash', async () => { | |
| const body = { | |
| type: 'error', | |
| operation: 'peginNative', | |
| location: 'frontend register', | |
| error: { | |
| message: 'wallet timeout', | |
| code: 504, | |
| }, | |
| }; | |
| const payloadHash = hashJsonPayload(body); | |
| await client | |
| .post('/logs') | |
| .set('api_key', apiKey) | |
| .set('x-payload-hash', payloadHash) | |
| .send(body) | |
| .expect(200); | |
| await client | |
| .post('/logs') | |
| .set('api_key', apiKey) | |
| .set('x-payload-hash', payloadHash) | |
| .send(body) | |
| .expect(200) | |
| .expect('x-payload-hash-cache', 'HIT'); | |
| }); |


No description provided.