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
4 changes: 3 additions & 1 deletion Lib/configparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -985,7 +985,9 @@ def _write_section(self, fp, section_name, section_items, delimiter, unnamed=Fal
value = self._interpolation.before_write(self, section_name, key,
value)
if value is not None or not self._allow_no_value:
value = delimiter + str(value).replace('\n', '\n\t')
# Convert all possible line-endings into '\n\t'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure the exact underlying security issue. As is, I think this will break round tripping on Windows; Text I/O used by the open expects to encode/decode newlines in particular formats on Windows by default (https://github.com/sethmlarson/cpython/blob/2a122fd420f5b425fd39848a48f5eb196cee2aa7/Lib/configparser.py#L753). See #143428 (comment) for a recent related case + more background links.

In the open calls can we use newline="" potentially?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Byte-for-byte round-tripping is already kind of broken for newlines. This is a text file; relying on newline format (or e.g. trailing whitespace) is a bad idea to begin with.

In the open calls can we use newline="" potentially?

Potentially, but it would change the behavior for all users on Windows.
And it wouldn't solve the issue, since users can pass in a file they opened themselves.

value = (delimiter + str(value).replace('\r\n', '\n')
.replace('\r', '\n').replace('\n', '\n\t'))
Comment on lines +989 to +990

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On my system, re is significantly faster than three replaces for reasonable-sized input.

Suggested change
value = (delimiter + str(value).replace('\r\n', '\n')
.replace('\r', '\n').replace('\n', '\n\t'))
value = delimiter + re.sub('\r\n?|\n', '\n\t', str(value))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, re is about four times slower than three replacess on my macOS:

p --version --version
Python 3.15.0b2 (v3.15.0b2:94a64bbc6ce, Jun  2 2026, 11:57:29) [Clang 21.0.0 (clang-2100.1.1.101)]p -m timeit -s "v = 'lorem ipsum dolor sit amet '*5" "v.replace('\r\n', '\n').replace('\r', '\n').replace('\n', '\n\t')"
2000000 loops, best of 5: 189 nsec per loopp -m timeit -s "import re; v = 'lorem ipsum dolor sit amet '*5" "re.sub('\r\n?|\n', '\n\t', v)"
500000 loops, best of 5: 773 nsec per loopp -m timeit -s "v = ('lorem ipsum dolor sit amet '*5 + '\n')*5" "v.replace('\r\n', '\n').replace('\r', '\n').replace('\n', '\n\t')"
500000 loops, best of 5: 936 nsec per loopp -m timeit -s "import re; v = ('lorem ipsum dolor sit amet '*5 + '\n')*5" "re.sub('\r\n?|\n', '\n\t', v)"
100000 loops, best of 5: 3.43 usec per loopp -m timeit -s "v = 'a\r\nb\nc\rd ' * 200" "v.replace('\r\n', '\n').replace('\r', '\n').replace('\n', '\n\t')"
20000 loops, best of 5: 10.2 usec per loopp -m timeit -s "import re; v = 'a\r\nb\nc\rd ' * 200" "re.sub('\r\n?|\n', '\n\t', v)"
5000 loops, best of 5: 43.1 usec per loopp -m timeit -s "v = 'a\r\nb\nc\rd ' * 10000" "v.replace('\r\n', '\n').replace('\r', '\n').replace('\n', '\n\t')"
500 loops, best of 5: 516 usec per loopp -m timeit -s "import re; v = 'a\r\nb\nc\rd ' * 10000" "re.sub('\r\n?|\n', '\n\t', v)"
100 loops, best of 5: 2.07 msec per loop

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, let's keep the replaces.

else:
value = ""
fp.write("{}{}\n".format(key, value))
Expand Down
11 changes: 11 additions & 0 deletions Lib/test/test_configparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,17 @@ def test_default_case_sensitivity(self):
cf.get(self.default_section, "Foo"), "Bar",
"could not locate option, expecting case-insensitive defaults")

def test_crlf_normalization(self):
cf = self.newconfig({"key1": "a\nb","key2": "a\rb", "key3": "a\r\nb", "key4": "a\r\nb"})
buf = io.StringIO()
cf.write(buf)
cf_str = buf.getvalue()
self.assertNotIn("\r", cf_str)
self.assertNotIn("\r\n", cf_str)
self.assertEqual(cf_str.count("\n"), 10)
self.assertEqual(cf_str.count("\n\t"), 4)
self.assertTrue(cf_str.endswith("\n\n"))

def test_parse_errors(self):
cf = self.newconfig()
self.parse_error(cf, configparser.ParsingError,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Normalize all line endings (CR, CRLF, and LF) to LF+TAB when writing
multi-line configparser values.
Loading