| @@ -175,15 +175,30 @@ | ||
| 175 | 175 | $truncated = $total_count > $export_cap; |
| 176 | 176 | $donations = Donations::get_for_export( $filters, $export_cap, 0 ); |
| 177 | 177 | |
| 178 | 178 | // First pass: collect the union of custom-field labels so every row |
| 179 | - // shares one consistent set of trailing columns. | |
| 180 | - $field_labels = []; | |
| 179 | + // shares one consistent set of trailing columns. The label is | |
| 180 | + // attacker-controlled (submitted for any slug absent from the saved | |
| 181 | + // form), so the union is capped and looked up by key rather than | |
| 182 | + // `in_array()` — otherwise a form fed thousands of distinct labels | |
| 183 | + // turns this into an O(field_count x label_count) scan and a | |
| 184 | + // same-sized column set. Labels past the cap are not dropped; they are | |
| 185 | + // exported instead in a single trailing JSON column below. | |
| 186 | + $max_field_columns = 50; | |
| 187 | + $field_labels = []; | |
| 188 | + $field_label_index = []; | |
| 189 | + $fields_truncated = false; | |
| 181 | 190 | foreach ( $donations as $donation ) { |
| 182 | 191 | foreach ( $this->get_donation_custom_fields( $donation ) as $label => $value ) { |
| 183 | - if ( ! in_array( $label, $field_labels, true ) ) { | |
| 184 | - $field_labels[] = $label; | |
| 192 | + if ( isset( $field_label_index[ $label ] ) ) { | |
| 193 | + continue; | |
| 185 | 194 | } |
| 195 | + if ( count( $field_labels ) >= $max_field_columns ) { | |
| 196 | + $fields_truncated = true; | |
| 197 | + continue; | |
| 198 | + } | |
| 199 | + $field_label_index[ $label ] = true; | |
| 200 | + $field_labels[] = $label; | |
| 186 | 201 | } |
| 187 | 202 | } |
| 188 | 203 | |
| 189 | 204 | $rows = []; |
| @@ -188,9 +203,10 @@ | ||
| 188 | 203 | |
| 189 | 204 | $rows = []; |
| 190 | 205 | $rows[] = array_merge( |
| 191 | 206 | Column_Map::standard_donation_export_labels(), |
| 192 | - $field_labels | |
| 207 | + $field_labels, | |
| 208 | + $fields_truncated ? [ Column_Map::other_fields_export_label() ] : [] | |
| 193 | 209 | ); |
| 194 | 210 | |
| 195 | 211 | $title_cache = []; |
| 196 | 212 | foreach ( $donations as $donation ) { |
| @@ -226,8 +242,13 @@ | ||
| 226 | 242 | // anonymous donor on a localised site. Every other value in this |
| 227 | 243 | // row is raw for the same reason. |
| 228 | 244 | ! empty( $donation['is_anonymous'] ) ? 'yes' : 'no', |
| 229 | 245 | $donation['donor_comment'] ?? '', |
| 246 | + // Untranslated on purpose, same as the anonymity flag above: this is | |
| 247 | + // the column that decides whether a comment is public, so a translated | |
| 248 | + // or missing value on re-import would republish comments a moderator | |
| 249 | + // had rejected. | |
| 250 | + $donation['donor_comment_status'] ?? '', | |
| 230 | 251 | $donation['ip_address'] ?? '', |
| 231 | 252 | $donation['created_at'] ?? '', |
| 232 | 253 | $donation['import_source'] ?? '', |
| 233 | 254 | ! empty( $donation['import_source_id'] ) ? $donation['import_source_id'] : '', |
| @@ -235,8 +256,13 @@ | ||
| 235 | 256 | |
| 236 | 257 | $field_values = $this->get_donation_custom_fields( $donation ); |
| 237 | 258 | foreach ( $field_labels as $label ) { |
| 238 | 259 | $row[] = $field_values[ $label ] ?? ''; |
| 260 | + } | |
| 261 | + | |
| 262 | + if ( $fields_truncated ) { | |
| 263 | + $overflow = array_diff_key( $field_values, $field_label_index ); | |
| 264 | + $row[] = ! empty( $overflow ) ? wp_json_encode( $overflow ) : ''; | |
| 239 | 265 | } |
| 240 | 266 | |
| 241 | 267 | $rows[] = $row; |
| 242 | 268 | } |