Page MenuHomePhabricator

Protect file data with a one-time-token
ClosedPublic

Authored by 20after4 on Jul 25 2014, 4:43 PM.
Referenced Files
Unknown Object (File)
Wed, Apr 24, 10:16 PM
Unknown Object (File)
Fri, Apr 19, 11:02 AM
Unknown Object (File)
Fri, Apr 19, 2:04 AM
Unknown Object (File)
Fri, Apr 19, 2:04 AM
Unknown Object (File)
Fri, Apr 19, 2:04 AM
Unknown Object (File)
Fri, Apr 19, 2:04 AM
Unknown Object (File)
Fri, Apr 19, 2:04 AM
Unknown Object (File)
Fri, Apr 19, 2:04 AM

Details

Test Plan

currently untested work in progress

Diff Detail

Repository
rP Phabricator
Lint
Lint Skipped
Unit
Tests Skipped

Event Timeline

20after4 retitled this revision from to Protect file data with a one-time-token.
20after4 edited the test plan for this revision. (Show Details)

This isn't quite done but I wanted to make sure I'm on the right track with the one time token stuff and my logic in the data controller.

It doesn't handle redirect loops yet and I'm not quite sure where the canCDN flag should be set, but other than that I think this should be close to correct.

epriestley edited edge metadata.

Yep, this looks like it's headed down the right path. Couple of inlines.

For setting the canCDN flag, find the relevant places where images are uploaded/created -- for example, PhabricatorFile::newFromPHPUpload() in PhabricatorPeopleProfilePictureController. The ones I can think of offhand are:

  • That one;
  • the gravatar/oauth profile picture stuff;
  • uploading project profile pictures;
  • generating project profile pictures;
  • macros;
  • and probably thumbnails?

Then add some new key to the options at those callsites, like:

'canCDN' => true,

Finally, handle the option in PhabricatorFile::buildFromFileData(), by setting the appropriate metadata flag.

src/applications/files/controller/PhabricatorFileDataController.php
12

You can simplify this slightly as idx($data, 'token').

81–82

We're loading this file too early, in a way that bypasses permissions (using the omnipotent viewer).

If we're going to generate a token, we need to use $viewer to make sure the viewer really has permission.

If we're consuming a token, or the file can CDN, we can use the omnipotent user.

116–118

If any token is set (even a bad one), we shouldn't let the response cache.

117–123

I think this will do the wrong thing if security.alternate-file-domain is not set.

126–130

Maybe this should go on PhabricatorFile, e.g. getCDNURIWithToken() or something, which generates a token and returns the entire valid URI.

src/applications/files/storage/PhabricatorFile.php
861–863

Theoretically we might want to CDN other things in the future (like build artifacts, audio, or video) but this seems reasonable for now.

864

Some ambiguity between CAN_CDN vs NO_CDN. I think it's a little safer to use CAN_CDN, so the default behavior is more locked down. The downside is that this will need a migration, but that shouldn't be too hard.

879

This doesn't actually check $token_code, but should (e.g., withTokenCodes(...)).

This revision now requires changes to proceed.Jul 25 2014, 5:12 PM
src/applications/files/controller/PhabricatorFileDataController.php
117–123

I'm not sure what the would be the right thing when alternate-file-domain isn't set? If we want to encourage people to set this, then we could return an error unless it's explicitly set to null or something.

Do you have a better idea?

126–130

That does sound better, will do.

src/applications/files/storage/PhabricatorFile.php
864

I was kinda hoping to avoid the migration but if defaulting to secure is desirable then I guess I'll learn how to do phab migrations.

For no domain being set, I still want things to work -- a lot of installs don't have a setting here yet, and new installs won't either. We can do a normal login check on these files -- just make sure they aren't cacheable.

I think we just add a first if ($alt_domain === null) branch, so the overall logic looks like this:

if (no alt domain configured) {
  load the file with permissions checks;
  return the file data without cache headers;
} else if (we are on the primary domain) {
  load the file with permissions checks;
  if the user can see it, generate a token;
  redirect to the alt domain with the token;
} else if (a token exists) {
  consume the token, if it is valid;
  load the file, bypassing permission checks;
  return the file data without cache headers;
}

Some of that might be able to be simplified, but I think that's roughly how the behaviors should work.

For the migration, I expect it to look something like this:

$table = new PhabricatorFile();
$conn_w = $table->establishConnection('w');
foreach (new LiskMigrationIterator($table) as $file) {
  $id = $file->getID();
  echo "Updating flags for file {$id}...\n";
  $meta = $file->getMetadata();
  if (!idx($meta, 'canCDN')) {

    $meta['canCDN'] = true;

    queryfx(
      $conn_w,
      'UPDATE %T SET metadata = %s WHERE id = %d',
      $table->getTableName(),
      json_encode($meta),
      $id);
  }
}

Migrations are no fun and it would be nice not to have to do it, but I think we're much better off in the long run if the default is the "safe" option.

20after4 edited edge metadata.

Addressed review comments.

Still a bit more to do I suspect, and the migration isn't included here...

The logic in PhabricatorFileDataController should be just about right, but I wouldn't mind a second set of eyeballs. I deviated slightly from the pseudocode in @epriestley's review, here's why:

  1. I don't consume the token if it's a range request, I don't know if there is a good way to handle this since there could be multiple requests for the same file/token. Do tokens get garbage collected eventually? If so, maybe it's ok to leave it for garbage collection in this case.
  2. I'm just returning a 403 when there is a request meeting all of these conditions: using alt domain, without a token, CanCDN is false. I probably should redirect to the main domain to get a token in this case but this is the situation where we need to prevent redirect loops. I'll think a bit more about this one and attempt to handle that properly.
src/applications/files/controller/PhabricatorFileDataController.php
133

only consume the token if it isn't a range request... not sure how to deal with this..

135

probably need to redirect to main domain and handle possible redirect loops here.

src/applications/files/storage/PhabricatorFile.php
206–207

This was redundant

Tokens are automatically GC'd, yeah. Your range behavior seems reasonable. I think this only actually impacts audio for image macros, and the browser(s) in question might only actually make one request (?) for the whole file (??) -- it's been a while. But generally this seems like a reasonable line of attack.

I think if you redirect instead of doing the second 403 this is basically in good shape.

src/applications/files/controller/PhabricatorFileDataController.php
84–85

I think you can $request->getHost() to get this a little more easily.

90

As a edge case, we should also do this if the alt domain is set, but misconfigured to be the same as the main domain -- that is, setting the alt domain to the main domain is the same as not setting it.

97

We can still cache in this case if the file "canCDN" (and should).

135

This one can safely redirect without worrying about loops, I think. 403'ing above seems like a reasonable way to solve the loop problem for now, and we can deal with it in more detail later on if we need to.

20after4 edited edge metadata.

Handle redirect when there is no token, and other misc.
changes to address @epriestley's review feedback.

epriestley edited edge metadata.

This looks good, except for one inline: we need to validate the secret before we do anything with the file, including issuing redirects.

src/applications/files/controller/PhabricatorFileDataController.php
78–80

This can probably be removed now, I think it's kind of confusing.

87–111

Oh, we need to validate the secret before redirecting. Otherwise, we may expose the secret to a user who doesn't know it.

134

Maybe use a temp var here for clarity.

This revision now requires changes to proceed.Jul 31 2014, 7:51 PM

ok: all issues should be addressed in the incoming update....

src/applications/files/controller/PhabricatorFileDataController.php
87–111

I'm going to refactor this slightly to move the 404 and 403 response checks into a reusable function that gets called in a couple of places.

20after4 edited edge metadata.
  • Removed confusing comment
  • refactored the 404 and 403 checks into a separate small function that gets called in 3 places.
  • moved temp variable usage out of the if so that assignment isn't confused with equality.

Cool, this looks correct to me. We also need to set the canCDN flag on new profile pictures, macros, Pholio mocks, and thumbnails -- without that, this will throw them into big redirect messes. Do you want to do that as a separate diff and we can just hold this until that's ready?

Thinking about it, that can actually land first. That is, do one diff which:

  • adds support for a canCDN flag to PhabricatorFile::buildFromFileData();
  • sets the flag on files created via profile pictures, macros, Pholio mocks, thumbnail transformations, and maybe Remarkup .dot file images -- let me grep around and try to get you an exhaustive list; and
  • adds a property on the File detail view (maybe on the "File Info" tab) showing the value of the flag for a given file, so it's easier to test/debug that things are working right.

We can land that, kick the tires on it a bit, and then land this.

src/applications/files/controller/PhabricatorFileDataController.php
53

Prefer $error_response. Consider performing assignment and condition on separate lines for clarity:

$x = ...
if ($x) {
  ...
}

Here are the places I was able to identify that need the canCDN flag:

  • add_macro.php, CLI creation of new image macros.
  • All calls in PhabricatorImageTransformer: the rule this will implement is "image thumbnails can always CDN", which I think is reasonable. We do not permit users to choose their thumbnail dimensions arbitrarily, so this can never show you the source file by generating a thumbnail with source dimensions unless the source is very small.
  • PhabricatorPeopleProfilePictureController, the Gravatar profile image write and the explicit upload write.
  • PhabricatorMacroAudioController, the audio write.
  • PhabricatorMacroEditController, both the from-file and from-url writes.
  • PhabricatorProjectEditPictureController, the explicit upload write.
  • PhabricatorAuthProvider, the profile image write.
  • PhabricatorFileComposeController, the write of generated project profile images.

And possibly:

  • FileUploadConduitAPIMethod should maybe expose this (and maybe viewPolicy) but we can wait for use cases.

I'm omitting:

  • On consideration, let's leave Pholio off the canCDN list for now, since conceivably it could have sensitive stuff in it and the number of actual image requests is very small and it's reasonable to special-case it later and somewhat complicated to get the flag set.
  • I'm leaving the write in PhabricatorRemarkupGraphvizBlockInterpreter off for similar reasons.

In general, I've compiled this list by looking for calls to PhabricatorFile::newFromFileData(), PhabricatorFile::newFromPHPUpload(), PhabricatorFile::newFromFileDownload(), PhabricatorFile::newFromXHRUpload(), or PhabricatorFile::buildFromFileDataOrHash() and then calling out the ones that generate a reasonably-public, commonly used file.

@epriestley ok so do you think the migration should be in this diff or the other one?

This comment was removed by 20after4.

I think doing it in either place is OK, whatever's easiest.

If you rebase this (since some of it happened in D10166), I think we're good to go.

rebased on top of upstream/master

rebased but without modifying phutil_library_map

epriestley edited edge metadata.

Oh -- is this still untested?

  • In PhabricatorFile::generateOneTimeToken(), the token does not generate because it is protected by the write guard and the request does not have CSRF tokens.
  • In the same method, $token (an object) is returned, but it is used as a string in getCDNURIWithToken().
  • In validateOneTimeToken(), we look up the token directly, instead of looking up the hash, so we never find it.
    • The approach here is to save the hash -- not the token itself -- since it's "free" and marginally reduces the value of read access to some artifact like part of the database binary logs. Although no practical attack likely exists against these tokens, there are more plausible attacks against some other similar tokens (and it is hard to imagine a user getting database access without also being able to see something more valuable), and consistently discarding the original token when we don't need to retain it is a little safer. Likewise, for example, we do not store the real session keys.
  • Token deletion is also protected by the write guard.

I'm going to fix this stuff locally and pull it, since it seems correct with those adjustments.

Also:

  • To solve the redirect thing and reduce the hostility of the 403, we could just serve a page with a dialog box like "the link you followed is invalid or expired, click here to continue". I'll shoot you a diff for this.
src/applications/files/controller/PhabricatorFileDataController.php
43

A slightly better test than empty() is !strlen(), because the string "0" is empty().

This usually doesn't matter, but can lead to odd behaviors like not being able to leave the comment "0", not being able to have the username "0", etc.

(In this case, the alternate domain "0" is meaningless anyway.)

This revision is now accepted and ready to land.Aug 11 2014, 2:32 PM
epriestley updated this revision to Diff 24576.

Closed by commit rP25ae4c458dda (authored by @20after4, committed by @epriestley).