@@ -24,80 +24,109 @@ module.exports = function (self) {
2424 }
2525 }
2626
27- // Find any file field submissions and insert the files as attachments
28- for ( const [ field , value ] of Object . entries ( input ) ) {
29- if ( value === 'files-pending' ) {
30- try {
31- input [ field ] = await self . insertFieldFiles ( req , field , req . files ) ;
32- } catch ( error ) {
33- self . apos . util . error ( error ) ;
34- formErrors . push ( {
35- field,
36- error : 'invalid' ,
37- message : req . t ( 'aposForm:fileUploadError' )
38- } ) ;
27+ // Reject invalid submissions (for example a failed reCAPTCHA challenge)
28+ // BEFORE storing any uploaded files. Otherwise a rejected submission
29+ // would still persist a publicly accessible attachment that garbage
30+ // collection never reclaims (GHSA-rgg4-476q-xgcg).
31+ if ( formErrors . length > 0 ) {
32+ throw self . apos . error ( 'invalid' , {
33+ formErrors
34+ } ) ;
35+ }
36+
37+ // Field names for the query params check list. Declared here so it
38+ // remains available after the try block below.
39+ const fieldNames = [ ] ;
40+
41+ // Track attachments stored while processing this submission so they can
42+ // be removed if the submission is ultimately rejected. This ensures a
43+ // rejected submission never leaves persistent, publicly accessible files
44+ // behind (GHSA-rgg4-476q-xgcg).
45+ const insertedAttachmentIds = [ ] ;
46+
47+ try {
48+ // Find any file field submissions and insert the files as attachments
49+ for ( const [ field , value ] of Object . entries ( input ) ) {
50+ if ( value === 'files-pending' ) {
51+ try {
52+ input [ field ] = await self . insertFieldFiles ( req , field , req . files ) ;
53+ insertedAttachmentIds . push ( ...input [ field ] ) ;
54+ } catch ( error ) {
55+ self . apos . util . error ( error ) ;
56+ formErrors . push ( {
57+ field,
58+ error : 'invalid' ,
59+ message : req . t ( 'aposForm:fileUploadError' )
60+ } ) ;
61+ }
3962 }
4063 }
41- }
4264
43- // Recursively walk the area and its sub-areas so we find
44- // fields nested in two-column widgets and the like
65+ // Recursively walk the area and its sub-areas so we find
66+ // fields nested in two-column widgets and the like
4567
46- // walk is not an async function so build an array of them to start
47- const areas = [ ] ;
68+ // walk is not an async function so build an array of them to start
69+ const areas = [ ] ;
4870
49- self . apos . area . walk ( {
50- contents : form . contents
51- } , function ( area ) {
52- areas . push ( area ) ;
53- } ) ;
71+ self . apos . area . walk ( {
72+ contents : form . contents
73+ } , function ( area ) {
74+ areas . push ( area ) ;
75+ } ) ;
5476
55- const fieldNames = [ ] ;
56- const conditionals = { } ;
57- const skipFields = [ ] ;
58-
59- // Populate the conditionals object fully to clear disabled values
60- // before starting sanitization.
61- for ( const area of areas ) {
62- const widgets = area . items || [ ] ;
63- for ( const widget of widgets ) {
64- // Capture field names for the params check list.
65- fieldNames . push ( widget . fieldName ) ;
66-
67- if ( widget . type === '@apostrophecms/form-conditional' ) {
68- self . trackConditionals ( conditionals , widget ) ;
77+ const conditionals = { } ;
78+ const skipFields = [ ] ;
79+
80+ // Populate the conditionals object fully to clear disabled values
81+ // before starting sanitization.
82+ for ( const area of areas ) {
83+ const widgets = area . items || [ ] ;
84+ for ( const widget of widgets ) {
85+ // Capture field names for the params check list.
86+ fieldNames . push ( widget . fieldName ) ;
87+
88+ if ( widget . type === '@apostrophecms/form-conditional' ) {
89+ self . trackConditionals ( conditionals , widget ) ;
90+ }
6991 }
7092 }
71- }
72-
73- self . collectToSkip ( input , conditionals , skipFields ) ;
7493
75- for ( const area of areas ) {
76- const widgets = area . items || [ ] ;
77- for ( const widget of widgets ) {
78- const manager = self . apos . area . getWidgetManager ( widget . type ) ;
79- if (
80- manager && manager . sanitizeFormField &&
81- ! skipFields . includes ( widget . fieldName )
82- ) {
83- try {
84- manager . checkRequired ( req , widget , input ) ;
85- await manager . sanitizeFormField ( widget , input , output ) ;
86- } catch ( err ) {
87- if ( err . data && err . data . fieldError ) {
88- formErrors . push ( err . data . fieldError ) ;
89- } else {
90- throw err ;
94+ self . collectToSkip ( input , conditionals , skipFields ) ;
95+
96+ for ( const area of areas ) {
97+ const widgets = area . items || [ ] ;
98+ for ( const widget of widgets ) {
99+ const manager = self . apos . area . getWidgetManager ( widget . type ) ;
100+ if (
101+ manager && manager . sanitizeFormField &&
102+ ! skipFields . includes ( widget . fieldName )
103+ ) {
104+ try {
105+ manager . checkRequired ( req , widget , input ) ;
106+ await manager . sanitizeFormField ( widget , input , output ) ;
107+ } catch ( err ) {
108+ if ( err . data && err . data . fieldError ) {
109+ formErrors . push ( err . data . fieldError ) ;
110+ } else {
111+ throw err ;
112+ }
91113 }
92114 }
93115 }
94116 }
95- }
96117
97- if ( formErrors . length > 0 ) {
98- throw self . apos . error ( 'invalid' , {
99- formErrors
100- } ) ;
118+ if ( formErrors . length > 0 ) {
119+ throw self . apos . error ( 'invalid' , {
120+ formErrors
121+ } ) ;
122+ }
123+ } catch ( error ) {
124+ // The submission was rejected after one or more uploaded files were
125+ // stored (a field validation error, a file upload failure, or an
126+ // unexpected error). Remove those attachments so a rejected submission
127+ // can't leave persistent, publicly accessible files behind.
128+ await self . deleteFieldFiles ( req , insertedAttachmentIds ) ;
129+ throw error ;
101130 }
102131
103132 if ( form . enableQueryParams && form . queryParamList . length > 0 ) {
@@ -128,6 +157,27 @@ module.exports = function (self) {
128157 return ids ;
129158
130159 } ,
160+ async deleteFieldFiles ( req , ids = [ ] ) {
161+ // Remove attachments that were stored while processing a form submission
162+ // that was ultimately rejected. Both the stored files (in uploadfs) and
163+ // the database record are removed so no publicly accessible orphan
164+ // remains. Best effort: a failure to remove one attachment must not
165+ // prevent removal of the others, nor mask the original submission error.
166+ for ( const id of ids ) {
167+ try {
168+ const attachment = await self . apos . attachment . db . findOne ( { _id : id } ) ;
169+
170+ if ( ! attachment ) {
171+ continue ;
172+ }
173+
174+ await self . apos . attachment . alterAttachment ( attachment , 'remove' ) ;
175+ await self . apos . attachment . db . removeOne ( { _id : id } ) ;
176+ } catch ( error ) {
177+ self . apos . util . error ( error ) ;
178+ }
179+ }
180+ } ,
131181 matchesName ( str , name ) {
132182 return str . startsWith ( name ) && str . match ( / .+ - \d + $ / ) ;
133183 } ,
0 commit comments