1
0
mirror of https://github.com/lancedikson/bowser synced 2026-09-22 20:14:20 +00:00

fix: resolve open GitHub security findings

Code scanning (js/polynomial-redos, alerts #16 and #17)
-------------------------------------------------------
bowser applies these regexps to attacker-controlled User-Agent strings, and
four of them ran in quadratic time:

  * The "Something else" fallback used /^(.*)\/(.*) / and
    /^(.*)\/(.*)[ \t]\((.*)/. Two unbounded `.*` before a required literal
    make the split ambiguous, so a UA of "/a" repeated backtracks O(n^2).
    Greedy `(.*)` always picks the last `/` that still has the delimiter
    after it, so the second group can never span a `/` -- narrowing it to
    `[^/]*` is exactly equivalent and removes the ambiguity. Verified
    identical on 1,000,000 fuzzed inputs and on the full acceptance corpus.

  * The Linespider and SlackBot version regexps used `(?:-[-\w]+)?` before a
    required `[\s/]`. Since `[-\w]` and `[\s/]` are disjoint the backtracking
    is pure waste, but the engine still walks it once per start position, so
    "linespider-" repeated is quadratic. Bounding the run to `{1,64}` makes
    it linear; no real bot-name suffix approaches 64 characters.

A sweep of all 253 regexp literals in src/ (fuzzed for superlinear scaling,
with the four pre-fix patterns used to confirm the detector works) reports no
remaining superlinear regexps. test/unit/redos.js locks this in.

Actions (actions/missing-workflow-permissions, alerts #5 and #15)
-----------------------------------------------------------------
merge-to-master.yml and draft-or-update-next-release.yml had no `permissions`
block and so inherited the default token. Both now declare least privilege,
matching publish.yml and pull-request.yml.

Dependabot (22 open alerts, all development scope)
--------------------------------------------------
bowser ships no runtime dependencies, so none of these reached consumers, but
they were live in CI. `pnpm audit` goes from 29 advisories to 0:

  * jsdoc 3 -> 4 (with docdash 1 -> 2) drops taffydb, which has no patched
    release, and picks up current markdown-it/linkify-it.
  * coveralls -> coveralls-next 6 drops `request`, which is deprecated with no
    patched release, along with form-data, qs 6.5.x, uuid 3 and tough-cookie 2.
    Same `coveralls` bin and same stdin contract; lcov conversion verified.
  * gh-pages 3 -> 6 clears the critical prototype pollution advisory.
  * pnpm overrides pin the remaining transitive-only advisories to the lowest
    patched release on each existing major.

Verified: pnpm audit clean, lint clean, 346 tests pass (the acceptance corpus
runs against both src/ and the built es5.js), build, package smoke test, and
doc generation.
This commit is contained in:
naorpeled
2026-08-30 20:37:19 +03:00
parent a88622557d
commit 982a91d5e8
7 changed files with 322 additions and 563 deletions

View File

@@ -7,6 +7,10 @@ on:
- master
workflow_dispatch:
permissions:
contents: write
pull-requests: read
jobs:
prepare-deployment:
name: 📝 Draft or update next release

View File

@@ -4,6 +4,9 @@ on:
push:
branches: [master]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -89,20 +89,20 @@
"babel-plugin-add-module-exports": "^1.0.4",
"babel-plugin-istanbul": "^8.0.0",
"core-js": "^3.49.0",
"coveralls": "^3.0.6",
"docdash": "^1.1.1",
"coveralls-next": "^6.0.2",
"docdash": "^2.0.2",
"eslint": "^10.8.0",
"eslint-config-airbnb-extended": "^3.1.0",
"eslint-plugin-ava": "^17.0.1",
"eslint-plugin-import": "^2.32.0",
"gh-pages": "^3.0.0",
"jsdoc": "^3.6.3",
"gh-pages": "^6.3.0",
"jsdoc": "^4.0.5",
"nyc": "^18.0.0",
"publint": "^0.3.22",
"regenerator-runtime": "^0.14.1",
"sinon": "^22.0.0",
"terser": "^5.49.0",
"testem": "^3.0.0",
"testem": "^3.20.2",
"tsdown": "^0.22.14",
"typescript": "^5.9.3",
"yamljs": "^0.3.0"

804
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,18 @@
allowBuilds:
core-js: false
unrs-resolver: false
# Dev-only security pins; bowser itself ships no runtime dependencies. Each is
# the lowest patched release on the existing major, so old dependents resolve.
overrides:
brace-expansion@1: ^1.1.18 # GHSA-3jxr-9vmj-r5cp
brace-expansion@2: ^2.1.4 # GHSA-3jxr-9vmj-r5cp
brace-expansion@5: ^5.0.9 # GHSA-3jxr-9vmj-r5cp
js-yaml@3: ^3.15.2 # GHSA-52cp-r559-cp3m, GHSA-h67p-54hq-rp68
js-yaml@4: ^4.3.2 # GHSA-52cp-r559-cp3m, GHSA-5p4m-2wfm-xmqj
ws@8: ^8.21.3 # GHSA-96hv-2xvq-fx4p
socket.io-parser: ^4.2.7 # GHSA-2m8v-j782-fhvr
linkify-it: ^5.0.2 # GHSA-v245-v573-v5vm
markdown-it: ^14.3.1 # GHSA-6v5v-wf23-fmfq
got@9: ^11.8.6 # GHSA-pfrx-2q88-qq97
body-parser@2: ^2.3.0

View File

@@ -273,7 +273,8 @@ const browsersList = [
const browser = {
name: 'Linespider',
};
const version = Utils.getFirstMatch(/(?:linespider)(?:-[-\w]+)?[\s/](\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua);
// `{1,64}` bounds the backtracking: unbounded, a repeated-token UA is quadratic.
const version = Utils.getFirstMatch(/(?:linespider)(?:-[-\w]{1,64})?[\s/](\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua);
if (version) {
browser.version = version;
@@ -385,7 +386,8 @@ const browsersList = [
const browser = {
name: 'SlackBot',
};
const version = Utils.getFirstMatch(/(?:slackbot|slack-imgproxy)(?:-[-\w]+)?[\s/](\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua);
// `{1,64}` bounds the backtracking: unbounded, a repeated-token UA is quadratic.
const version = Utils.getFirstMatch(/(?:slackbot|slack-imgproxy)(?:-[-\w]{1,64})?[\s/](\d+(\.\d+)+)/i, ua) || Utils.getFirstMatch(commonVersionIdentifier, ua);
if (version) {
browser.version = version;
@@ -1232,8 +1234,8 @@ const browsersList = [
* in order to decide what regexp exactly we want to apply
* (as there is a specific decision based on that conclusion)
*/
const regexpWithoutDeviceSpec = /^(.*)\/(.*) /;
const regexpWithDeviceSpec = /^(.*)\/(.*)[ \t]\((.*)/;
const regexpWithoutDeviceSpec = /^(.*)\/([^/]*) /;
const regexpWithDeviceSpec = /^(.*)\/([^/]*)[ \t]\((.*)/;
const hasDeviceSpec = ua.search('\\(') !== -1;
const regexp = hasDeviceSpec ? regexpWithDeviceSpec : regexpWithoutDeviceSpec;
return {

39
test/unit/redos.js Normal file
View File

@@ -0,0 +1,39 @@
import test from 'ava';
import Bowser from '../../src/bowser';
/*
* Regression tests for CodeQL js/polynomial-redos. Each input used to drive a
* quadratic backtracking path; fixed, they parse in single-digit milliseconds,
* so this budget has ~2 orders of magnitude of headroom and is not machine-sensitive.
*/
const TIME_BUDGET_MS = 500;
function timeParse(ua) {
const startedAt = process.hrtime.bigint();
Bowser.parse(ua);
return Number(process.hrtime.bigint() - startedAt) / 1e6;
}
test('parses a Linespider UA made of repeated name tokens in linear time', (t) => {
const ua = 'linespider-'.repeat(20000);
const elapsed = timeParse(ua);
t.true(elapsed < TIME_BUDGET_MS, `took ${elapsed.toFixed(0)}ms, budget ${TIME_BUDGET_MS}ms`);
});
test('parses a SlackBot UA made of repeated name tokens in linear time', (t) => {
const ua = 'slackbot-'.repeat(20000);
const elapsed = timeParse(ua);
t.true(elapsed < TIME_BUDGET_MS, `took ${elapsed.toFixed(0)}ms, budget ${TIME_BUDGET_MS}ms`);
});
test('parses an unrecognised slash-heavy UA without a device spec in linear time', (t) => {
const ua = `/${'/a'.repeat(60000)}`;
const elapsed = timeParse(ua);
t.true(elapsed < TIME_BUDGET_MS, `took ${elapsed.toFixed(0)}ms, budget ${TIME_BUDGET_MS}ms`);
});
test('parses an unrecognised slash-heavy UA with a device spec in linear time', (t) => {
const ua = `/${'/a'.repeat(60000)}(`;
const elapsed = timeParse(ua);
t.true(elapsed < TIME_BUDGET_MS, `took ${elapsed.toFixed(0)}ms, budget ${TIME_BUDGET_MS}ms`);
});