diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ded5a75 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Unified diffs preserve upstream whitespace in context lines. +patches/*.patch -whitespace diff --git a/.github/workflows/dependency-security.yml b/.github/workflows/dependency-security.yml new file mode 100644 index 0000000..5150fb7 --- /dev/null +++ b/.github/workflows/dependency-security.yml @@ -0,0 +1,18 @@ +name: Dependency security regressions +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:security diff --git a/package-lock.json b/package-lock.json index b9770a3..1831848 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "habitmates", "version": "1.0.0", + "hasInstallScript": true, "dependencies": { "@expo/vector-icons": "^15.0.2", "@react-native-community/netinfo": "12.0.1", @@ -34,6 +35,7 @@ "expo-web-browser": "~56.0.5", "lucide-react-native": "^1.17.0", "nativewind": "^4.1.23", + "patch-package": "8.0.1", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", @@ -3981,6 +3983,12 @@ "node": ">=10.0.0" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "license": "BSD-2-Clause" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -4677,6 +4685,24 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4690,6 +4716,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5595,12 +5637,12 @@ "license": "MIT" }, "node_modules/decode-uri-component": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", - "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.5.0.tgz", + "integrity": "sha512-1BiQVoK8C9gUbQU6NzAtO/tkz2qOFpEObMWpcFvhx4fYnj4Oc5yzaJN/LD36ihkVUdXyh5ZekzX+yM+ty/SrPg==", "license": "MIT", "engines": { - "node": ">=0.10" + "node": ">=14.16" } }, "node_modules/dedent": { @@ -5639,6 +5681,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7002,6 +7061,15 @@ "node": ">=8" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flow-enums-runtime": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", @@ -7059,6 +7127,29 @@ "node": ">= 0.6" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7241,6 +7332,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -7734,6 +7837,12 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8833,6 +8942,25 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -8845,6 +8973,45 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -9881,6 +10048,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -10203,6 +10379,15 @@ "node": ">= 6" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -10496,6 +10681,56 @@ "node": ">= 0.8" } }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/path-dirname": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", @@ -12197,6 +12432,23 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -12926,6 +13178,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index 07590b0..0ed9b13 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", - "test": "jest --watchAll" + "test": "jest --watchAll", + "postinstall": "patch-package --error-on-fail", + "test:security": "node --test tests/security/*.test.cjs" }, "jest": { "preset": "jest-expo" @@ -54,7 +56,8 @@ "react-native-worklets": "0.8.3", "tailwindcss": "^3.4.17", "victory-native": "^41.16.2", - "zustand": "^5.0.3" + "zustand": "^5.0.3", + "patch-package": "8.0.1" }, "devDependencies": { "@babel/core": "^7.29.0", @@ -72,7 +75,10 @@ "uuid": "11.1.1", "ws": "8.21.0", "@xmldom/xmldom@>=0.9.0 <0.9.12": "0.9.12", - "brace-expansion@>=3 <5.0.9": "5.0.9" + "brace-expansion@>=3 <5.0.9": "5.0.9", + "query-string@7.1.3": { + "decode-uri-component": "0.5.0" + } }, "private": true } diff --git a/patches/decode-uri-component+0.5.0.patch b/patches/decode-uri-component+0.5.0.patch new file mode 100644 index 0000000..21e1136 --- /dev/null +++ b/patches/decode-uri-component+0.5.0.patch @@ -0,0 +1,75 @@ +diff --git a/node_modules/decode-uri-component/index.js b/node_modules/decode-uri-component/index.js +--- a/node_modules/decode-uri-component/index.js ++++ b/node_modules/decode-uri-component/index.js +@@ -75,6 +75,14 @@ + continue; + } + ++ // Preserve the package's UTF-16 BOM fallback without reprocessing decoded text. ++ const bom = input.slice(position, position + 6); ++ if (bom === '%FE%FF' || bom === '%FF%FE') { ++ output += '\uFFFD\uFFFD'; ++ position += 6; ++ continue; ++ } ++ + const sequenceLength = utf8SequenceLength(firstByte.byte); + + // Continuation byte or invalid lead byte — emit one `%XX` literally. +@@ -110,8 +118,9 @@ + } + } + +- // Missing continuation bytes or decode failure — emit the first byte literally. +- output += input.slice(position, position + 3); ++ // Preserve truncated uppercase %C2 replacement on the original bytes only. ++ const literal = input.slice(position, position + 3); ++ output += literal === '%C2' ? '\uFFFD' : literal; + position += 3; + } + +@@ -120,41 +129,9 @@ + } + + function customDecodeURIComponent(input) { +- // Keep track of all the replacements and prefill the map with the `BOM` +- const replaceMap = { +- '%FE%FF': '\uFFFD\uFFFD', +- '%FF%FE': '\uFFFD\uFFFD', +- }; +- +- // Find percent-encoded runs separated by literal text or lone `%` characters. +- let match = multiMatcher.exec(input); +- +- while (match) { +- try { +- // Decode as big chunks as possible +- replaceMap[match[0]] = decodeURIComponent(match[0]); +- } catch { +- const result = decode(match[0]); +- +- if (result !== match[0]) { +- replaceMap[match[0]] = result; +- } +- } +- +- match = multiMatcher.exec(input); +- } +- +- // Add `%C2` at the end of the map to make sure it does not replace the combinator before everything else +- replaceMap['%C2'] = '\uFFFD'; +- +- const entries = Object.keys(replaceMap); +- +- for (const key of entries) { +- // Replace all decoded components +- input = input.replace(new RegExp(key, 'g'), replaceMap[key]); +- } +- +- return input; ++ // Decode each original run once. A replacement map rescans the entire input ++ // for every unique run, making malformed inputs with many runs quadratic. ++ return input.replace(multiMatcher, run => decode(run)); + } + + export default function decodeUriComponent(encodedURI) { diff --git a/patches/decoder-security.md b/patches/decoder-security.md new file mode 100644 index 0000000..e09fbbb --- /dev/null +++ b/patches/decoder-security.md @@ -0,0 +1,16 @@ +# Decoder fallback CPU mitigation + +`decode-uri-component` 0.5.0 fixes the old recursive malformed-UTF-8 decoder, but its outer fallback still collects one replacement-map entry per distinct encoded run and rescans the entire input for every entry. A roughly 1.18 MB query containing 64,000 distinct valid encoded runs followed by `%C3` exceeded a five-second timeout through `query-string.parse()` during verification. + +`decode-uri-component+0.5.0.patch` replaces the outer replacement-map loop with a callback over the original percent-encoded runs. Each run is decoded once. The existing linear byte scanner retains the package's uppercase UTF-16 BOM and truncated `%C2` fallback behavior, applied to original bytes so decoded percent characters are not decoded again. Callback replacements also preserve literal dollar characters instead of interpreting JavaScript replacement-string patterns. + +The patch intentionally leaves the published package version and registry integrity intact. `patch-package` must run during installation; the project's failing postinstall hook prevents silently proceeding when the version-specific patch no longer applies. Re-evaluate and remove this patch when an upstream release addresses distinct-run rescanning. + +## Verification + +- All 135 upstream test invocations from [the 0.5 decoder fix's test suite](https://github.com/SamVerschueren/decode-uri-component/blob/fa479dafeede7bedf04e5c89aa78f2a78c664005/test.js) passed against a temporary patched module. The upstream AVA assertions were executed using a local Node assert adapter; no upstream test files were added to this repository. +- `node --test tests/security/decoder-runs.test.cjs` covers 64,000 distinct runs in a bounded subprocess, one-pass decoding, dollar output, BOM/truncated-C2 behavior, valid Unicode boundaries, and malformed UTF-8. +- The distinct-run regression completed in approximately 81 ms including process startup locally, compared with a five-second timeout before the patch. Timing varies by machine; the test checks a generous deadline rather than an exact duration. +- Existing `query-string` integration tests cover the Expo Router resolution path, query parsing, round trips, malformed repeated bytes, and Unicode. + +Source: [upstream decoder implementation](https://github.com/SamVerschueren/decode-uri-component/blob/fa479dafeede7bedf04e5c89aa78f2a78c664005/index.js). The additional distinct-run problem was independently reproduced while validating the dependency security update. diff --git a/patches/image-size+1.2.1.patch b/patches/image-size+1.2.1.patch new file mode 100644 index 0000000..ddb1e51 --- /dev/null +++ b/patches/image-size+1.2.1.patch @@ -0,0 +1,41 @@ +diff --git a/node_modules/image-size/dist/types/icns.js b/node_modules/image-size/dist/types/icns.js +--- a/node_modules/image-size/dist/types/icns.js ++++ b/node_modules/image-size/dist/types/icns.js +@@ -64,10 +64,16 @@ + ic10: 1024, + }; + function readImageHeader(input, imageOffset) { ++ const fileLength = (0, utils_1.readUInt32BE)(input, FILE_LENGTH_OFFSET); ++ if (imageOffset + SIZE_HEADER > input.length || imageOffset + SIZE_HEADER > fileLength) ++ throw new TypeError('Invalid ICNS entry header'); + const imageLengthOffset = imageOffset + ENTRY_LENGTH_OFFSET; ++ const entryLength = (0, utils_1.readUInt32BE)(input, imageLengthOffset); ++ if (entryLength < SIZE_HEADER || entryLength > fileLength - imageOffset) ++ throw new TypeError('Invalid ICNS entry length'); + return [ + (0, utils_1.toUTF8String)(input, imageOffset, imageLengthOffset), +- (0, utils_1.readUInt32BE)(input, imageLengthOffset), ++ entryLength, + ]; + } + function getImageSize(type) { +diff --git a/node_modules/image-size/dist/types/utils.js b/node_modules/image-size/dist/types/utils.js +--- a/node_modules/image-size/dist/types/utils.js ++++ b/node_modules/image-size/dist/types/utils.js +@@ -49,10 +49,13 @@ + } + exports.readUInt = readUInt; + function readBox(input, offset) { +- if (input.length - offset < 4) ++ if (input.length - offset < 8) + return; +- const boxSize = (0, exports.readUInt32BE)(input, offset); +- if (input.length - offset < boxSize) ++ const declaredSize = (0, exports.readUInt32BE)(input, offset); ++ // ISO BMFF size 0 extends to EOF. Never return a non-advancing box. ++ const boxSize = declaredSize === 0 ? input.length - offset : declaredSize; ++ // Extended-size boxes (size 1) are not supported by this parser. ++ if (boxSize < 8 || input.length - offset < boxSize) + return; + return { + name: (0, exports.toUTF8String)(input, 4 + offset, 8 + offset), diff --git a/patches/image-size-security.md b/patches/image-size-security.md new file mode 100644 index 0000000..e9ff838 --- /dev/null +++ b/patches/image-size-security.md @@ -0,0 +1,12 @@ +# image-size parser mitigation + +`image-size+1.2.1.patch` locally guards Metro's existing CommonJS dependency against the non-advancing parser loops described in [GHSA-w3rx-r6r6-pgpr](https://github.com/advisories/GHSA-w3rx-r6r6-pgpr) and [GHSA-5p2g-fcmc-qvqq](https://github.com/advisories/GHSA-5p2g-fcmc-qvqq). The [researcher's report](https://joshua.hu/image-size-infinite-loop-dos-vulnerabilities) explains the zero-length ICNS entry and matched JXL partial-stream box cases. This is a local mitigation, not a published upstream release or a renamed package. + +- ICNS entries require a complete eight-byte header and a declared length of at least eight bytes within the declared file length. Each iteration therefore advances. Header-only reads of large valid icons remain supported. +- ISO BMFF boxes require a complete eight-byte header and a bounded size. A legal zero-sized box is normalized to the bytes remaining through EOF, so callers receive a positive size. Unsupported extended-size headers and undersized/oversized boxes stop parsing. + +Run `node --test tests/security/image-size.test.cjs`. Every parser invocation runs in a child process with a two-second timeout and a 64 MiB JS heap limit, so a regression cannot hang the test runner. Tests cover malformed ICNS/JXL/HEIF headers, both first and later ICNS entries, and ordinary/EOF-sized valid containers plus PNG/GIF dimensions. Tests resolve the same image-size installation that Metro loads. + +A standard install must run the root postinstall patch step. An install with `--ignore-scripts` must explicitly run `npm run postinstall` before use. Keep the security tests in validation. When a compatible upstream fix becomes available, upgrade image-size, remove this version-specific patch, and rerun the tests. + +As of 2026-09-09, npm's latest image-size is 2.0.2 and the advisories list no patched version. Dependabot/npm audit inspect package versions, not this patch; the two image-size alerts can remain open until upstream publishes a recognized fix. Do not dismiss them merely because this mitigation is applied. diff --git a/patches/query-string+7.1.3.patch b/patches/query-string+7.1.3.patch new file mode 100644 index 0000000..b892d46 --- /dev/null +++ b/patches/query-string+7.1.3.patch @@ -0,0 +1,11 @@ +diff --git a/node_modules/query-string/index.js b/node_modules/query-string/index.js +--- a/node_modules/query-string/index.js ++++ b/node_modules/query-string/index.js +@@ -1,6 +1,6 @@ + 'use strict'; + const strictUriEncode = require('strict-uri-encode'); +-const decodeComponent = require('decode-uri-component'); ++const decodeComponent = require('decode-uri-component').default; + const splitOnFirst = require('split-on-first'); + const filterObject = require('filter-obj'); + diff --git a/tests/security/decoder-runs.test.cjs b/tests/security/decoder-runs.test.cjs new file mode 100644 index 0000000..a64be4e --- /dev/null +++ b/tests/security/decoder-runs.test.cjs @@ -0,0 +1,60 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { createRequire } = require('node:module'); +const routerRequire = createRequire(require.resolve('expo-router/package.json')); +const queryRequire = createRequire(routerRequire.resolve('query-string')); +const decoderPath = process.env.DECODER_TEST_MODULE || queryRequire.resolve('decode-uri-component'); +const decode = require(decoderPath).default; + +test('distinct encoded runs plus malformed suffix finish in bounded time', () => { + const source = ` + const assert = require('node:assert/strict'); + const decode = require(process.argv[1]).default; + const count = 64000; + const input = Array.from({length: count}, (_, i) => '%41' + + Array.from(String(i), c => '%' + c.charCodeAt(0).toString(16)).join('')).join('x') + '%C3'; + const expected = Array.from({length: count}, (_, i) => 'A' + i).join('x') + '%C3'; + assert.equal(decode(input), expected); + `; + const child = spawnSync(process.execPath, ['--max-old-space-size=128', '-e', source, decoderPath], { + encoding: 'utf8', timeout: 5000, + }); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr || `Worker killed by ${child.signal}`); +}); + +test('fallback processes original runs once, including percent and dollar output', () => { + for (const [input, expected] of [ + ['%2525x%25x%C3', '%25x%x%C3'], + ['%25C2%G0', '%C2%G0'], + ['%25FE%25FF%G0', '%FE%FF%G0'], + ['%24%26%C3', '$&%C3'], + ['%84%D7%25%88%90', '%84%D7%%88%90'], + ['%C3%A5%80%C3%A5', 'å%80å'], + ]) assert.equal(decode(input), expected, input); +}); + +test('retains original BOM and uppercase truncated C2 behavior', () => { + for (const [input, expected] of [ + ['%FE%FF', '\uFFFD\uFFFD'], ['%FF%FE', '\uFFFD\uFFFD'], + ['a%FE%FFb', 'a\uFFFD\uFFFDb'], + ['%FE%FF%FE%FF', '\uFFFD\uFFFD\uFFFD\uFFFD'], + ['%EF%BB%BF', '\uFEFF'], ['%fe%ff', '%fe%ff'], + ['%C2', '\uFFFD'], ['%C2%41', '\uFFFDA'], + ['%C2%C2', '\uFFFD\uFFFD'], ['%C2%C2%B5', '\uFFFDµ'], + ['%C2%B5%C2', 'µ\uFFFD'], ['%c2', '%c2'], + ['%%C2%%', '%\uFFFD%%'], + ]) assert.equal(decode(input), expected, input); +}); + +test('retains Unicode boundaries and malformed literal sequences', () => { + for (const [input, expected] of [ + ['%F4%8F%BF%BF', '\u{10FFFF}'], ['%ED%9F%BF', '\uD7FF'], + ['%F0%9F%98%80', '😀'], ['%E4%BD%A0%E5%A5%BD', '你好'], + ['%F0%9F%98', '%F0%9F%98'], ['%F0%41%82%83', '%F0A%82%83'], + ['%C0%AF', '%C0%AF'], ['%ED%A0%80', '%ED%A0%80'], + ['%F4%90%80%80', '%F4%90%80%80'], ['%E0%80%80', '%E0%80%80'], + ['%G0', '%G0'], ['%C3%41', '%C3A'], ['%2525', '%25'], + ]) assert.equal(decode(input), expected, input); +}); diff --git a/tests/security/image-size.test.cjs b/tests/security/image-size.test.cjs new file mode 100644 index 0000000..7524b84 --- /dev/null +++ b/tests/security/image-size.test.cjs @@ -0,0 +1,88 @@ +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); +const { createRequire } = require('node:module'); +// Resolve the exact dependency Metro loads, including a future nested install. +const metroRequire = createRequire(require.resolve('metro/package.json')); +const modulePath = process.env.IMAGE_SIZE_TEST_MODULE || metroRequire.resolve('image-size'); +const worker = ` + const assert = require('node:assert/strict'); + const { input, dimensions, reject } = JSON.parse(require('node:fs').readFileSync(0, 'utf8')); + const imageSize = require(process.argv[1]); + const run = () => imageSize(Buffer.from(input, 'base64')); + if (reject) assert.throws(run); + else { const result = run(); assert.equal(result.width, dimensions[0]); assert.equal(result.height, dimensions[1]); } +`; +function check(input, expectation) { + const result = spawnSync(process.execPath, ['--max-old-space-size=64', '-e', worker, modulePath], { + input: JSON.stringify({ input: input.toString('base64'), ...expectation }), + encoding: 'utf8', timeout: 2000, + }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr || `Worker killed by ${result.signal}`); +} +function u32(value) { const b = Buffer.alloc(4); b.writeUInt32BE(value); return b; } +function box(name, data = Buffer.alloc(0), size = data.length + 8) { + return Buffer.concat([u32(size), Buffer.from(name), data]); +} +function icns(entries, declaredLength) { + // ICNS headers store the type before the length (opposite of BMFF). + const body = Buffer.concat(entries.map(entry => entry.length < 8 ? entry : + Buffer.concat([entry.subarray(4, 8), entry.subarray(0, 4), entry.subarray(8)]))); + return Buffer.concat([Buffer.from('icns'), u32(declaredLength ?? body.length + 8), body]); +} +const jxlHeader = box('JXL ', Buffer.from([13, 10, 135, 10])); +const jxlBrand = box('ftyp', Buffer.from('jxl \0\0\0\0')); +const jxlStream = Buffer.from([255, 10, 63, 62]); +const jxl = (...boxes) => Buffer.concat([jxlHeader, jxlBrand, ...boxes]); +function heif(lastSize) { + const ispe = box('ispe', Buffer.concat([u32(0), u32(32), u32(24)]), lastSize); + return Buffer.concat([box('ftyp', Buffer.from('avif\0\0\0\0')), + box('meta', Buffer.concat([u32(0), box('iprp', box('ipco', ispe))]))]); +} +for (const length of [0, 1, 4, 7, 0xffffffff]) { + test(`rejects ICNS entry length ${length} within a bounded subprocess`, () => { + check(icns([box('is32', Buffer.alloc(0), length)]), { reject: true }); + }); +} +test('rejects a zero-length second ICNS entry', () => { + check(icns([box('is32'), box('icp5', Buffer.alloc(0), 0)]), { reject: true }); +}); +test('rejects truncated ICNS entry headers', () => { + check(icns([Buffer.from('is32')]), { reject: true }); +}); +test('preserves single and multiple ICNS dimensions', () => { + check(icns([box('is32')]), { dimensions: [16, 16] }); + check(icns([box('is32'), box('icp5')]), { dimensions: [16, 16] }); +}); +test('preserves ICNS dimensions from a partial file read', () => { + check(icns([box('is32', Buffer.alloc(0), 600000)], 600008), { dimensions: [16, 16] }); +}); +test('rejects empty zero-sized JXL partial stream without looping', () => { + check(jxl(box('jxlp', u32(0), 0)), { reject: true }); +}); +for (const length of [1, 4, 7, 0xffffffff]) { + test(`rejects invalid JXL box size ${length}`, () => { + check(jxl(box('jxlp', u32(0), length)), { reject: true }); + }); +} +test('preserves complete and split JXL codestream dimensions', () => { + check(jxl(box('jxlc', jxlStream)), { dimensions: [256, 256] }); + check(jxl(box('jxlp', Buffer.concat([u32(0), jxlStream.subarray(0, 2)])), + box('jxlp', Buffer.concat([u32(0x80000001), jxlStream.subarray(2)]))), { dimensions: [256, 256] }); +}); +test('handles EOF-sized JXL partial stream with forward progress', () => { + check(jxl(box('jxlp', Buffer.concat([u32(0x80000000), jxlStream]), 0)), { dimensions: [256, 256] }); +}); +test('preserves HEIF dimensions and legal EOF-sized final box', () => { + check(heif(20), { dimensions: [32, 24] }); + check(heif(0), { dimensions: [32, 24] }); +}); +for (const length of [1, 4, 7, 0xffffffff]) { + test(`rejects invalid HEIF box size ${length}`, () => check(heif(length), { reject: true })); +} +test('preserves PNG and GIF dimension detection', () => { + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64'); + const gif = Buffer.from('R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', 'base64'); + check(png, { dimensions: [1, 1] }); check(gif, { dimensions: [1, 1] }); +}); diff --git a/tests/security/query-string.test.cjs b/tests/security/query-string.test.cjs new file mode 100644 index 0000000..a5d3c36 --- /dev/null +++ b/tests/security/query-string.test.cjs @@ -0,0 +1,54 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { createRequire } = require('node:module'); +const queryString = require('query-string'); + +// Resolve through Expo Router, including any nested query-string installation. +const routerRequire = createRequire(require.resolve('expo-router/package.json')); +const routerQueryString = routerRequire('query-string'); + +test('Expo Router resolves the patched query-string implementation', () => { + assert.equal(routerQueryString, queryString); + const decoderRequire = createRequire(require.resolve('query-string')); + const decoder = decoderRequire('decode-uri-component'); + assert.equal(typeof decoder.default, 'function'); +}); + +test('query parsing preserves deep-link values, Unicode, duplicates, and empty parameters', () => { + assert.deepEqual({ ...routerQueryString.parse('q=hello+world&name=%E4%BD%A0%E5%A5%BD&tag=a&tag=b&empty=&flag&next=%2Fhome%3Fx%3D1') }, { + q: 'hello world', name: '你好', tag: ['a', 'b'], empty: '', flag: null, next: '/home?x=1', + }); +}); + +test('query strings round-trip and bracket arrays retain their existing API', () => { + const params = { q: 'a+b & c', name: '你好', empty: '', tag: ['one', 'two'] }; + const encoded = routerQueryString.stringify(params, { arrayFormat: 'bracket' }); + assert.deepEqual({ ...routerQueryString.parse(encoded, { arrayFormat: 'bracket' }) }, params); + const parsed = routerQueryString.parseUrl('myapp://details/7?q=hello%20world#section', { parseFragmentIdentifier: true }); + assert.equal(parsed.url, 'myapp://details/7'); + assert.equal(parsed.query.q, 'hello world'); + assert.equal(parsed.fragmentIdentifier, 'section'); +}); + +test('malformed UTF-8 stays literal while valid adjacent characters decode once', () => { + for (const [input, expected] of [ + ['%C3%41', '%C3A'], ['%F0%9F%98', '%F0%9F%98'], ['%G0', '%G0'], + ['%C3%A5%80%C3%A5', 'å%80å'], ['%84%D7%25%88%90', '%84%D7%%88%90'], + ['%2525', '%25'], + ]) assert.equal(routerQueryString.parse(`q=${input}`).q, expected); +}); + +test('large malformed query input finishes instead of exhausting CPU', () => { + // A separate process makes a regression killable even if the parser blocks its event loop. + const child = spawnSync(process.execPath, ['-e', ` + const assert = require('node:assert/strict'); + const qs = require(${JSON.stringify(require.resolve('query-string'))}); + for (const byte of ['%C3', '%80', '%F0%9F']) { + const input = byte.repeat(12000); + assert.equal(qs.parse('q=' + input).q, input); + } + `], { encoding: 'utf8', timeout: 5000 }); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); +});