PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.8
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.8
3.0.1 3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 All 89 releases
← All changes | includes/class-htaccess-manager.php +159 -50 2.10.4 → 2.11.8 View file →
@@ -140,12 +140,34 @@
140 140
141 141 try {
142 142 // Read current content
143 143 $original = $this->read_file();
144 +
145 + /*
146 + * read_file() answers '' when there is no file and false when there
147 + * is one PHP cannot read. Until 2.11.8 both became an empty string
148 + * here, so a .htaccess that PHP could write but not read was replaced
149 + * whole by the Vigilant block, and every other rule in it was lost.
150 + */
144 151 if ( false === $original ) {
145 - $original = '';
152 + return new WP_Error( 'read_failed', __( '.htaccess could not be read, so it was left as it is.', 'vigilante' ) );
146 153 }
147 154
155 + /*
156 + * A block that has lost a marker would take the rest of the file with
157 + * it. The other known blocks are asked too: validate_content() below
158 + * refuses any result where one of them is unmatched, and saying why
159 + * here keeps that refusal from reading as a write failure worth
160 + * retrying every hour.
161 + */
162 + $whole = $this->blocks_are_whole( $original, $marker_start, $marker_end );
163 + foreach ( $this->known_blocks as $known_start => $known_end ) {
164 + $whole = $whole && $this->blocks_are_whole( $original, $known_start, $known_end );
165 + }
166 + if ( ! $whole ) {
167 + return new WP_Error( 'block_incomplete', __( 'A Vigilant block in .htaccess is missing one of its markers, so the file was left as it is.', 'vigilante' ) );
168 + }
169 +
148 170 // Create backup before modification
149 171 if ( ! empty( $original ) ) {
150 172 $this->create_backup( $original );
151 173 }
@@ -190,8 +212,15 @@
190 212
191 213 return new WP_Error( 'verify_failed', __( 'The .htaccess was written but did not read back as expected, so the previous content was restored', 'vigilante' ) );
192 214 }
193 215
216 + // Record the block as Vigilant's own. The integrity scan leaves
217 + // out of its hash only the blocks whose fingerprint is recorded,
218 + // so anything else carrying these markers is still checked.
219 + if ( class_exists( 'Vigilante_File_Integrity' ) ) {
220 + Vigilante_File_Integrity::remember_owned_block( '.htaccess', $marker_start, $block );
221 + }
222 +
194 223 return true;
195 224 } finally {
196 225 $this->release_lock();
197 226 }
@@ -199,31 +228,18 @@
199 228
200 229 /**
201 230 * Take the write lock, or fail if another process holds it.
202 231 *
203 - * add_option() is the atomic part: option_name carries a unique index, so
204 - * exactly one caller can create the row. A lock older than the timeout is
205 - * treated as abandoned (a fatal between acquire and release) and taken over,
206 - * otherwise a single crash would freeze every future write.
232 + * Until 2.11.8 this relied on add_option() being atomic, and it is not: it
233 + * runs INSERT ... ON DUPLICATE KEY UPDATE, so two writers arriving together
234 + * both believed they held the lock. See Vigilante_Settings::acquire_option_lock().
235 + * A lock older than the timeout still counts as abandoned and is taken over.
207 236 *
208 237 * @since 2.10.0
209 238 * @return bool
210 239 */
211 240 private function acquire_lock() {
212 - $now = time();
213 - $held = get_option( self::LOCK_OPTION );
214 -
215 - if ( false !== $held && is_numeric( $held ) && ( $now - (int) $held ) < self::LOCK_TIMEOUT ) {
216 - return false;
217 - }
218 -
219 - if ( false !== $held ) {
220 - // Abandoned lock: take it over.
221 - update_option( self::LOCK_OPTION, $now, false );
222 - return true;
223 - }
224 -
225 - return (bool) add_option( self::LOCK_OPTION, $now, '', false );
241 + return Vigilante_Settings::acquire_option_lock( self::LOCK_OPTION, self::LOCK_TIMEOUT );
226 242 }
227 243
228 244 /**
229 245 * Release the write lock.
@@ -230,57 +246,101 @@
230 246 *
231 247 * @since 2.10.0
232 248 */
233 249 private function release_lock() {
234 - delete_option( self::LOCK_OPTION );
250 + Vigilante_Settings::release_option_lock( self::LOCK_OPTION );
235 251 }
236 252
237 253 /**
254 + * Drop the integrity scan's record of a block Vigilant no longer has in the file
255 + *
256 + * @since 2.11.5
257 + *
258 + * @param string $marker_start Start marker of the block.
259 + */
260 + private function forget_owned_block( $marker_start ) {
261 + if ( class_exists( 'Vigilante_File_Integrity' ) ) {
262 + Vigilante_File_Integrity::forget_owned_blocks( '.htaccess', $marker_start );
263 + }
264 + }
265 +
266 + /**
238 267 * Remove a block from .htaccess
239 268 *
269 + * Takes the same write lock as add_block(): until 2.11.0 this
270 + * read-modify-write ran unlocked, so a removal racing an addition of a
271 + * different block could drop the block that had just been written (S5).
272 + *
240 273 * @param string $marker_start Start marker.
241 274 * @param string $marker_end End marker.
275 + * @param bool $automatic True when Vigilant removes the block by itself
276 + * (a mode expiring on cron), false when a person
277 + * asked for it. Same distinction as add_block().
242 278 * @return bool|WP_Error
243 279 */
244 - public function remove_block( $marker_start, $marker_end ) {
245 - if ( ! Vigilante_Settings::can_write_shared_files() ) {
280 + public function remove_block( $marker_start, $marker_end, $automatic = false ) {
281 + $allowed = $automatic
282 + ? Vigilante_Settings::owns_shared_files()
283 + : Vigilante_Settings::can_write_shared_files();
284 +
285 + if ( ! $allowed ) {
246 286 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
247 287 }
248 288
249 - // Read current content
250 - $content = $this->read_file();
251 -
252 - if ( false === $content || empty( $content ) ) {
253 - return true; // Nothing to remove
289 + if ( ! $this->acquire_lock() ) {
290 + return new WP_Error( 'locked', __( 'Another process is writing .htaccess right now', 'vigilante' ) );
254 291 }
255 292
256 - // Check if block exists
257 - if ( strpos( $content, $marker_start ) === false ) {
258 - return true; // Block doesn't exist, nothing to do
259 - }
293 + try {
294 + // Read current content
295 + $content = $this->read_file();
260 296
261 - // Create backup before modification
262 - $this->create_backup( $content );
297 + if ( false === $content || empty( $content ) ) {
298 + // An unreadable file proves nothing about the block, so its
299 + // record stays. A missing or empty one has no block left.
300 + if ( false !== $content ) {
301 + $this->forget_owned_block( $marker_start );
302 + }
303 + return true; // Nothing to remove
304 + }
263 305
264 - // Remove the block
265 - $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
306 + // Check if block exists
307 + if ( strpos( $content, $marker_start ) === false ) {
308 + $this->forget_owned_block( $marker_start );
309 + return true; // Block doesn't exist, nothing to do
310 + }
266 311
267 - // Validate result - WordPress rules should still be there if they were before
268 - if ( strpos( $content, '# BEGIN WordPress' ) !== false &&
269 - strpos( $new_content, '# BEGIN WordPress' ) === false ) {
270 - // WordPress rules were removed - this is wrong, restore backup
312 + // A block that has lost a marker would take the rest of the file with it.
313 + if ( ! $this->blocks_are_whole( $content, $marker_start, $marker_end ) ) {
314 + return new WP_Error( 'block_incomplete', __( 'A Vigilant block in .htaccess is missing one of its markers, so the file was left as it is.', 'vigilante' ) );
315 + }
316 +
317 + // Create backup before modification
318 + $this->create_backup( $content );
319 +
320 + // Remove the block
321 + $new_content = $this->remove_block_from_content( $content, $marker_start, $marker_end );
322 +
323 + // Validate result - WordPress rules should still be there if they were before
324 + if ( strpos( $content, '# BEGIN WordPress' ) !== false &&
325 + strpos( $new_content, '# BEGIN WordPress' ) === false ) {
326 + // WordPress rules were removed - this is wrong, restore backup
327 + $this->restore_backup();
328 + return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) );
329 + }
330 +
331 + // Write file
332 + if ( $this->write_file( $new_content ) ) {
333 + $this->forget_owned_block( $marker_start );
334 + return true;
335 + }
336 +
337 + // Write failed, restore backup
271 338 $this->restore_backup();
272 - return new WP_Error( 'wordpress_rules_lost', __( 'Operation would remove WordPress rules, aborted', 'vigilante' ) );
339 + return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
340 + } finally {
341 + $this->release_lock();
273 342 }
274 -
275 - // Write file
276 - if ( $this->write_file( $new_content ) ) {
277 - return true;
278 - }
279 -
280 - // Write failed, restore backup
281 - $this->restore_backup();
282 - return new WP_Error( 'write_failed', __( 'Failed to write .htaccess', 'vigilante' ) );
283 343 }
284 344
285 345 /**
286 346 * Check if a block exists in .htaccess
@@ -296,8 +356,47 @@
296 356 return strpos( $content, $marker_start ) !== false;
297 357 }
298 358
299 359 /**
360 + * Whether no start marker of a block is left without its end
361 + *
362 + * The removal below works line by line and keeps dropping lines from a start
363 + * marker until it meets an end marker, so a start whose end is missing, or a
364 + * second start before the end, takes everything after it. Neither existing
365 + * check catches that: remove_block() only looks for "# BEGIN WordPress",
366 + * which the rules Network Setup hands out do not carry, and the
367 + * validate_content() of add_block() only compares marker pairs, which still
368 + * match once both WordPress markers have been cut away. An end marker with
369 + * no start before it is harmless to the removal, which just drops that
370 + * line, so it does not count against the content.
371 + *
372 + * @since 2.11.6
373 + *
374 + * @param string $content Content to check.
375 + * @param string $marker_start Start marker.
376 + * @param string $marker_end End marker.
377 + * @return bool
378 + */
379 + private function blocks_are_whole( $content, $marker_start, $marker_end ) {
380 + $inside = false;
381 +
382 + foreach ( explode( "\n", $content ) as $line ) {
383 + $line = trim( $line );
384 +
385 + if ( $line === $marker_start ) {
386 + if ( $inside ) {
387 + return false;
388 + }
389 + $inside = true;
390 + } elseif ( $line === $marker_end ) {
391 + $inside = false;
392 + }
393 + }
394 +
395 + return ! $inside;
396 + }
397 +
398 + /**
300 399 * Remove a specific block from content string
301 400 *
302 401 * @param string $content Content to modify.
303 402 * @param string $marker_start Start marker.
@@ -497,10 +596,20 @@
497 596 return false;
498 597 }
499 598 }
500 599
600 + /*
601 + * Keep the permissions the file already has. put_contents() always sets
602 + * a mode, and FS_CHMOD_FILE is "permissions of index.php | 0644", so
603 + * until 2.11.6 every write left a .htaccess kept at 0640 at 0644 or
604 + * wider. A mode that cannot be read falls back to the old one rather
605 + * than to 0, which would lock the server out of the file.
606 + */
607 + $perms = file_exists( $this->htaccess_path ) ? fileperms( $this->htaccess_path ) : false;
608 + $mode = ( false !== $perms && ( $perms & 0777 ) ) ? ( $perms & 0777 ) : FS_CHMOD_FILE;
609 +
501 610 // Write with WP_Filesystem
502 - return $wp_filesystem->put_contents( $this->htaccess_path, $content, FS_CHMOD_FILE );
611 + return $wp_filesystem->put_contents( $this->htaccess_path, $content, $mode );
503 612 }
504 613
505 614 /**
506 615 * Create backup of current .htaccess