-
-
Notifications
You must be signed in to change notification settings - Fork 19
Instrument urls built from a JavaScript undefined (BL-16666) #8175
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
Open
JohnThomson
wants to merge
8
commits into
master
Choose a base branch
from
BL-16666-undefined-url-instrumentation
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 4 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
cc26cb9
Instrument urls built from a JavaScript undefined (BL-16666)
JohnThomson 0585c64
Point the code comments at the new card, BL-16666
JohnThomson 3450686
Merge remote-tracking branch 'origin/master' into BL-16666-undefined-…
JohnThomson 284dde3
Don't interrupt users with our own url bug, and fix a stale test (BL-…
JohnThomson c2438fb
Actually include both kinds of detail in the report (BL-16666)
JohnThomson 79858a5
Make the two "is this our url bug?" decisions share one derivation (B…
JohnThomson b8c9cb7
Source-map the detector's stacks, or they name a bundle not our code …
JohnThomson 4607a0b
Key the once-only rule on the call site, not the message (BL-16666)
JohnThomson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
|
JohnThomson marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
JohnThomson marked this conversation as resolved.
JohnThomson marked this conversation as resolved.
JohnThomson marked this conversation as resolved.
JohnThomson marked this conversation as resolved.
JohnThomson marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| // Catches the moment a piece of our front-end code puts a JavaScript value it doesn't actually | ||
| // have into a url. | ||
| // | ||
| // `element.src = someUndefinedVariable` doesn't fail: the DOM turns the value into the *text* | ||
| // "undefined" and asks the server for a file by that name. The server can only report that some | ||
| // file called "undefined" is missing, which tells us nothing about who asked for it - so these | ||
| // have sat in Sentry for years without ever being traceable to a line of code (BL-16666, split from BL-16577). We | ||
| // already fixed one instance the hard way (BL-16447, where the Adjust Timings dialog rendered | ||
| // before its audio url was ready); this exists so the next one names itself. | ||
| // | ||
| // The interception below is deliberately passive: it reports and then lets the assignment | ||
| // proceed exactly as before. What we need from it is the stack, and we get that either way, so | ||
| // there is no reason to change what the app does. | ||
|
|
||
| /// The exact strings JavaScript produces when these values are turned into text. We match them | ||
| /// case-sensitively and only as a whole url or a whole path segment, so a real file that happens | ||
| /// to be called "Undefined.png", or a folder called "undefinedThings", is left alone. | ||
| const javascriptValueStrings = ["undefined", "null", "NaN"]; | ||
|
|
||
| /** | ||
| * True if this will become a url that is, or contains a path segment that is, the text form of a | ||
| * JavaScript value. Nothing legitimately loads such a url, so a true here is always one of our bugs. | ||
| * | ||
| * Takes the value rather than a string on purpose. At an assignment like `img.src = x` the value | ||
| * arrives here as the real `undefined`; it is the DOM that turns it into the text "undefined" a | ||
| * moment later. By the time anyone could see a string it is already too late to know who did it. | ||
| */ | ||
| export function isJavascriptValueUrl(url: unknown): boolean { | ||
| // These are exactly the values that stringify to the text we are looking for. | ||
| if (url === undefined || url === null) return true; | ||
| if (typeof url === "number") return Number.isNaN(url); | ||
| // Anything else that isn't a string (a URL object, say) stringifies to something real. | ||
| if (typeof url !== "string" || url.length === 0) return false; | ||
|
|
||
| // Ignore any query string or fragment; only the path can name a file. | ||
| const path = url.split(/[?#]/)[0]; | ||
| return path | ||
| .split(/[/\\]/) | ||
| .some((segment) => javascriptValueStrings.includes(segment)); | ||
| } | ||
|
|
||
| /** | ||
| * What we send to the server. Kept separate from the interception so it can be tested, and so the | ||
| * message reads the same wherever it was caught. | ||
| */ | ||
| export function describeBadUrl(url: unknown, whereItWasSet: string): string { | ||
| return ( | ||
| `A url was built from a JavaScript value that wasn't ready: ${whereItWasSet} was set to "${String(url)}". ` + | ||
| `Bloom will now ask the server for a file by that name and fail. See BL-16666.` | ||
| ); | ||
| } | ||
|
|
||
| type Reporter = (message: string, stack: string | undefined) => void; | ||
|
|
||
| let installed = false; | ||
|
|
||
| // Each report is an http post to the server, and the bug that triggers it is typically a render | ||
| // that repeats - the Adjust Timings dialog in BL-16447 would have fired on every open. We only | ||
| // need to learn about each site once, so report a given message once and stop altogether after a | ||
| // handful. Losing the repeat count costs us nothing: the point is to identify the code, and the | ||
| // server-side count in Sentry already tells us how often it happens. | ||
| const maxReports = 10; | ||
| const alreadyReported = new Set<string>(); | ||
|
|
||
| /** Exported only so tests can start from a clean slate. */ | ||
| export function resetReportingForTests(): void { | ||
| alreadyReported.clear(); | ||
| } | ||
|
|
||
| /** | ||
| * Installs the interception. Safe to call more than once; only the first call does anything, so a | ||
| * bundle that pulls in the bootstrap twice doesn't end up double-reporting. | ||
| * | ||
| * @param report how to get the problem back to us - errorHandler's reportError in real use. | ||
| */ | ||
| export function installUndefinedUrlDetector(report: Reporter): void { | ||
| if (installed || typeof window === "undefined") return; | ||
| installed = true; | ||
|
|
||
| const check = (url: unknown, whereItWasSet: string) => { | ||
| if (!isJavascriptValueUrl(url)) return; | ||
| const message = describeBadUrl(url, whereItWasSet); | ||
| if (alreadyReported.has(message) || alreadyReported.size >= maxReports) | ||
| return; | ||
| alreadyReported.add(message); | ||
| // The stack of *this* call is the whole point: it names the line that built the url. | ||
| report(message, new Error().stack); | ||
| }; | ||
|
|
||
| guardSrcProperty(window.HTMLImageElement, "an image's src", check); | ||
| guardSrcProperty(window.HTMLMediaElement, "an audio/video src", check); | ||
| guardSrcProperty(window.HTMLIFrameElement, "an iframe's src", check); | ||
| guardSrcProperty(window.HTMLScriptElement, "a script's src", check); | ||
| guardSetAttribute(check); | ||
| guardFetch(check); | ||
| guardXmlHttpRequest(check); | ||
| } | ||
|
|
||
| /** | ||
| * Wraps the `src` property of one element type. We call the original setter afterwards, so the | ||
| * element behaves exactly as it did before; we are only listening. | ||
| */ | ||
| function guardSrcProperty( | ||
| elementType: { prototype: object } | undefined, | ||
| description: string, | ||
| check: (url: unknown, whereItWasSet: string) => void, | ||
| ): void { | ||
| // Not every environment has every element type (jsdom, for one), and a browser could in | ||
| // principle define src without a setter. Nothing to wrap in that case. | ||
| if (!elementType) return; | ||
| const original = Object.getOwnPropertyDescriptor( | ||
| elementType.prototype, | ||
| "src", | ||
| ); | ||
| if (!original || !original.set) return; | ||
|
|
||
| Object.defineProperty(elementType.prototype, "src", { | ||
| ...original, | ||
| set(value: unknown) { | ||
| check(value, description); | ||
| original.set!.call(this, value); | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| function guardSetAttribute( | ||
| check: (url: unknown, whereItWasSet: string) => void, | ||
| ): void { | ||
| const originalSetAttribute = Element.prototype.setAttribute; | ||
| Element.prototype.setAttribute = function ( | ||
| name: string, | ||
| value: string, | ||
| ): void { | ||
| // Only src: an <a href> of "undefined" is a dead link rather than a failed request, and | ||
| // is not what we are hunting. | ||
| if (name === "src") check(value, `setAttribute("src")`); | ||
| originalSetAttribute.call(this, name, value); | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * The BL-16447 case came in through a library (WaveSurfer) fetching the url rather than putting it | ||
| * on an element, so element interception alone would have missed it. | ||
| */ | ||
| function guardFetch( | ||
| check: (url: unknown, whereItWasSet: string) => void, | ||
| ): void { | ||
| if (typeof window.fetch !== "function") return; | ||
| const originalFetch = window.fetch; | ||
| window.fetch = function (input: RequestInfo | URL, init?: RequestInit) { | ||
| check(urlOfFetchInput(input), "a fetch()"); | ||
| // Call it on `window`, not on `this`. A bare `fetch(url)` inside a module gives us | ||
| // `this === undefined`, and the browser rejects fetch invoked on anything that isn't the | ||
| // window ("Illegal invocation") - which would break every fetch in Bloom. | ||
| return originalFetch.call(window, input, init); | ||
| }; | ||
| } | ||
|
|
||
| function urlOfFetchInput(input: RequestInfo | URL): string | undefined { | ||
| if (typeof input === "string") return input; | ||
| if (input instanceof URL) return input.href; | ||
| // A Request object | ||
| if (input && typeof (input as Request).url === "string") | ||
| return (input as Request).url; | ||
| return undefined; | ||
| } | ||
|
|
||
| function guardXmlHttpRequest( | ||
| check: (url: unknown, whereItWasSet: string) => void, | ||
| ): void { | ||
| if (typeof XMLHttpRequest !== "function") return; | ||
| const originalOpen = XMLHttpRequest.prototype.open; | ||
| // Deliberately untyped rest args: open() has two overloads and we only care about the url. | ||
| XMLHttpRequest.prototype.open = function ( | ||
| method: string, | ||
| url: string | URL, | ||
| ...rest: unknown[] | ||
| ) { | ||
| check(typeof url === "string" ? url : url?.href, "an XMLHttpRequest"); | ||
| return (originalOpen as (...args: unknown[]) => void).call( | ||
| this, | ||
| method, | ||
| url, | ||
| ...rest, | ||
| ); | ||
| }; | ||
| } |
|
JohnThomson marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import { | ||
| describeBadUrl, | ||
| installUndefinedUrlDetector, | ||
| isJavascriptValueUrl, | ||
| resetReportingForTests, | ||
| } from "./undefinedUrlDetector"; | ||
|
|
||
| describe("isJavascriptValueUrl", () => { | ||
| it("catches a url that is nothing but the value", () => { | ||
| expect(isJavascriptValueUrl("undefined")).toBe(true); | ||
| expect(isJavascriptValueUrl("null")).toBe(true); | ||
| expect(isJavascriptValueUrl("NaN")).toBe(true); | ||
| }); | ||
|
|
||
| it("catches the value as a path segment, which is the shape we see in Sentry", () => { | ||
| // The real reported paths look like this, because a bare "undefined" src resolves | ||
| // against a document whose base is the temp folder. | ||
| expect( | ||
| isJavascriptValueUrl( | ||
| "C:/Users/someone/AppData/Local/Temp/undefined", | ||
| ), | ||
| ).toBe(true); | ||
| expect(isJavascriptValueUrl("audio/undefined")).toBe(true); | ||
| expect(isJavascriptValueUrl("undefined/audio/abc.mp3")).toBe(true); | ||
| }); | ||
|
|
||
| it("ignores the query string and fragment", () => { | ||
| expect(isJavascriptValueUrl("audio/undefined?assetv=1")).toBe(true); | ||
| expect(isJavascriptValueUrl("audio/real.mp3?name=undefined")).toBe( | ||
| false, | ||
| ); | ||
| expect(isJavascriptValueUrl("page.htm#undefined")).toBe(false); | ||
| }); | ||
|
|
||
| it("catches the values themselves, which is what an assignment actually passes", () => { | ||
| // `img.src = x` hands us the real value; the DOM stringifies it afterwards. Catching it | ||
| // here is the only moment at which we still know who set it. | ||
| expect(isJavascriptValueUrl(undefined)).toBe(true); | ||
| expect(isJavascriptValueUrl(null)).toBe(true); | ||
| expect(isJavascriptValueUrl(Number.NaN)).toBe(true); | ||
| }); | ||
|
|
||
| it("leaves real files and folders alone", () => { | ||
| expect(isJavascriptValueUrl("audio/abc123.mp3")).toBe(false); | ||
| expect(isJavascriptValueUrl("undefined.png")).toBe(false); | ||
| expect(isJavascriptValueUrl("undefinedThings/x.png")).toBe(false); | ||
| expect(isJavascriptValueUrl("images/Undefined")).toBe(false); | ||
| expect(isJavascriptValueUrl("")).toBe(false); | ||
| // A real number or a URL object stringifies to something legitimate. | ||
| expect(isJavascriptValueUrl(17)).toBe(false); | ||
| expect(isJavascriptValueUrl(new URL("http://x/y.png"))).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("describeBadUrl", () => { | ||
| it("names both the value and where it was set", () => { | ||
| const message = describeBadUrl(undefined, "an image's src"); | ||
| expect(message).toContain("an image's src"); | ||
| expect(message).toContain('"undefined"'); | ||
| expect(message).toContain("BL-16666"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("installUndefinedUrlDetector", () => { | ||
| // Installed once for the whole file, as it is in the real app: the installer deliberately | ||
| // ignores repeat calls so a bundle can't double-report. | ||
| const report = vi.fn(); | ||
| installUndefinedUrlDetector(report); | ||
|
|
||
| beforeEach(() => { | ||
| report.mockClear(); | ||
| // Each test would otherwise be silenced by the previous test's identical message. | ||
| resetReportingForTests(); | ||
| }); | ||
|
|
||
| it("reports an image src assigned an undefined variable, with a stack", () => { | ||
| report.mockClear(); | ||
| const image = document.createElement("img"); | ||
| let notReadyYet: string | undefined; | ||
|
|
||
| image.src = notReadyYet as unknown as string; | ||
|
|
||
| expect(report).toHaveBeenCalledTimes(1); | ||
| const [message, stack] = report.mock.calls[0]; | ||
| expect(message).toContain("an image's src"); | ||
| // The stack is the entire point of this layer: it is what names the offending line. | ||
| expect(stack).toBeTruthy(); | ||
| expect(stack).toContain("undefinedUrlDetectorSpec"); | ||
| }); | ||
|
|
||
| it("still actually sets the src, so nothing behaves differently", () => { | ||
| report.mockClear(); | ||
| const image = document.createElement("img"); | ||
|
|
||
| image.src = "audio/real.mp3"; | ||
|
|
||
| expect(report).not.toHaveBeenCalled(); | ||
| expect(image.getAttribute("src")).toBe("audio/real.mp3"); | ||
| }); | ||
|
|
||
| it("reports setAttribute('src', undefined) too", () => { | ||
| report.mockClear(); | ||
| const image = document.createElement("img"); | ||
|
|
||
| image.setAttribute("src", undefined as unknown as string); | ||
|
|
||
| expect(report).toHaveBeenCalledTimes(1); | ||
| expect(report.mock.calls[0][0]).toContain('setAttribute("src")'); | ||
| }); | ||
|
|
||
| it("leaves other attributes alone", () => { | ||
| report.mockClear(); | ||
| const div = document.createElement("div"); | ||
|
|
||
| div.setAttribute("title", "undefined"); | ||
|
|
||
| expect(report).not.toHaveBeenCalled(); | ||
| expect(div.getAttribute("title")).toBe("undefined"); | ||
| }); | ||
|
|
||
| it("reports a repeating bug only once, so a re-rendering component can't flood the server", () => { | ||
| const image = document.createElement("img"); | ||
|
|
||
| image.src = undefined as unknown as string; | ||
| image.src = undefined as unknown as string; | ||
| image.src = undefined as unknown as string; | ||
|
|
||
| expect(report).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it("reports a fetch of an undefined url, which is how BL-16447 escaped", () => { | ||
| // Called bare, exactly as application code calls it - which means the wrapper receives | ||
| // `this === undefined` and must not pass that on to the real fetch. | ||
| void fetch("audio/undefined").catch(() => { | ||
| // jsdom has no real network; the rejection is expected and irrelevant here. | ||
| }); | ||
|
|
||
| expect(report).toHaveBeenCalledTimes(1); | ||
| expect(report.mock.calls[0][0]).toContain("a fetch()"); | ||
| }); | ||
|
|
||
| it("leaves an ordinary fetch alone", () => { | ||
| void fetch("audio/real.mp3").catch(() => { | ||
| // Again, no network in jsdom. | ||
| }); | ||
|
|
||
| expect(report).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.