Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ jobs:
- name: Test
run: npm test -- --runInBand

- name: Check multipart dependency
run: npm run check:multipart

- name: Lint
run: npm run lint

Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ Pages. After each release, wait for the Pages workflow and verify

## Change discipline

- The legacy development-only `request` dependency pins vulnerable `form-data`
2.3.x. The scoped npm override selects 2.5.6 to fix predictable multipart
boundaries (GHSA-fjxv-7rqg-78g4) and the follow-up boundary advisory
GHSA-hmw2-7cc7-3qxx. `npm run check:multipart` verifies boundary generation
and a real local text/binary upload through `request`; it runs in `verify`
and CI. Keep the override until the parent dependency is retired or accepts
a patched version itself.
- Add a regression test for every bug fix when practical.
- Keep unrelated dependency upgrades and behavior changes in separate pull requests.
- Preserve the public API unless the change is intentionally versioned as breaking.
Expand Down
78 changes: 39 additions & 39 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
"build:ssr": "npm --prefix ssr-test ci --ignore-scripts && npm --prefix ssr-test run build",
"check:ssr-hydration": "node scripts/check-ssr-hydration.js",
"check:package-export": "node scripts/check-package-export.js",
"check:multipart": "node scripts/check-multipart.js",
"build:analyze": "ANALYZE=true webpack --config webpack.config.prop.js",
"doc": "webpack --config webpack.config.doc.js",
"verify": "npm test -- --runInBand && npm run lint && npm run build && npm run check:package-export && npm run doc && npm run build:ssr && npm run check:ssr-hydration && npm pack --dry-run",
"verify": "npm test -- --runInBand && npm run lint && npm run build && npm run check:package-export && npm run doc && npm run build:ssr && npm run check:ssr-hydration && npm run check:multipart && npm pack --dry-run",
"pub": "npm publish",
"prepublishOnly": "npm run build"
},
Expand Down Expand Up @@ -91,6 +92,11 @@
"webpack-dev-server": "^3.8.2",
"wolfy87-eventemitter": "^5.2.5"
},
"overrides": {
"request": {
"form-data": "2.5.6"
}
},
"dependencies": {
"classnames": "^2.2.5"
},
Expand Down
90 changes: 90 additions & 0 deletions scripts/check-multipart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
const assert = require('assert');
const http = require('http');
const { createRequire } = require('module');
const request = require('request');
const requireFromRequest = createRequire(require.resolve('request'));
const FormData = requireFromRequest('form-data');

async function verifyMultipart() {
// The retired request package pins an older form-data minor. Check that its
// security override uses independent boundaries even with predictable PRNG.
const random = Math.random;
try {
Math.random = () => 0.5;
assert.notStrictEqual(
new FormData().getBoundary(),
new FormData().getBoundary(),
'Multipart boundaries must not depend on Math.random'
);
} finally {
Math.random = random;
}

const payload = Buffer.from([0, 1, 2, 255]);
let uploadError;
let received = false;
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
try {
const body = Buffer.concat(chunks);
const contentType = req.headers['content-type'];
assert(contentType.startsWith('multipart/form-data; boundary='));
const boundary = contentType.slice(contentType.indexOf('boundary=') + 9);
assert(body.includes(Buffer.from('--' + boundary + '\r\n')));
assert(body.includes(Buffer.from('name="caption"\r\n\r\nreact-viewer\r\n')));
assert(body.includes(Buffer.from('name="image"; filename="sample.bin"')));
assert(body.includes(Buffer.from('Content-Type: application/octet-stream')));
assert(body.includes(payload));
assert(body.toString('latin1').endsWith('--' + boundary + '--\r\n'));
assert.strictEqual(Number(req.headers['content-length']), body.length);
received = true;
res.end('ok');
} catch (error) {
uploadError = error;
res.statusCode = 400;
res.end('invalid multipart upload');
}
});
});

try {
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
await new Promise((resolve, reject) => {
request.post({
url: 'http://127.0.0.1:' + server.address().port,
timeout: 5000,
formData: {
caption: 'react-viewer',
image: {
value: payload,
options: { filename: 'sample.bin', contentType: 'application/octet-stream' },
},
},
}, (error, response, body) => {
if (error || uploadError) return reject(error || uploadError);
try {
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(body, 'ok');
assert(received, 'The local server must receive the upload');
resolve();
} catch (assertionError) {
reject(assertionError);
}
});
});
console.log('Request multipart boundaries and text/binary upload passed.');
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}
}

verifyMultipart().catch(error => {
console.error(error);
process.exitCode = 1;
});