diff --git a/.changes/next-release/bugfix-s3streamingoutput-59585.json b/.changes/next-release/bugfix-s3streamingoutput-59585.json new file mode 100644 index 000000000000..e34eeb66de1b --- /dev/null +++ b/.changes/next-release/bugfix-s3streamingoutput-59585.json @@ -0,0 +1,5 @@ +{ + "type": "bugfix", + "category": "s3, streaming output", + "description": "Output files created by S3 Select and streaming output commands are now created with owner-only permissions (0600). Existing files are also tightened to 0600 when overwritten." +} diff --git a/awscli/customizations/s3events.py b/awscli/customizations/s3events.py index 2cd57fef7975..50818865e31b 100644 --- a/awscli/customizations/s3events.py +++ b/awscli/customizations/s3events.py @@ -13,6 +13,7 @@ """Add S3 specific event streaming output arg.""" import os +import stat from awscli.arguments import CustomArgument @@ -124,7 +125,9 @@ def save_file(self, parsed, **kwargs): fd = os.open( self._output_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 ) - os.chmod(self._output_file, 0o600) + if stat.S_ISREG(os.fstat(fd).st_mode): + # Only chmod regular files; skip special files like /dev/null + os.chmod(self._output_file, 0o600) with os.fdopen(fd, 'wb') as fp: for event in event_stream: if 'Records' in event: diff --git a/awscli/customizations/streamingoutputarg.py b/awscli/customizations/streamingoutputarg.py index 596aab8a3342..fe23bd7a5195 100644 --- a/awscli/customizations/streamingoutputarg.py +++ b/awscli/customizations/streamingoutputarg.py @@ -11,6 +11,7 @@ # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. import os +import stat from botocore.model import Shape @@ -109,7 +110,9 @@ def save_file(self, parsed, **kwargs): fd = os.open( self._output_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 ) - os.chmod(self._output_file, 0o600) + if stat.S_ISREG(os.fstat(fd).st_mode): + # Only chmod regular files; skip special files like /dev/null + os.chmod(self._output_file, 0o600) with os.fdopen(fd, 'wb') as fp: data = body.read(buffer_size) while data: diff --git a/tests/functional/s3api/test_select_object_content.py b/tests/functional/s3api/test_select_object_content.py index e9078aa4b677..b5b7c33b48a0 100644 --- a/tests/functional/s3api/test_select_object_content.py +++ b/tests/functional/s3api/test_select_object_content.py @@ -108,6 +108,27 @@ def test_output_file_permissions(self): # Mask file type bits to isolate permission bits (rwxrwxrwx) self.assertEqual(os.stat(filename).st_mode & 0o777, 0o600) + @skip_if_windows('chmod is not supported on Windows') + def test_output_does_not_chmod_non_regular_files(self): + cmdline = self.prefix + [ + '--bucket', + 'mybucket', + '--key', + 'mykey', + '--expression', + 'SELECT * FROM S3Object', + '--expression-type', + 'SQL', + '--input-serialization', + '{"CSV": {}}', + '--output-serialization', + '{"CSV": {}}', + '/dev/null', + ] + original_mode = os.stat('/dev/null').st_mode & 0o777 + self.assert_params_for_cmd(cmdline, ignore_params=True) + self.assertEqual(os.stat('/dev/null').st_mode & 0o777, original_mode) + def test_errors_are_propagated(self): self.http_response.status_code = 400 self.parsed_response = { diff --git a/tests/functional/test_streaming_output.py b/tests/functional/test_streaming_output.py index 3255e7b74e9a..7fbb9980a6a0 100644 --- a/tests/functional/test_streaming_output.py +++ b/tests/functional/test_streaming_output.py @@ -62,3 +62,17 @@ def test_streaming_output_file_permissions(self): self.assert_params_for_cmd(cmdline % outpath, ignore_params=True) # Mask file type bits to isolate permission bits (rwxrwxrwx) self.assertEqual(os.stat(outpath).st_mode & 0o777, 0o600) + + @skip_if_windows('chmod is not supported on Windows') + def test_streaming_output_does_not_chmod_non_regular_files(self): + cmdline = ( + 'kinesis-video-media get-media --stream-name test-stream ' + '--start-selector StartSelectorType=EARLIEST %s' + ) + self.parsed_response = { + 'ContentType': 'video/webm', + 'Payload': BytesIO(b'testbody'), + } + original_mode = os.stat('/dev/null').st_mode & 0o777 + self.assert_params_for_cmd(cmdline % '/dev/null', ignore_params=True) + self.assertEqual(os.stat('/dev/null').st_mode & 0o777, original_mode)