a friend told me her professor pulled her aside after a quiz because the LMS flagged her for "tab switching 7 times". she wasn't cheating. she alt-tabbed to check the time on her clock app, then back. seven times over a 50-minute quiz.
i went looking for what canvas actually sends back when you blur the window. turns out it's pretty noisy.
what canvas tracks
open devtools on a quiz page, switch tabs, switch back. you'll see POSTs to something like /api/v1/courses/X/quizzes/Y/submissions/Z/events with payloads like:
{
"event_type": "page_blurred",
"event_data": { "timestamp": 1716902400000 }
}
then page_focused when you come back. it also pings on visibilitychange for good measure, and there's a separate page-view heartbeat that ticks every few seconds.
the events get attached to your submission. teachers see them in speedgrader as a little timeline. some schools have explicit policies that more than N tab-switches is grounds for "additional scrutiny".
first attempt: just block the endpoint
my first idea was a one-line declarativeNetRequest rule blocking */quiz_submission_events*. easy. doesn't work.
why? canvas uses navigator.sendBeacon() for some of these. beacons queue at the browser level and behave a little differently than fetch when it comes to extension interception. some events were still leaking through. there are also a couple of analytics endpoints that the same event posts to as redundancy, so blocking the obvious URL misses a few.
the actual approach
two layers.
layer 1: declarativeNetRequest. static rules in rules.json that block five known endpoints. cheap, fast. browser handles it before any js runs.
layer 2: a main-world inject script. patches addEventListener so anything registering for blur/focus/visibilitychange on quiz pages just gets a no-op. patches sendBeacon to return true without sending. patches fetch and XMLHttpRequest to filter the same URL patterns. also overrides document.visibilityState and document.hidden so even if a listener slips through, it always reads "visible".
manifest v3 makes you jump through hoops here. content scripts run in an isolated world by default and can't patch page globals. you need world: "MAIN" in the content_scripts entry, which is a relatively recent MV3 addition. without it none of the prototype patching works.
// snippet from inject.js
const origAdd = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, opts) {
if (BLOCKED_EVENTS.has(type) && isQuizPage()) {
return;
}
return origAdd.call(this, type, listener, opts);
};
dumb but it works. canvas's quiz js binds blur/focus exactly once on load, so if you patch addEventListener before their script runs, those listeners never get registered.
tier system
i ended up with three settings because heavy mode broke a few classes that legitimately use page-view tracking for participation grades.
- lite: blocks the quiz_events endpoint and drops blur/focus listeners. minimal footprint.
- mod: adds beacon/heartbeat blocking and stubs sendBeacon.
- heavy (default): everything above plus visibility spoofing and full fetch/XHR interception.
per-domain allowlist. doesn't activate unless you've explicitly added the school's canvas instance.
what it can't do
proctoring software (respondus lockdown, proctorio, honorlock) is outside the browser sandbox. they hook the OS-level focus events through native code. nothing a chrome extension can touch.
server-side page views are also unblockable. canvas logs an HTTP request every time it serves a page. if you load the quiz, that's logged. you can stop the blur tracking but not "they opened the quiz at 3:47pm".
what i'm not happy with yet
the inject script timing is fragile. on slow networks the canvas quiz js sometimes runs before my patches apply, and then a blur event leaks through. i've seen it twice in 50-ish tests. moving the prototype patching to a run_at: document_start content script in a separate file would close that gap. on the todo list.
DNR's five-rule cap on static rulesets is also annoying. dynamic rules would let me add more endpoints based on what i see in network logs, but then i need storage permission and the security review for store distribution gets harder. for now i'm shipping unpacked from github.
privacy note
doesn't send anything anywhere. no analytics, no crash reports, no remote config. the only network requests it makes are the ones it's blocking.
repo: https://github.com/TiltedLunar123/canvas-blinders
if you want to see what canvas actually tracks before installing anything, open devtools on a quiz, switch tabs a few times, and watch the network panel. it's all there in cleartext.
Top comments (0)