@pipeworx/mcp-edge-case-museum 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +17 -12
  2. package/bin/cli.js +55 -11
  3. package/lib/index.js +333 -0
  4. package/package.json +17 -16
  5. package/server.json +1 -1
  6. package/tools.json +98 -0
  7. package/src/bake.mjs +0 -112
  8. package/src/ecm-index-data.ts +0 -15
  9. package/src/exhibits/datetime/add-24-hours-vs-add-1-day-across-dst.json +0 -51
  10. package/src/exhibits/datetime/add-365-days-to-feb-29-not-next-feb-29.json +0 -49
  11. package/src/exhibits/datetime/age-calculator-feb29-birthday-crash-vs-silent.json +0 -52
  12. package/src/exhibits/datetime/century-leap-year-1900-not-leap.json +0 -68
  13. package/src/exhibits/datetime/datetime-utcnow-naive-deprecated.json +0 -39
  14. package/src/exhibits/datetime/dst-fall-back-ambiguous-fold.json +0 -51
  15. package/src/exhibits/datetime/dst-spring-forward-nonexistent-time.json +0 -57
  16. package/src/exhibits/datetime/fractional-utc-offsets-kathmandu-kolkata.json +0 -50
  17. package/src/exhibits/datetime/iso-week-year-vs-calendar-year.json +0 -48
  18. package/src/exhibits/datetime/js-date-field-overflow-rollover.json +0 -49
  19. package/src/exhibits/datetime/leap-second-rejected-vs-silently-invalid.json +0 -57
  20. package/src/exhibits/datetime/lord-howe-30-minute-dst.json +0 -47
  21. package/src/exhibits/datetime/midnight-that-doesnt-exist-sao-paulo.json +0 -53
  22. package/src/exhibits/datetime/python-naive-aware-comparison-typeerror.json +0 -46
  23. package/src/exhibits/datetime/samoa-skipped-day-2011.json +0 -50
  24. package/src/exhibits/datetime/southern-hemisphere-dst-reversed.json +0 -50
  25. package/src/exhibits/datetime/two-digit-year-century-pivot-mismatch.json +0 -49
  26. package/src/exhibits/datetime/year-2038-int32-overflow.json +0 -52
  27. package/src/exhibits/filenames/absolute-second-argument-resets-join.json +0 -62
  28. package/src/exhibits/filenames/bidi-override-extension-spoof.json +0 -58
  29. package/src/exhibits/filenames/case-insensitive-filesystem-pure-path-mismatch.json +0 -74
  30. package/src/exhibits/filenames/dotfile-has-no-extension.json +0 -61
  31. package/src/exhibits/filenames/extension-trap-double-extension.json +0 -62
  32. package/src/exhibits/filenames/filename-length-limits-bytes-not-chars.json +0 -66
  33. package/src/exhibits/filenames/leading-dash-filename-parsed-as-flag.json +0 -67
  34. package/src/exhibits/filenames/nfc-nfd-filename-lookup-mismatch.json +0 -68
  35. package/src/exhibits/filenames/ntfs-alternate-data-stream-colon.json +0 -59
  36. package/src/exhibits/filenames/ntpath-splitdrive-unc-paths.json +0 -58
  37. package/src/exhibits/filenames/nul-byte-in-filename-raises-at-open.json +0 -60
  38. package/src/exhibits/filenames/path-join-escapes-the-base-directory.json +0 -63
  39. package/src/exhibits/filenames/windows-backslash-traversal-ntpath.json +0 -62
  40. package/src/exhibits/filenames/windows-reserved-device-names.json +0 -73
  41. package/src/exhibits/filenames/windows-trailing-dot-space-stripped.json +0 -77
  42. package/src/exhibits/float-money/decimal-from-float-vs-string.json +0 -66
  43. package/src/exhibits/float-money/float-anchor-0-1-plus-0-2.json +0 -67
  44. package/src/exhibits/float-money/intl-number-format-de-de-roundtrip-misparsed.json +0 -62
  45. package/src/exhibits/float-money/iso4217-minor-units-not-always-two.json +0 -78
  46. package/src/exhibits/float-money/math-abs-negative-zero.json +0 -51
  47. package/src/exhibits/float-money/max-safe-integer-plus-two.json +0 -62
  48. package/src/exhibits/float-money/naive-loop-sum-order-dependence.json +0 -71
  49. package/src/exhibits/float-money/nan-breaks-equality-and-max-min.json +0 -70
  50. package/src/exhibits/float-money/negative-zero-balance-formatting.json +0 -74
  51. package/src/exhibits/float-money/number-epsilon-false-positive-small-magnitude.json +0 -52
  52. package/src/exhibits/float-money/parsefloat-comma-thousands-separator.json +0 -66
  53. package/src/exhibits/float-money/python-banker-rounding-vs-js-round-half-up.json +0 -65
  54. package/src/exhibits/float-money/python-round-2-675-float-imprecision.json +0 -64
  55. package/src/exhibits/float-money/splitting-100-dollars-three-ways-leaves-a-cent.json +0 -63
  56. package/src/exhibits/float-money/sum-ten-dimes-not-a-dollar.json +0 -77
  57. package/src/exhibits/float-money/tofixed-1-005-rounds-down-not-up.json +0 -63
  58. package/src/exhibits/float-money/tofixed-2-345-inconsistent-rounding.json +0 -64
  59. package/src/exhibits/integers/array-index-above-2-32-2-not-an-index.json +0 -42
  60. package/src/exhibits/integers/bigint-number-mixing-typeerror.json +0 -49
  61. package/src/exhibits/integers/bitwise-32-bit-truncation.json +0 -56
  62. package/src/exhibits/integers/bool-is-int-subclass-dict-key-collision.json +0 -58
  63. package/src/exhibits/integers/floor-div-mod-sign-python-vs-js.json +0 -48
  64. package/src/exhibits/integers/json-parse-loses-id-precision.json +0 -58
  65. package/src/exhibits/integers/max-safe-integer-plus-one-equals-plus-two.json +0 -58
  66. package/src/exhibits/integers/number-empty-and-whitespace-string-equals-zero.json +0 -55
  67. package/src/exhibits/integers/parseint-null-radix36-digit-soup.json +0 -57
  68. package/src/exhibits/integers/parseint-number-argument-scientific-notation.json +0 -48
  69. package/src/exhibits/integers/parseint-stops-at-first-invalid-char-vs-number.json +0 -58
  70. package/src/exhibits/integers/python-int-accepts-fullwidth-unicode-digits.json +0 -56
  71. package/src/exhibits/integers/python-int-accepts-underscores-and-whitespace.json +0 -48
  72. package/src/exhibits/integers/python-unbounded-int-vs-fixed-width-wrap.json +0 -49
  73. package/src/exhibits/names-addresses/address-with-no-street-number.json +0 -54
  74. package/src/exhibits/names-addresses/ascii-regex-rejects-accented-and-hyphenated-names.json +0 -55
  75. package/src/exhibits/names-addresses/house-number-suffix-collapsed-by-digit-extraction.json +0 -55
  76. package/src/exhibits/names-addresses/mononym-fails-first-last-required.json +0 -56
  77. package/src/exhibits/names-addresses/naive-lowercase-breaks-greek-final-sigma.json +0 -64
  78. package/src/exhibits/names-addresses/null-surname-coerced-to-null-value.json +0 -58
  79. package/src/exhibits/names-addresses/single-letter-surname-fails-min-length.json +0 -55
  80. package/src/exhibits/names-addresses/sort-by-surname-particle-convention-mismatch.json +0 -57
  81. package/src/exhibits/names-addresses/spanish-double-surname-treated-as-single.json +0 -55
  82. package/src/exhibits/names-addresses/split-surname-on-last-space-loses-particle.json +0 -59
  83. package/src/exhibits/names-addresses/title-case-mangles-mixed-case-and-apostrophes.json +0 -60
  84. package/src/exhibits/names-addresses/uk-canada-postal-code-assumed-numeric.json +0 -55
  85. package/src/exhibits/names-addresses/us-zip-code-leading-zero-lost-as-integer.json +0 -58
  86. package/src/exhibits/names-addresses/varchar-truncation-splits-name-mid-grapheme.json +0 -63
  87. package/src/exhibits/parsing/csv-crlf-trailing-blank-line.json +0 -87
  88. package/src/exhibits/parsing/csv-doubled-quote-escaping.json +0 -70
  89. package/src/exhibits/parsing/csv-formula-injection-excel.json +0 -71
  90. package/src/exhibits/parsing/csv-quoted-field-with-comma.json +0 -70
  91. package/src/exhibits/parsing/csv-quoted-field-with-newline.json +0 -70
  92. package/src/exhibits/parsing/csv-semicolon-delimited-european-excel.json +0 -70
  93. package/src/exhibits/parsing/csv-utf8-bom-first-header.json +0 -74
  94. package/src/exhibits/parsing/email-regex-rejects-valid-apostrophe-address.json +0 -68
  95. package/src/exhibits/parsing/json-big-integer-precision-loss.json +0 -65
  96. package/src/exhibits/parsing/json-duplicate-keys-last-wins.json +0 -64
  97. package/src/exhibits/parsing/json-lone-surrogate-roundtrip.json +0 -58
  98. package/src/exhibits/parsing/json-nan-infinity-not-valid-json.json +0 -54
  99. package/src/exhibits/parsing/url-backslash-treated-as-slash-whatwg.json +0 -60
  100. package/src/exhibits/parsing/url-urlparse-never-raises-on-garbage.json +0 -60
  101. package/src/exhibits/parsing/url-userinfo-trusted-looking-host.json +0 -60
  102. package/src/exhibits/sorting/accented-letters-sort-after-z.json +0 -63
  103. package/src/exhibits/sorting/byte-order-caps-before-lowercase.json +0 -66
  104. package/src/exhibits/sorting/case-insensitive-sort-lower-vs-casefold.json +0 -56
  105. package/src/exhibits/sorting/comparator-returns-boolean-js.json +0 -49
  106. package/src/exhibits/sorting/js-array-sort-stringifies-numbers.json +0 -68
  107. package/src/exhibits/sorting/locale-collation-de-vs-sv.json +0 -54
  108. package/src/exhibits/sorting/localecompare-numeric-option.json +0 -50
  109. package/src/exhibits/sorting/nan-in-sort.json +0 -59
  110. package/src/exhibits/sorting/natural-sort-breaks-lexicographic.json +0 -63
  111. package/src/exhibits/sorting/postgres-collation-documented.json +0 -61
  112. package/src/exhibits/sorting/python-cannot-compare-str-int.json +0 -70
  113. package/src/exhibits/sorting/reverse-sort-vs-reverse-after-sort-ties.json +0 -48
  114. package/src/exhibits/sorting/sorting-mixed-none-null.json +0 -56
  115. package/src/exhibits/sorting/typed-array-sort-is-numeric-by-default.json +0 -48
  116. package/src/exhibits/unicode/arabic-indic-digit-parses.json +0 -73
  117. package/src/exhibits/unicode/bidi-override-filename-spoof.json +0 -62
  118. package/src/exhibits/unicode/cyrillic-homoglyph-paypal-spoof.json +0 -67
  119. package/src/exhibits/unicode/fullwidth-bypasses-keyword-filter.json +0 -61
  120. package/src/exhibits/unicode/german-eszett-case-fold.json +0 -72
  121. package/src/exhibits/unicode/grapheme-vs-codepoint-length.json +0 -57
  122. package/src/exhibits/unicode/greek-final-sigma-casefold.json +0 -54
  123. package/src/exhibits/unicode/js-word-regex-ascii-only.json +0 -64
  124. package/src/exhibits/unicode/nfc-nfd-same-name-two-accounts.json +0 -70
  125. package/src/exhibits/unicode/nfkc-compatibility-folding.json +0 -63
  126. package/src/exhibits/unicode/truncation-orphans-combining-mark.json +0 -62
  127. package/src/exhibits/unicode/truncation-splits-surrogate-pair.json +0 -65
  128. package/src/exhibits/unicode/turkish-dotless-i-case-mapping.json +0 -72
  129. package/src/exhibits/unicode/zalgo-combining-mark-stacking.json +0 -57
  130. package/src/exhibits/unicode/zero-width-space-username.json +0 -62
  131. package/src/harness.mjs +0 -331
  132. package/src/index.ts +0 -686
  133. package/src/server.ts +0 -49
  134. package/src/verified.json +0 -977
  135. package/tsconfig.json +0 -18
@@ -1,62 +0,0 @@
1
- {
2
- "id": "extension-trap-double-extension",
3
- "title": "splitext(\"archive.tar.gz\") gives you \".gz\", not \".tar.gz\"",
4
- "category": "filenames",
5
- "assumption": "The 'extension' of a filename is everything after the first dot.",
6
- "input": {
7
- "display": "\"archive.tar.gz\"",
8
- "description": "Any compressed-archive filename, used to decide how to decompress an uploaded or downloaded file."
9
- },
10
- "what_goes_wrong": "Code that branches on the file's extension to pick a decompressor sees only \".gz\", runs gunzip, and is left holding a .tar file it never un-tars — or mis-identifies the file type entirely.",
11
- "why": "Both os.path.splitext and Node's path.extname define 'the extension' as the text from the LAST dot to the end of the string, a deliberate choice so a filename like \"version.1.2.3\" isn't treated as having the extension \".1.2.3\". That same rule means a genuinely double-barreled extension like \".tar.gz\" is reported as just \".gz\"; the \".tar\" part is silently folded into what both functions report as the base name.",
12
- "affects": [
13
- "python os.path.splitext",
14
- "node path.extname",
15
- "any 'branch on extension' dispatcher"
16
- ],
17
- "repros": [
18
- {
19
- "language": "python",
20
- "code": "import os.path\nprint(os.path.splitext(\"archive.tar.gz\"))\n",
21
- "expected_output": "('archive.tar', '.gz')",
22
- "verified_with": "Python 3.14.2"
23
- },
24
- {
25
- "language": "javascript",
26
- "code": "import path from 'node:path';\nconsole.log(path.extname(\"archive.tar.gz\"));\nconsole.log(path.basename(\"archive.tar.gz\", path.extname(\"archive.tar.gz\")));\n",
27
- "expected_output": ".gz\narchive.tar",
28
- "verified_with": "Node.js v22.22.2"
29
- }
30
- ],
31
- "fix": "For known compound suffixes (.tar.gz, .tar.bz2, .tar.xz, ...), check against an explicit list of multi-part suffixes before falling back to splitext/extname.",
32
- "related": [
33
- "dotfile-has-no-extension"
34
- ],
35
- "sources": [
36
- {
37
- "title": "Python docs: os.path.splitext",
38
- "url": "https://docs.python.org/3/library/os.path.html#os.path.splitext"
39
- },
40
- {
41
- "title": "Node.js docs: path.extname",
42
- "url": "https://nodejs.org/api/path.html#pathextnamepath"
43
- }
44
- ],
45
- "tags": [
46
- "filename",
47
- "path",
48
- "parsing",
49
- "upload",
50
- "validation"
51
- ],
52
- "test_cases": [
53
- {
54
- "inputs": [
55
- "archive.tar.gz"
56
- ],
57
- "relation": "returns",
58
- "expected": ".tar.gz",
59
- "note": "An extension detector used to pick a decompressor must recognize compound archive suffixes; splitext/extname alone return only '.gz'."
60
- }
61
- ]
62
- }
@@ -1,66 +0,0 @@
1
- {
2
- "id": "filename-length-limits-bytes-not-chars",
3
- "title": "A 255-byte filename limit fits only 85 Chinese characters",
4
- "category": "filenames",
5
- "assumption": "A filename length limit measured in characters means you can always fit that many characters, in any script.",
6
- "input": {
7
- "display": "85 repetitions of U+6587 (文) vs 86 repetitions",
8
- "description": "A user-supplied title or display name being used, unmodified, as a filename or as a path component in an export."
9
- },
10
- "what_goes_wrong": "A filename that looks well under a '255 character' budget by character count can already be AT or OVER the real filesystem limit, because most Linux filesystems enforce the limit in BYTES of the encoded name, and common CJK characters take 3 bytes each in UTF-8.",
11
- "why": "ext4 and most other Linux filesystems cap a single path component at 255 bytes, not 255 Unicode code points. A Latin-script name is about one byte per character, so the limit feels like a character limit, but a common CJK character encodes to 3 bytes in UTF-8, so 85 of them consume all 255 bytes and the 86th pushes the name past the limit at a character count of only 86. Windows measures differently again: an NTFS name component is limited to 255 UTF-16 code units, and the Win32 API separately caps the whole path at MAX_PATH (260 characters) unless long-path support is enabled, so a limit tuned for one platform does not carry over to the other.",
12
- "affects": [
13
- "ext4 / most linux filesystems (255 bytes per component)",
14
- "windows (255 utf-16 units per ntfs component; 260-character MAX_PATH for the whole path unless long paths are enabled)"
15
- ],
16
- "repros": [
17
- {
18
- "language": "python",
19
- "code": "s85 = \"\\u6587\" * 85\ns86 = \"\\u6587\" * 86\nprint(len(s85), len(s85.encode(\"utf-8\")))\nprint(len(s86), len(s86.encode(\"utf-8\")))\nprint(len(s86.encode(\"utf-8\")) > 255)\n",
20
- "expected_output": "85 255\n86 258\nTrue",
21
- "verified_with": "Python 3.14.2"
22
- },
23
- {
24
- "language": "javascript",
25
- "code": "const s85 = \"\\u6587\".repeat(85);\nconst s86 = \"\\u6587\".repeat(86);\nconsole.log(s85.length, Buffer.byteLength(s85, \"utf8\"));\nconsole.log(s86.length, Buffer.byteLength(s86, \"utf8\"));\nconsole.log(Buffer.byteLength(s86, \"utf8\") > 255);\n",
26
- "expected_output": "85 255\n86 258\ntrue",
27
- "verified_with": "Node.js v22.22.2"
28
- }
29
- ],
30
- "fix": "When checking a filename against a filesystem's length limit, measure the encoded byte length (UTF-8 for Linux) against that filesystem's actual limit, not the character or code-point count, and look up the right number for the right platform instead of reusing '255' everywhere.",
31
- "related": [],
32
- "sources": [
33
- {
34
- "title": "Linux kernel docs: ext4 directory entries (255-byte names)",
35
- "url": "https://www.kernel.org/doc/html/latest/filesystems/ext4/directory.html"
36
- },
37
- {
38
- "title": "Microsoft Learn: Maximum Path Length Limitation",
39
- "url": "https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation"
40
- }
41
- ],
42
- "tags": [
43
- "filename",
44
- "path",
45
- "length validation",
46
- "upload",
47
- "i18n",
48
- "validation"
49
- ],
50
- "test_cases": [
51
- {
52
- "inputs": [
53
- "文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文"
54
- ],
55
- "relation": "raises",
56
- "note": "86 CJK characters is 258 UTF-8 bytes: a validator enforcing a 255-byte filename limit should reject this even though it is only 86 characters."
57
- },
58
- {
59
- "inputs": [
60
- "文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文文"
61
- ],
62
- "relation": "no_crash",
63
- "note": "85 of them is exactly 255 bytes and should be accepted."
64
- }
65
- ]
66
- }
@@ -1,67 +0,0 @@
1
- {
2
- "id": "leading-dash-filename-parsed-as-flag",
3
- "title": "A file literally named \"-rf\" is not a filename to most command-line tools; it is two flags",
4
- "category": "filenames",
5
- "assumption": "A string you pass as a filename argument is always interpreted as a filename.",
6
- "input": {
7
- "display": "\"-rf\"",
8
- "description": "A filename generated from user content (a title, a hashtag, a ticket id) that happens to start with a hyphen, then passed as a bare positional argument to a CLI tool or argument parser."
9
- },
10
- "what_goes_wrong": "A command built as `tool <filename>` with a filename of \"-rf\" is read by the tool's argument parser as options, not a filename. At best the tool errors out; in the classic case, `rm *` in a directory containing a file named \"-rf\" hands that name to rm as an argument, and GNU rm (which accepts options anywhere on the command line) reads it as its own -r and -f flags, so rm deletes the other matched directories recursively and without prompting.",
11
- "why": "POSIX-style argument parsers decide whether a token is an option by its first character: a leading '-' (other than a lone \"-\", which conventionally means standard input) marks it as a flag, whatever the program meant that argument to be. Python's argparse applies this rule and refuses \"-rf\" as a positional, exiting with a usage error. Node's util.parseArgs, in its non-strict mode, silently parses it as the two boolean flags r and f and leaves no positional at all; in strict mode it throws on the unknown option. Both accept the same token as a filename once it follows a \"--\" end-of-options marker.",
12
- "affects": [
13
- "posix-style cli argument parsing (not limited to one language)",
14
- "python argparse (rejects a leading-dash positional)",
15
- "node util.parseArgs (non-strict: parses it as flags; strict: throws)"
16
- ],
17
- "repros": [
18
- {
19
- "language": "python",
20
- "code": "import argparse\nparser = argparse.ArgumentParser()\nparser.add_argument(\"filename\")\ntry:\n args = parser.parse_args([\"-rf\"])\n print(\"parsed filename:\", args.filename)\nexcept SystemExit as e:\n print(\"argparse rejected it as a flag, exit code\", e.code)\n",
21
- "expected_output": "argparse rejected it as a flag, exit code 2",
22
- "verified_with": "Python 3.14.2"
23
- },
24
- {
25
- "language": "javascript",
26
- "code": "import { parseArgs } from \"node:util\";\nconst loose = parseArgs({ args: [\"-rf\"], allowPositionals: true, strict: false });\nconsole.log(\"non-strict:\", JSON.stringify(loose));\ntry {\n parseArgs({ args: [\"-rf\"], allowPositionals: true });\n} catch (e) {\n console.log(\"strict:\", e.code);\n}\nconst escaped = parseArgs({ args: [\"--\", \"-rf\"], allowPositionals: true });\nconsole.log(\"after --:\", JSON.stringify(escaped.positionals));\n",
27
- "expected_output": "non-strict: {\"values\":{\"r\":true,\"f\":true},\"positionals\":[]}\nstrict: ERR_PARSE_ARGS_UNKNOWN_OPTION\nafter --: [\"-rf\"]",
28
- "verified_with": "Node.js v22.22.2"
29
- }
30
- ],
31
- "fix": "When a value could plausibly start with '-', pass it after a literal `--` end-of-options marker wherever the tool supports one, or prefix it with `./` so it no longer starts with a dash.",
32
- "related": [],
33
- "sources": [
34
- {
35
- "title": "POSIX: Utility Syntax Guidelines (Guideline 10, the -- delimiter)",
36
- "url": "https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap12.html"
37
- },
38
- {
39
- "title": "GNU C Library: Program Argument Syntax Conventions",
40
- "url": "https://www.gnu.org/software/libc/manual/html_node/Argument-Syntax.html"
41
- },
42
- {
43
- "title": "Python docs: argparse",
44
- "url": "https://docs.python.org/3/library/argparse.html"
45
- },
46
- {
47
- "title": "Node.js docs: util.parseArgs",
48
- "url": "https://nodejs.org/api/util.html#utilparseargsconfig"
49
- }
50
- ],
51
- "tags": [
52
- "filename",
53
- "security",
54
- "validation",
55
- "upload",
56
- "parsing"
57
- ],
58
- "test_cases": [
59
- {
60
- "inputs": [
61
- "-rf"
62
- ],
63
- "relation": "no_crash",
64
- "note": "A generated filename that starts with '-' needs an explicit escape before it reaches a CLI argument list, or it won't be read as a filename at all."
65
- }
66
- ]
67
- }
@@ -1,68 +0,0 @@
1
- {
2
- "id": "nfc-nfd-filename-lookup-mismatch",
3
- "title": "Two filenames that look identical can be different byte strings, and a Linux server will store both",
4
- "category": "filenames",
5
- "assumption": "A filename that looks the same on screen is the same filename on disk.",
6
- "input": {
7
- "display": "\"caf\\u00e9.txt\" encoded as NFC (\\u00e9) vs the same visible name encoded as NFD (e + \\u0301)",
8
- "description": "A filename that arrives in decomposed form (for example, a file that originated on an older Mac HFS+ volume, which stored names in a variant of NFD, or a client that decomposes text) stored verbatim on a Linux server, then looked up by the composed form a different client produced."
9
- },
10
- "what_goes_wrong": "A byte-exact filename search for the composed form finds nothing, even though a file with the 'same' visible name exists on disk, because the stored bytes are the decomposed form.",
11
- "why": "NFC and NFD are both valid Unicode encodings of identical visible text, but they are different code-point sequences and therefore different UTF-8 bytes. Linux filesystems like ext4 treat names as opaque bytes with no normalization, so the two forms are two unrelated names: a lookup by one misses the other, and both can coexist in one directory. Mac filesystems behave differently: HFS+ stored names in a variant of NFD, while APFS preserves whichever form it was given but matches names normalization-insensitively, so the same lookup that succeeds on a Mac fails once the file is on a Linux server.",
12
- "affects": [
13
- "ext4 / most linux filesystems (byte-wise names, no normalization)",
14
- "macos hfs+ (stores a variant of nfd)",
15
- "macos apfs (preserves the given form, normalization-insensitive lookup)",
16
- "any client/server pair that disagrees on normalization form"
17
- ],
18
- "repros": [
19
- {
20
- "language": "python",
21
- "code": "import unicodedata\nnfc = unicodedata.normalize(\"NFC\", \"caf\\u00e9.txt\")\nnfd = unicodedata.normalize(\"NFD\", \"caf\\u00e9.txt\")\nprint(nfc == nfd, len(nfc), len(nfd))\nprint(nfc.encode(\"utf-8\") == nfd.encode(\"utf-8\"), len(nfc.encode(\"utf-8\")), len(nfd.encode(\"utf-8\")))\n",
22
- "expected_output": "False 8 9\nFalse 9 10",
23
- "verified_with": "Python 3.14.2"
24
- },
25
- {
26
- "language": "javascript",
27
- "code": "const nfc = \"caf\\u00e9.txt\".normalize(\"NFC\");\nconst nfd = \"caf\\u00e9.txt\".normalize(\"NFD\");\nconsole.log(nfc === nfd, nfc.length, nfd.length);\nconsole.log(Buffer.from(nfc, \"utf8\").equals(Buffer.from(nfd, \"utf8\")), Buffer.byteLength(nfc), Buffer.byteLength(nfd));\n",
28
- "expected_output": "false 8 9\nfalse 9 10",
29
- "verified_with": "Node.js v22.22.2"
30
- }
31
- ],
32
- "fix": "Normalize every filename to one form (NFC is the usual choice) at the point it enters your system, before it ever reaches a save or a lookup, so client and server always agree on the byte sequence.",
33
- "related": [
34
- "nfc-nfd-same-name-two-accounts"
35
- ],
36
- "sources": [
37
- {
38
- "title": "Unicode Standard Annex #15: Unicode Normalization Forms",
39
- "url": "https://www.unicode.org/reports/tr15/"
40
- },
41
- {
42
- "title": "Apple Technical Q&A QA1173: Text Encodings in VFS (HFS+ decomposed names)",
43
- "url": "https://developer.apple.com/library/archive/qa/qa1173/_index.html"
44
- },
45
- {
46
- "title": "Apple File System Guide: FAQ (APFS normalization and case sensitivity)",
47
- "url": "https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html"
48
- }
49
- ],
50
- "tags": [
51
- "filename",
52
- "path",
53
- "normalization",
54
- "upload",
55
- "i18n",
56
- "unicode"
57
- ],
58
- "test_cases": [
59
- {
60
- "inputs": [
61
- "café.txt",
62
- "café.txt"
63
- ],
64
- "relation": "equal",
65
- "note": "A filename-key function should normalize (usually to NFC) so the composed and decomposed spellings map to the same key."
66
- }
67
- ]
68
- }
@@ -1,59 +0,0 @@
1
- {
2
- "id": "ntfs-alternate-data-stream-colon",
3
- "title": "\"file.txt:hidden\" isn't a second file on NTFS — it's extra data bolted onto file.txt that `dir` will never show you",
4
- "category": "filenames",
5
- "assumption": "A colon in a filename is just another character, and a name containing one refers to a distinct file.",
6
- "input": {
7
- "display": "\"file.txt:hidden\"",
8
- "description": "A filename accepted from user input (or produced by careless string concatenation) that is later written to an actual NTFS volume."
9
- },
10
- "what_goes_wrong": "On NTFS, writing to \"file.txt:hidden\" doesn't create a new file — it creates (or overwrites) an alternate data stream attached to the EXISTING file.txt, invisible to a normal directory listing and not counted in the visible file's reported size, so arbitrary extra data can be smuggled in behind an ordinary-looking file.",
11
- "why": "NTFS filenames support multiple named 'data streams' per file entry, addressed as \"name:stream\"; the unnamed stream is the file's normal content, and \"file.txt:hidden\" addresses an additional stream on the SAME directory entry as file.txt, not a sibling file. Pure path libraries have no awareness of this NTFS-specific syntax at all — they just see a colon character — so splitext and basename don't special-case it; splitext even folds the whole \":hidden\" suffix into what it reports as the file's 'extension'.",
12
- "affects": [
13
- "windows ntfs (alternate data streams)",
14
- "python ntpath (no ads awareness — colon is just a character)",
15
- "node path.win32 (no ads awareness)"
16
- ],
17
- "repros": [
18
- {
19
- "language": "python",
20
- "code": "import ntpath\nname = \"file.txt:hidden\"\nprint(\"splitdrive:\", ntpath.splitdrive(name))\nprint(\"splitext:\", ntpath.splitext(name))\nprint(\"basename:\", ntpath.basename(name))\n",
21
- "expected_output": "splitdrive: ('', 'file.txt:hidden')\nsplitext: ('file', '.txt:hidden')\nbasename: file.txt:hidden",
22
- "verified_with": "Python 3.14.2"
23
- },
24
- {
25
- "language": "javascript",
26
- "code": "import path from 'node:path';\nconst name = \"file.txt:hidden\";\nconst parsed = path.win32.parse(name);\nconsole.log(\"ext:\", parsed.ext, \"name:\", parsed.name, \"base:\", parsed.base);\n",
27
- "expected_output": "ext: .txt:hidden name: file base: file.txt:hidden",
28
- "verified_with": "Node.js v22.22.2"
29
- }
30
- ],
31
- "fix": "Reject colons in filenames outright before they reach a Windows filesystem unless you specifically intend to address a named stream, and never rely on directory listings or visible file size alone to audit what's actually stored in a file.",
32
- "related": [],
33
- "sources": [
34
- {
35
- "title": "Microsoft Sysinternals: Streams (NTFS Alternate Data Streams)",
36
- "url": "https://learn.microsoft.com/en-us/sysinternals/downloads/streams"
37
- },
38
- {
39
- "title": "Microsoft Learn: Naming Files, Paths, and Namespaces",
40
- "url": "https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"
41
- }
42
- ],
43
- "tags": [
44
- "filename",
45
- "path",
46
- "security",
47
- "validation",
48
- "upload"
49
- ],
50
- "test_cases": [
51
- {
52
- "inputs": [
53
- "file.txt:hidden"
54
- ],
55
- "relation": "no_crash",
56
- "note": "Your filename validator should reject or specifically handle a colon before it reaches a Windows filesystem call."
57
- }
58
- ]
59
- }
@@ -1,58 +0,0 @@
1
- {
2
- "id": "ntpath-splitdrive-unc-paths",
3
- "title": "A UNC path's \"drive\" isn't a drive letter at all — it's the whole \\\\server\\share",
4
- "category": "filenames",
5
- "assumption": "splitdrive on a Windows path always returns either nothing or a single letter plus a colon.",
6
- "input": {
7
- "display": "\"\\\\\\\\server\\\\share\\\\file.txt\" (UNC path) vs \"C:\\\\Users\\\\me\\\\file.txt\"",
8
- "description": "A network path (a mapped file server, a backup target) handled by code that was only ever tested against local drive-letter paths."
9
- },
10
- "what_goes_wrong": "Code that assumes the 'drive' component is either empty or exactly two characters (\"C:\") mishandles a UNC path, truncating or misinterpreting the server/share portion that ntpath actually bundles into the drive.",
11
- "why": "ntpath.splitdrive special-cases UNC syntax: for a path starting with two separators followed by a server and share name, it returns the ENTIRE \\\\server\\share prefix as the 'drive', not just a letter and colon, because that whole prefix has to stay together to resolve the rest of the path. Node's path.win32.parse does the analogous thing, putting the full \\\\server\\share\\ into `root`. A function written only against \"C:\" paths, and assuming a fixed two-character drive length, breaks silently on the far longer UNC form.",
12
- "affects": [
13
- "python ntpath.splitdrive",
14
- "node path.win32 (root / parse)",
15
- "windows unc paths"
16
- ],
17
- "repros": [
18
- {
19
- "language": "python",
20
- "code": "import ntpath\nprint(ntpath.splitdrive(\"C:\\\\Users\\\\me\\\\file.txt\"))\nprint(ntpath.splitdrive(\"\\\\\\\\server\\\\share\\\\file.txt\"))\n",
21
- "expected_output": "('C:', '\\\\Users\\\\me\\\\file.txt')\n('\\\\\\\\server\\\\share', '\\\\file.txt')",
22
- "verified_with": "Python 3.14.2"
23
- },
24
- {
25
- "language": "javascript",
26
- "code": "import path from 'node:path';\nconsole.log(path.win32.parse(\"C:\\\\Users\\\\me\\\\file.txt\").root);\nconsole.log(path.win32.parse(\"\\\\\\\\server\\\\share\\\\file.txt\").root);\n",
27
- "expected_output": "C:\\\n\\\\server\\share\\",
28
- "verified_with": "Node.js v22.22.2"
29
- }
30
- ],
31
- "fix": "Use the library's own splitdrive/parse output rather than assuming a fixed-width drive prefix, and test path-handling code against UNC paths explicitly, not just local drive letters.",
32
- "related": [],
33
- "sources": [
34
- {
35
- "title": "Microsoft Learn: Naming Files, Paths, and Namespaces (UNC paths)",
36
- "url": "https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file"
37
- },
38
- {
39
- "title": "Python docs: os.path (ntpath)",
40
- "url": "https://docs.python.org/3/library/os.path.html"
41
- }
42
- ],
43
- "tags": [
44
- "filename",
45
- "path",
46
- "parsing",
47
- "validation"
48
- ],
49
- "test_cases": [
50
- {
51
- "inputs": [
52
- "\\\\server\\share\\file.txt"
53
- ],
54
- "relation": "no_crash",
55
- "note": "If your code assumes a drive component is always empty or exactly 'C:', test it against a UNC path like this one."
56
- }
57
- ]
58
- }
@@ -1,60 +0,0 @@
1
- {
2
- "id": "nul-byte-in-filename-raises-at-open",
3
- "title": "A filename with an embedded NUL byte sails through every path function, then blows up the moment you open it",
4
- "category": "filenames",
5
- "assumption": "If os.path.join / path.join didn't raise an error, the filename is safe to use.",
6
- "input": {
7
- "display": "\"a\\u0000b\" (the letter a, a NUL byte, the letter b)",
8
- "description": "A filename decoded from untrusted bytes (a multipart upload, a binary protocol field) that was never validated as a 'reasonable' string before being treated as a filename."
9
- },
10
- "what_goes_wrong": "String-only path functions accept the NUL byte without complaint and happily build a joined path containing it; the error only appears later, at the actual filesystem call, in a different part of the codebase than where the bad input was first accepted.",
11
- "why": "os.path.join and path.join are pure string operations — they concatenate and, in Node's case, normalize separators, but neither inspects the byte content for anything the OS might reject. The OS filesystem call layer represents paths as NUL-terminated C strings, so an embedded NUL byte would truncate or corrupt the path at the syscall level; both Python's open() and Node's fs functions guard against this themselves and raise before making the call, rather than letting the OS ever see a truncated string.",
12
- "affects": [
13
- "python os.path.join (no validation)",
14
- "node path.join (no validation)",
15
- "python open() (raises ValueError)",
16
- "node fs (raises ERR_INVALID_ARG_VALUE)"
17
- ],
18
- "repros": [
19
- {
20
- "language": "python",
21
- "code": "import os.path\njoined = os.path.join(\"a\\x00b\", \"c\")\nprint(\"join accepted it:\", repr(joined))\ntry:\n open(\"a\\x00b\")\nexcept ValueError as e:\n print(\"open() raised:\", e)\n",
22
- "expected_output": "join accepted it: 'a\\x00b/c'\nopen() raised: embedded null byte",
23
- "verified_with": "Python 3.14.2"
24
- },
25
- {
26
- "language": "javascript",
27
- "code": "import path from 'node:path';\nimport fs from 'node:fs';\nconst joined = path.join(\"a\\0b\", \"c\");\nconsole.log(\"join accepted it:\", JSON.stringify(joined));\ntry {\n fs.readFileSync(\"a\\0b\");\n} catch (e) {\n console.log(\"fs raised:\", e.code);\n}\n",
28
- "expected_output": "join accepted it: \"a\\u0000b/c\"\nfs raised: ERR_INVALID_ARG_VALUE",
29
- "verified_with": "Node.js v22.22.2"
30
- }
31
- ],
32
- "fix": "Validate filenames for control characters (including NUL) at the point you accept them, not at the point you happen to open them — the path-building layer in between will not catch it for you.",
33
- "related": [],
34
- "sources": [
35
- {
36
- "title": "Python docs: File Names, Command-Line Arguments, and Environment Variables",
37
- "url": "https://docs.python.org/3/library/os.html#file-names-command-line-arguments-and-environment-variables"
38
- },
39
- {
40
- "title": "Node.js docs: fs — file paths",
41
- "url": "https://nodejs.org/api/fs.html#file-paths"
42
- }
43
- ],
44
- "tags": [
45
- "filename",
46
- "path",
47
- "validation",
48
- "upload",
49
- "security"
50
- ],
51
- "test_cases": [
52
- {
53
- "inputs": [
54
- "a\u0000b"
55
- ],
56
- "relation": "raises",
57
- "note": "Your file-open code should raise here, not silently truncate the filename at the NUL byte."
58
- }
59
- ]
60
- }
@@ -1,63 +0,0 @@
1
- {
2
- "id": "path-join-escapes-the-base-directory",
3
- "title": "Joining a trusted folder with a filename doesn't confine the result to that folder",
4
- "category": "filenames",
5
- "assumption": "os.path.join(trusted_base, user_supplied_name) always produces a path inside trusted_base.",
6
- "input": {
7
- "display": "\"../../etc/passwd\" joined onto \"/srv/uploads\"",
8
- "description": "An upload handler or export feature that builds a save path from a base directory plus a user-controlled filename."
9
- },
10
- "what_goes_wrong": "The joined path points outside the upload directory entirely, letting a crafted filename read or overwrite files the base directory was supposed to contain.",
11
- "why": "os.path.join concatenates path segments; it never checks whether a later argument walks back out of the first with `..` components, it just glues the strings together, and the escape only becomes visible once the OS resolves the dots at open time. Node's path.join goes further and actively normalizes `.` and `..` within the joined string itself, so the same escape is visible immediately in the returned string rather than only at the filesystem call. Either way, neither function treats its first argument as a security boundary; it is nothing more than the first string in a concatenation.",
12
- "affects": [
13
- "python os.path.join",
14
- "node path.join",
15
- "any 'join the base dir with the filename' upload handler"
16
- ],
17
- "repros": [
18
- {
19
- "language": "python",
20
- "code": "import os.path\njoined = os.path.join(\"/srv/uploads\", \"../../etc/passwd\")\nprint(joined)\nprint(joined.startswith(\"/srv/uploads\"))\n",
21
- "expected_output": "/srv/uploads/../../etc/passwd\nTrue",
22
- "verified_with": "Python 3.14.2"
23
- },
24
- {
25
- "language": "javascript",
26
- "code": "import path from 'node:path';\nconst joined = path.join(\"/srv/uploads\", \"../../etc/passwd\");\nconsole.log(joined);\nconsole.log(joined.startsWith(\"/srv/uploads\"));\n",
27
- "expected_output": "/etc/passwd\nfalse",
28
- "verified_with": "Node.js v22.22.2"
29
- }
30
- ],
31
- "fix": "After joining, resolve the result to an absolute, normalized path (os.path.realpath / path.resolve) and verify it still starts with the resolved base directory before touching the filesystem; reject anything that doesn't.",
32
- "related": [
33
- "absolute-second-argument-resets-join",
34
- "windows-backslash-traversal-ntpath"
35
- ],
36
- "sources": [
37
- {
38
- "title": "Python docs: os.path.join",
39
- "url": "https://docs.python.org/3/library/os.path.html#os.path.join"
40
- },
41
- {
42
- "title": "Node.js docs: path.join",
43
- "url": "https://nodejs.org/api/path.html#pathjoinpaths"
44
- }
45
- ],
46
- "tags": [
47
- "filename",
48
- "path",
49
- "upload",
50
- "security",
51
- "validation"
52
- ],
53
- "test_cases": [
54
- {
55
- "inputs": [
56
- "/srv/uploads",
57
- "../../etc/passwd"
58
- ],
59
- "relation": "no_crash",
60
- "note": "Your path-builder should either refuse this filename or produce a path still inside the base directory — check which, don't assume."
61
- }
62
- ]
63
- }
@@ -1,62 +0,0 @@
1
- {
2
- "id": "windows-backslash-traversal-ntpath",
3
- "title": "A traversal filter that only blocks \"../\" misses \"..\\\\\" — and you can test the miss without touching Windows",
4
- "category": "filenames",
5
- "assumption": "Checking a filename for the substring \"../\" catches every directory-traversal attempt.",
6
- "input": {
7
- "display": "\"..\\\\..\\\\windows\\\\system32\" (backslash-separated traversal)",
8
- "description": "A filename accepted by a web form where the server later resolves the path on Windows, or a ZIP/tar entry name that uses backslashes."
9
- },
10
- "what_goes_wrong": "A blocklist written against the forward-slash form lets the backslash form straight through, and ntpath/path.win32 — which treat backslash as the canonical separator — walk back out of the intended directory exactly as the forward-slash form would.",
11
- "why": "ntpath and path.win32 are pure-path implementations of Windows' own path rules, where backslash is the primary separator even though forward slash also works. A traversal check written only against the string \"../\" is really checking for a POSIX idiom and has nothing to say about \"..\\\\\", so on any code path that eventually resolves the string as a Windows path, the backslash form reaches the same `..` component and climbs out of the base directory just the same.",
12
- "affects": [
13
- "windows path resolution (backslash is the canonical separator)",
14
- "ntpath (runs on any host, not just windows)",
15
- "path.win32 (runs on any host, not just windows)"
16
- ],
17
- "repros": [
18
- {
19
- "language": "python",
20
- "code": "import ntpath\nraw = \"..\\\\..\\\\windows\\\\system32\"\nprint(\"contains ../ :\", \"../\" in raw)\njoined = ntpath.join(\"C:\\\\srv\\\\uploads\", raw)\nprint(\"joined:\", joined)\nprint(\"normalized:\", ntpath.normpath(joined))\n",
21
- "expected_output": "contains ../ : False\njoined: C:\\srv\\uploads\\..\\..\\windows\\system32\nnormalized: C:\\windows\\system32",
22
- "verified_with": "Python 3.14.2"
23
- },
24
- {
25
- "language": "javascript",
26
- "code": "import path from 'node:path';\nconst raw = \"..\\\\..\\\\windows\\\\system32\";\nconsole.log(\"contains ../ :\", raw.includes(\"../\"));\nconst joined = path.win32.join(\"C:\\\\srv\\\\uploads\", raw);\nconsole.log(\"joined:\", joined);\n",
27
- "expected_output": "contains ../ : false\njoined: C:\\windows\\system32",
28
- "verified_with": "Node.js v22.22.2"
29
- }
30
- ],
31
- "fix": "Normalize with the platform-appropriate path module and compare the RESULT against the resolved base directory; never pattern-match the raw string for one separator style.",
32
- "related": [
33
- "path-join-escapes-the-base-directory"
34
- ],
35
- "sources": [
36
- {
37
- "title": "Python docs: os.path (ntpath on Windows)",
38
- "url": "https://docs.python.org/3/library/os.path.html"
39
- },
40
- {
41
- "title": "Node.js docs: path.win32",
42
- "url": "https://nodejs.org/api/path.html#pathwin32"
43
- }
44
- ],
45
- "tags": [
46
- "filename",
47
- "path",
48
- "security",
49
- "validation",
50
- "upload"
51
- ],
52
- "test_cases": [
53
- {
54
- "inputs": [
55
- "C:\\srv\\uploads",
56
- "..\\..\\windows\\system32"
57
- ],
58
- "relation": "no_crash",
59
- "note": "A traversal filter tested only against forward slashes should also be tested against this backslash form before you trust it."
60
- }
61
- ]
62
- }