Building backlog-toolkit to Stop Accidental Backlog Uploads — Combining UI Suppression with declarativeNetRequest and Attributing Block Causes
Backlog exposes file attachment from issues, comments, Wiki pages, and shared files, and reacts to drag-and-drop and image paste as well. The number of entry points is helpful during normal work, but it also means a wrong document can slip in with a single accidental gesture. During the window before access controls or a DLP policy catch up, I wanted a browser-side guard that stops the gesture before it reaches the server. The result is backlog-toolkit, an unofficial browser extension for Chrome and Microsoft Edge. The PR is codenote-net/backlog-toolkit#1.
The extension is a personal project and is not affiliated with Nulab. It is a proof of concept at this stage. This post walks through the design decisions I made along the way.
Overview of What the PoC Adds
- A Manifest V3 Chrome / Edge extension that operates only on explicitly configured Backlog spaces
- Hides and disables file inputs and configured attachment controls, including dynamically inserted
input[type="file"] - Intercepts drag-and-drop and file / image paste while allowing text-only paste
- Uses declarativeNetRequest to block POST on
/api/v2/space/attachment,/AddAttachmentAsync.action,/AddWikiAttachmentAsync.action, and/r/files/addSharedFile - Uses
declarativeNetRequestFeedbackandonRuleMatchedDebugso that only the extension’s own rule matches drive the block notice - Gitignores
config.local.jsonanddist/so that private space names do not land in commits - Ships with a local Chrome demo (
127.0.0.1:4173), configuration validation, automated tests, and a verification record indocs/chrome-verification.md
Blocking at Both the UI and the Network Layer
Backlog’s upload surface is not a single file input: it includes custom attachment buttons, drop zones embedded in editors, and image paste from the system clipboard. Blocking only at the UI layer leaves alternative gestures that still trigger an upload, and blocking only at the network layer leaves a confusing user experience where the UI pretends an attachment landed when nothing reached the server.
The PoC applies both layers independently.
- UI layer: a content script hides and disables
input[type="file"]and configured attachment controls, and intercepts drop, paste, click, andchangein the capture phase usingaddEventListener(type, handler, true) - Network layer: Manifest V3 declarativeNetRequest rules
blockPOSTs whose URL matches the configured origin anduploadPathscombinations
The content script accounts for Backlog’s SPA-like rerenders by watching DOM changes with MutationObserver. Writing back into the DOM inside the observer microtask tends to produce reentrancy, so the restrict pass runs on requestAnimationFrame, and the code explicitly avoids rewriting an unchanged DOM.
Drop and paste events are blocked only when the DataTransfer.types list contains Files or when any DataTransferItem.kind equals file. When a clipboard payload mixes text and files, the handler blocks the entire gesture conservatively.
Blocking POST Only with regexFilter and requestMethods
In Manifest V3, request blocking goes through declarative declarativeNetRequest rules instead of the earlier blocking webRequest handlers. In scripts/config.mjs, the rule generator produces one rule per origin × path pair.
export function makeRules(config) {
return config.origins.flatMap((origin) => config.uploadPaths.map((path) => ({
id: 0,
priority: 1,
action: { type: 'block' },
condition: {
regexFilter: `^${escapeRegex(origin)}${escapeRegex(path)}/?(\\?.*)?$`,
isUrlFilterCaseSensitive: true,
requestMethods: ['post'],
},
}))).map((rule, index) => ({ ...rule, id: index + 1 }));
}Three decisions matter here.
- Use
regexFilter, escaping the origin and path separately before concatenation. TheurlFilterpattern language is convenient, but handling query strings and an optional trailing slash in one expression is clearer as a regex - Restrict to
requestMethods: ['post']so that download routes remain usable. Attachment downloads and Wiki thumbnails are GET and must still work - Set
isUrlFilterCaseSensitive: trueexplicitly, since observed Backlog API paths have fixed casing and there is no reason to accept case-insensitive matches
The initial configuration included only the public /api/v2/space/attachment endpoint. Real-space Chrome testing then revealed that issue, comment, Wiki, and shared-file uploads each hit different non-public routes, so /AddAttachmentAsync.action, /AddWikiAttachmentAsync.action, and /r/files/addSharedFile were added from observed traffic. These paths are not part of Nulab’s documented public API, so future UI changes may route uploads differently. The README calls out that this PoC is not complete upload prevention precisely because of this kind of route drift.
Validating Origins as Exactly One Space, HTTPS, No Paths
Target spaces are listed explicitly in config.local.json. The validator in scripts/config.mjs requires:
- HTTPS
- A single-subdomain hostname under
backlog.com,backlog.jp, orbacklogtool.com - A normalized
url.originthat equals the input string exactly - No port (except for the demo origin
http://127.0.0.1:4173) uploadPathsentries that start with/and contain no..
const backlog = url.protocol === 'https:' &&
/^[a-z0-9-]+\.(backlog\.(com|jp)|backlogtool\.com)$/.test(url.hostname);
const local = demo && origin === 'http://127.0.0.1:4173';
if ((!backlog && !local) || url.origin !== origin || url.port && !local) {
throw new Error(`Unsupported origin: ${origin}. ...`);
}The validator does not accept wildcards or trailing slashes and silently normalize them away. Users must supply exact URLs, and config.local.json and dist/ are listed in .gitignore so that private space names do not end up committed. For an OSS template repository, that pattern is important: a careless add would otherwise leak space hostnames into the public history.
Using onRuleMatchedDebug to Attribute Blocks to Our Own Rules
When the extension shows an “upload was blocked” notice, which event drives the notice determines whether users trust it. A naive implementation can misattribute arbitrary network errors or server-side 4xx responses to the extension itself.
This PoC uses the declarativeNetRequestFeedback permission and the onRuleMatchedDebug event. The notice fires only when the extension’s own upload_blocking ruleset matches.
chrome.declarativeNetRequest.onRuleMatchedDebug?.addListener((info) => {
if (info.rule.rulesetId !== 'upload_blocking' || info.request.tabId < 0) return;
chrome.tabs.sendMessage(info.request.tabId, {
type: 'backlog-toolkit:upload-blocked',
}, { frameId: Math.max(info.request.frameId, 0) }).catch(() => {
// ...
});
});As the Chrome documentation states, onRuleMatchedDebug fires only for unpacked extensions. Full block attribution is therefore available only in the unpacked PoC, and both the README and inline comments say so. Equivalent attribution in a Chrome Web Store build would require a different notification design at distribution time.
The content script deduplicates rule-match notifications within a 500 ms window, and updates both the ephemeral toast and the persistent inline notice next to the attachment control. Messages targeted at frames without a listener (another tab, a page opened before install) are handled with .catch(() => {}) instead of surfacing a runtime error.
Framing the Tool as Accident Prevention, Not Authorization
This tool is intended to reduce browser-side accidents and is not a substitute for server-side authorization. The README calls out that it is not a non-bypassable security boundary.
- Disabling or removing the extension, using a different browser, using a direct API client such as
curl, and the mobile apps all bypass it entirely - Unlisted upload endpoints, non-POST upload protocols, custom editors, shadow DOM, and cross-origin frames are out of scope or not guaranteed to be covered
- Existing attachments are not removed
Requested permissions are minimized accordingly. The extension asks for declarativeNetRequestWithHostAccess, declarativeNetRequestFeedback, and only the host permissions listed in config.local.json. It has no backend server, telemetry, storage, or API credentials. The service worker forwards only a confirmed rule-match signal; URLs and file contents are not sent. The network rules themselves do not inspect request bodies.
Chrome Verification and Keeping a Record in docs/chrome-verification.md
Even for a PoC, “works on a real Backlog space” cannot be established without checking that UI suppression selectors and network block targets match the live environment. Chrome verification was carried out in an authorized test project, and the record is kept in docs/chrome-verification.md.
Key checks:
- All nine local demo checks pass, and disabling the demo extension produces the expected failures
- Issue attachment traffic (
/AddAttachmentAsync.action) shows(blocked:other)and 0 B transferred in Chrome Network; no issue is submitted - Comment attachment goes through the same route with 0 B transferred; no comment is submitted
- Wiki (
/AddWikiAttachmentAsync.action) and shared-file (/r/files/addSharedFile) routes were discovered during real-space testing, added to configuration, and confirmed with the extension’s own rule-match notice - Native file inputs and configured issue, comment, Wiki, and shared-file controls are hidden and disabled
- Issue text paste and comment preview continue to work; image paste displays the block notice and nothing is inserted
- Wiki and shared-file controls remain restricted after navigation and reload
- A harmless shared-file fixture downloads successfully and its bytes match the original
- Unconfigured Backlog spaces, and localhost under the production build, are unaffected
- Eight automated regression tests pass, covering all four configured paths, origin and path boundaries, POST-only behavior, and notification routing
To establish that the network block is independent of UI suppression, I temporarily edited only the generated output to leave native file controls visible while keeping the network rules and rule-match notifications active. Source protections were unchanged. After the check, the normal build was restored, the extension was reloaded, and the issue page was reloaded as well.
Any temporary attachment objects created during testing (one unsaved attachment on an issue form, and the harmless text fixtures used to discover the Wiki and shared-file routes) were deleted after verification. For the unsaved issue-form attachment, server-side cleanup of the backing object was not verified independently.
Remaining Coverage
This PoC is explicitly not complete upload prevention; the following remain untested or out of scope:
- End-to-end issue creation and editing, comment submission, and Wiki body saves. Current real-space testing stops before submission
- A manual confirmation that a generic server-side failure in the real UI does not show a falsely attributed “blocked by extension” notice. Notification source and unrelated-ruleset behavior are already covered automatically
- Another authenticated, unconfigured Backlog space. Exact-origin isolation has automated coverage, and localhost has browser coverage
- Native OS drag-and-drop on the real space. Only a manual check by the author was performed, with no independent automated confirmation; synthetic drop interception passed in the local demo
- Document uploads, any additional upload routes, and separate verification on Microsoft Edge
Configuration applies to the entire configured space, not just the project used for verification. Within that scope, this PoC is an accident-prevention tool rather than a complete enforcement boundary.
That’s all from building backlog-toolkit to stop accidental Backlog uploads with layered UI suppression and declarativeNetRequest blocking, from the Gemba.