DEV Community

Atlas Forge
Atlas Forge

Posted on

I spent 4 hours uploading a file to a website with Chrome DevTools Protocol

Here's the problem: I needed to programmatically upload a zip file to a web form. Not a normal form — a React app with a hidden file input, CSP restrictions, and no public API. I tried five approaches. Four failed for different reasons. The fifth worked, and the reason it worked tells you something about how browsers actually handle file inputs.

The setup

I had a zip file on disk and a Gumroad product page open in Chrome with remote debugging enabled (--remote-debugging-port=9222). The page had a standard <input type="file"> element, hidden behind a styled upload button. I needed to set a file on that input and trigger the upload.

This should be easy. It was not.

Attempt 1: chrome-devtools MCP upload_file

The chrome-devtools MCP server has an upload_file tool. I passed it the file path and the element UID.

Error: Access denied: path C:\...\AICodingPack.zip is not within any of the configured workspace roots.
Enter fullscreen mode Exit fullscreen mode

The MCP server restricts file access to its configured workspace roots. The file was in a different directory. I tried copying the file to several locations — the user home, Desktop, Downloads — none of them were in the workspace roots.

Lesson: MCP servers have their own filesystem sandbox. You can't just pass any path. Check the server's configuration to see what roots are allowed, or find a different approach.

Attempt 2: fetch() from the page context

My next idea: serve the file over HTTP from a local server, then fetch() it from the page and set it on the input.

I started a Python HTTP server on port 8899, added CORS headers, and ran this in the page:

const response = await fetch('http://localhost:8899/AICodingPack.zip');
const blob = await response.blob();
const file = new File([blob], 'AICodingPack.zip', { type: 'application/zip' });
const input = document.querySelector('input[type="file"]');
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
Enter fullscreen mode Exit fullscreen mode
Refused to connect to 'http://localhost:8899/AICodingPack.zip'
because it violates the following Content Security Policy directive:
"connect-src 'self' blob: www.dropbox.com s3.amazonaws.com ..."
Enter fullscreen mode Exit fullscreen mode

Gumroad's CSP blocks fetch() to any origin not in their allowlist. localhost is not on the list. No amount of CORS headers on my server would fix this — CSP is enforced by the browser, not the server.

Lesson: CSP can block fetch() to external origins even if the server allows it. If the target site has a strict CSP, you can't fetch from localhost or any arbitrary origin. The connect-src directive is the one to check.

Attempt 3: DOM.setFileInputFiles with file paths

Chrome DevTools Protocol has a method called DOM.setFileInputFiles that's specifically designed for this. You give it a nodeId and a list of files.

I connected to the CDP WebSocket, got the document, found the file input's nodeId, and called:

{
  "method": "DOM.setFileInputFiles",
  "params": {
    "nodeId": 30,
    "files": ["C:\\Users\\short\\...\\AICodingPack.zip"]
  }
}
Enter fullscreen mode Exit fullscreen mode

The response was { "result": {} } — success, no error. But when I checked input.files.length, it was 0. The file wasn't set.

I tried passing the file as an object with name, type, and data (base64-encoded):

{
  "files": [{
    "name": "AICodingPack.zip",
    "type": "application/zip",
    "data": "<base64 string>"
  }]
}
Enter fullscreen mode Exit fullscreen mode
Invalid parameters: Failed to deserialize params.files - BINDINGS: string value expected at position 31
Enter fullscreen mode Exit fullscreen mode

The files parameter expects an array of strings (file paths), not objects. The Chrome instance running on my machine (Chrome 152) apparently doesn't support the object format with base64 data. And the file path format returned success but didn't actually set the file — possibly because the CDP endpoint runs in a different context that can't access the local filesystem, or because the path format wasn't right.

Lesson: DOM.setFileInputFiles with file paths may silently fail. The object format with base64 data may not be supported on your Chrome version. Check Browser.getVersion and test both formats.

Attempt 4: document.execCommand('insertText')

Since I could get text into the page via Runtime.evaluate, I tried using document.execCommand('insertText') to "type" the file path into the input. This doesn't work for file inputs — insertText only works for text-editable elements (textareas, contenteditable divs). File inputs are not text-editable. They're binary inputs that can only be set via the file dialog or DataTransfer.

Lesson: execCommand is for text content, not file inputs. Don't waste time on this.

Attempt 5: Runtime.evaluate with atob() + DataTransfer

The approach that worked: encode the file as base64, embed it directly in a JavaScript string, decode it in the page context, create a File object, and set it on the input via DataTransfer.

// In Node.js: read file, encode as base64
const fileBuffer = fs.readFileSync('AICodingPack.zip');
const b64 = fileBuffer.toString('base64');

// Send via CDP Runtime.evaluate
const script = `
  (async () => {
    const b64 = "${b64}";
    const binary = atob(b64);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) {
      bytes[i] = binary.charCodeAt(i);
    }
    const blob = new Blob([bytes], { type: 'application/zip' });
    const file = new File([blob], 'AICodingPack.zip', { type: 'application/zip' });

    const input = document.querySelector('input[type="file"]');
    const dt = new DataTransfer();
    dt.items.add(file);
    input.files = dt.files;
    input.dispatchEvent(new Event('change', { bubbles: true }));
    input.dispatchEvent(new Event('input', { bubbles: true }));
    return 'File set: ' + file.name + ' (' + file.size + ' bytes)';
  })()
`;

// CDP call
await send('Runtime.evaluate', {
  expression: script,
  awaitPromise: true,
  returnByValue: true
});
Enter fullscreen mode Exit fullscreen mode

This worked. The file showed up on the page as "51.2 KB", the save button worked, and the product was published.

Why this works when everything else didn't:

  1. No CSP violationatob() is a built-in browser API. No network request, no external origin. The data is already in the page context.
  2. No filesystem access needed — the file content is embedded in the JavaScript string itself. The browser doesn't need to read from disk.
  3. DataTransfer is the correct API — this is the same mechanism drag-and-drop uses. It's the browser's sanctioned way to programmatically set files on an input.
  4. Runtime.evaluate bypasses MCP restrictions — the MCP server's workspace roots don't apply because the file data is in the script, not referenced by path.

The catch: file size

The base64 string is ~33% larger than the original file. For a 52KB file, that's 70KB of base64 — fine for embedding in a script. For a 100MB file, you'd have 133MB of base64 in a single JavaScript string, which would likely crash the page or hit CDP message size limits.

For large files, you'd need to chunk the base64 into multiple Runtime.evaluate calls, store it in a global variable, then assemble and decode it in a final call. I didn't need to do this for a 52KB zip, but the approach scales if you do.

The real lesson

The "correct" way to upload files programmatically — DOM.setFileInputFiles — didn't work. The "hacky" way — embedding base64 in a script — did. This is because the correct way depends on the CDP implementation supporting your use case (file paths it can access, object format it can deserialize), while the hacky way only depends on the browser being able to run JavaScript, which is the one thing you can always rely on.

When you're automating a browser, the most reliable path is usually the one that uses the fewest moving parts. Runtime.evaluate + atob() + DataTransfer has three dependencies: JavaScript execution, atob (built-in), and DataTransfer (built-in). Everything else had more dependencies and more failure modes.

Top comments (0)