-
Notifications
You must be signed in to change notification settings - Fork 10
fix: enableLogging no longer breaks requests or logs each one 79 times #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,18 @@ import axios, { InternalAxiosRequestConfig } from "axios"; | |
| import { Configuration } from "../Configuration"; | ||
| import { HttpError } from "../errors"; | ||
|
|
||
| /** Longest serialized body a log line carries before it is cut. */ | ||
| const MAX_LOGGED_DATA_LENGTH = 1000; | ||
|
|
||
| /** | ||
| * Configurations whose request logger is already installed on the global axios instance. | ||
| * | ||
| * Every resource and sub-resource a `PlaneClient` builds runs the `BaseResource` | ||
| * constructor with the same `Configuration`. Installing once per resource logged every | ||
| * request once per resource; keying on the configuration installs it once per client. | ||
| */ | ||
| const loggingInstalledFor = new WeakSet<Configuration>(); | ||
|
|
||
| /** | ||
| * Base resource class containing HTTP logic and authentication | ||
| * All API resources should extend this class | ||
|
|
@@ -12,7 +24,8 @@ export abstract class BaseResource { | |
|
|
||
| constructor(config: Configuration) { | ||
| this.config = config; | ||
| if (config.enableLogging) { | ||
| if (config.enableLogging && !loggingInstalledFor.has(config)) { | ||
| loggingInstalledFor.add(config); | ||
| this.setupInterceptors(); | ||
| } | ||
| } | ||
|
|
@@ -37,15 +50,25 @@ export abstract class BaseResource { | |
| } | ||
|
|
||
| /** | ||
| * Sanitize data to remove sensitive information and limit size | ||
| * Sanitize data to remove sensitive information and limit size. | ||
| * | ||
| * Only ever feeds a log line, so it must never throw: logging describes a request and | ||
| * must not change its outcome. | ||
| */ | ||
| private sanitizeData(data: any): any { | ||
| private sanitizeData(data: unknown): unknown { | ||
| if (!data) return data; | ||
|
|
||
| // If data is too large, truncate it | ||
| const dataStr = JSON.stringify(data); | ||
| if (dataStr.length > 1000) { | ||
| return JSON.parse(dataStr.substring(0, 1000)) + "... [TRUNCATED]"; | ||
| let dataStr: string | undefined; | ||
| try { | ||
| dataStr = JSON.stringify(data); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win Sensitive Data Exposure Reachability: External Redact body secrets before logging request data. If an API request body contains a token or password within the first 1,000 characters, 🤖 Prompt for AI Agents |
||
| } catch { | ||
| return "[UNSERIALIZABLE]"; | ||
| } | ||
|
|
||
| // If data is too large, truncate it. The cut lands mid-JSON, so keep it a string — | ||
| // `JSON.parse` on it throws. | ||
| if (dataStr !== undefined && dataStr.length > MAX_LOGGED_DATA_LENGTH) { | ||
| return `${dataStr.slice(0, MAX_LOGGED_DATA_LENGTH)}... [TRUNCATED]`; | ||
| } | ||
|
|
||
| return data; | ||
|
|
@@ -185,12 +208,19 @@ export abstract class BaseResource { | |
| } | ||
|
|
||
| /** | ||
| * Setup axios interceptors for request and response logging | ||
| * Setup the axios request interceptor that logs requests. | ||
| * | ||
| * It sits on the global axios instance, which the host application and `OAuthClient` | ||
| * use too, so it only reports requests under this client's API prefix. Matching | ||
| * `baseUrl` alone would log `OAuthClient`'s token exchange, whose body carries the | ||
| * client secret. | ||
| */ | ||
| private setupInterceptors(): void { | ||
| const apiPrefix = `${this.config.baseUrl}${this.apiBasePath}`; | ||
| // Request interceptor | ||
| axios.interceptors.request.use( | ||
| (config: InternalAxiosRequestConfig) => { | ||
| if (!config.url?.startsWith(apiPrefix)) return config; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win Sensitive Data Exposure Reachability: External Sanitize query values on accepted API requests. If an accepted API URL contains 🤖 Prompt for AI Agents🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win Sensitive Data Exposure Reachability: External Require an API path boundary in the URL filter. For 🤖 Prompt for AI Agents |
||
| console.log("🚀 [REQUEST]", { | ||
| method: config.method?.toUpperCase(), | ||
| url: config.url, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import axios from "axios"; | ||
| import nock from "nock"; | ||
| import { OAuthClient } from "../../src/client/oauth-client"; | ||
| import { PlaneClient } from "../../src/client/plane-client"; | ||
| import { HttpError } from "../../src/errors"; | ||
|
|
||
| /** | ||
| * `enableLogging: true` describes requests; it must never change their outcome. | ||
| * Offline, so it runs without a Plane instance. | ||
| */ | ||
| const BASE = "https://plane.example.com"; | ||
| const API_KEY = "logging-test-secret"; | ||
| const LABELS = "/api/v1/workspaces/ws/projects/p/labels/"; | ||
| const REQUEST_LOG = "🚀 [REQUEST]"; | ||
|
|
||
| let log: jest.SpyInstance; | ||
|
|
||
| const makeClient = () => new PlaneClient({ apiKey: API_KEY, baseUrl: BASE, enableLogging: true }); | ||
|
|
||
| /** The payload of every `🚀 [REQUEST]` line logged so far. */ | ||
| const requestLogs = () => log.mock.calls.filter(([label]) => label === REQUEST_LOG).map(([, entry]) => entry); | ||
|
|
||
| beforeEach(() => { | ||
| log = jest.spyOn(console, "log").mockImplementation(() => undefined); | ||
| jest.spyOn(console, "error").mockImplementation(() => undefined); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| // The logger is installed on the global axios instance; start every test without one. | ||
| axios.interceptors.request.clear(); | ||
| nock.cleanAll(); | ||
| jest.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe("BaseResource request logging", () => { | ||
| it("sends a request whose body serializes past the truncation limit", async () => { | ||
| const body = { name: "big", description: "x".repeat(1500) }; | ||
| const scope = nock(BASE).post(LABELS, body).reply(201, { id: "l1", name: "big" }); | ||
|
|
||
| await expect(makeClient().labels.create("ws", "p", body)).resolves.toMatchObject({ id: "l1" }); | ||
|
|
||
| expect(scope.isDone()).toBe(true); | ||
| }); | ||
|
|
||
| it("logs a truncated body as a string, with the api key redacted", async () => { | ||
| nock(BASE).post(LABELS).reply(201, { id: "l1" }); | ||
|
|
||
| await makeClient().labels.create("ws", "p", { name: "big", description: "x".repeat(1500) }); | ||
|
|
||
| const [entry] = requestLogs(); | ||
| expect(entry.data).toMatch(/\.\.\. \[TRUNCATED\]$/); | ||
| expect(JSON.stringify(entry)).not.toContain(API_KEY); | ||
| }); | ||
|
|
||
| it("logs each request once, however many resources the client built", async () => { | ||
| nock(BASE).get(`${LABELS}l1/`).reply(200, { id: "l1" }); | ||
|
|
||
| await makeClient().labels.retrieve("ws", "p", "l1"); | ||
|
|
||
| expect(requestLogs()).toHaveLength(1); | ||
| }); | ||
|
|
||
| it("raises HttpError for a failed request whose response body is large", async () => { | ||
| nock(BASE) | ||
| .get(`${LABELS}l1/`) | ||
| .reply(502, `<html>${"x".repeat(1500)}</html>`); | ||
|
|
||
| const failure = makeClient().labels.retrieve("ws", "p", "l1"); | ||
|
|
||
| await expect(failure).rejects.toBeInstanceOf(HttpError); | ||
| await expect(failure).rejects.toMatchObject({ statusCode: 502 }); | ||
| }); | ||
|
|
||
| it("leaves the host application's own axios requests alone", async () => { | ||
| makeClient(); // installs the logger on the global axios instance | ||
| const scope = nock("https://elsewhere.example.com").post("/hook").reply(200, { ok: true }); | ||
|
|
||
| await expect(axios.post("https://elsewhere.example.com/hook", { blob: "z".repeat(1500) })).resolves.toMatchObject({ | ||
| status: 200, | ||
| }); | ||
|
|
||
| expect(scope.isDone()).toBe(true); | ||
| expect(requestLogs()).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("does not log the OAuth token exchange, whose body carries the client secret", async () => { | ||
| makeClient(); // same baseUrl as the OAuth client, as with the api.plane.so defaults | ||
| const scope = nock(BASE).post("/auth/o/token/").reply(200, { access_token: "a" }); | ||
| const oauth = new OAuthClient({ | ||
| baseUrl: BASE, | ||
| clientId: "client-id", | ||
| clientSecret: "oauth-client-secret", | ||
| redirectUri: "https://app.example.com/callback", | ||
| }); | ||
|
|
||
| await oauth.exchangeCodeForToken("auth-code"); | ||
|
|
||
| expect(scope.isDone()).toBe(true); | ||
| expect(requestLogs()).toHaveLength(0); | ||
| expect(JSON.stringify(log.mock.calls)).not.toContain("oauth-client-secret"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win
Sensitive Data Exposure
Reachability: External
Exploitability: Moderate
CWE: CWE-532 — Insertion of Sensitive Information into Log File
Scope the logger to the client, not only its
Configuration.If two
PlaneClientinstances use the samebaseUrl, each enabled configuration installs a global interceptor. Both interceptors then log either client's requests. A later client withenableLogging: falseis logged by an earlier enabled client's interceptor. The WeakSet prevents duplicate registration among one client's resources, but it does not isolate clients. Use a client-owned Axios instance for SDK requests and its logger. Axios documents that interceptors registered on the default instance run for requests made through that instance. (github.com)View in Security blast radius
🤖 Prompt for AI Agents