Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue #6497 - Replace the Alias checkers with new implementation. (Jetty-9.4) #6540

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a1bb8f3
Issue #6497 - Implement the AllowedResourceAliasChecker to improve Sa…
lachlan-roberts Jul 7, 2021
32ae130
Issue #6497 - add testing for AliasCheckers with symlinks
lachlan-roberts Jul 9, 2021
be8c6ac
Issue #6497 - AllowedResourceAliasChecker can test target of symlink
lachlan-roberts Jul 12, 2021
7c0c3c9
Issue #6497 - improve testing for AliasCheck with symlinks
lachlan-roberts Jul 23, 2021
1858153
Issue #6277 - add replacement Symlink AliasChecker
lachlan-roberts Jul 23, 2021
ad85c40
Merge remote-tracking branch 'origin/jetty-9.4.x' into jetty-9.4.x-Al…
lachlan-roberts Jul 23, 2021
b916269
Add the new SymlinkAliasChecker to fix test.
lachlan-roberts Jul 26, 2021
6909476
Deprecate AllowSymLinkAliasChecker in favour of SymlinkAllowedResourc…
lachlan-roberts Jul 26, 2021
68d93fb
Issue #6497 - Improve comments, re-implement the hasSymbolicLink method.
lachlan-roberts Jul 27, 2021
a7639bc
Issue #6497 - Make AllowedResourceAliasChecker a LifeCycle
lachlan-roberts Jul 30, 2021
32bff77
Issue #6497 - Fix AllowedResourceAliasChecker if protectedTarget does…
lachlan-roberts Jul 30, 2021
bf078be
Issue #6497 - aliasChecker with no baseResource should always denies …
lachlan-roberts Jul 30, 2021
27d8069
Fix AliasChecker lifecycle issue in ContextHandlerGetResourceTest
lachlan-roberts Aug 2, 2021
6e66604
Use root path in AllowedResourceAliasChecker if no basePath is define…
lachlan-roberts Aug 2, 2021
b1fe449
Clean up symlinks to not confuse DeploymentErrorTest
lachlan-roberts Aug 3, 2021
57741c0
changes from review
lachlan-roberts Aug 26, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import org.eclipse.jetty.security.HashLoginService;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.AllowSymLinkAliasChecker;
import org.eclipse.jetty.server.SymlinkAllowedResourceAliasChecker;
import org.eclipse.jetty.server.session.DefaultSessionCache;
import org.eclipse.jetty.server.session.DefaultSessionIdManager;
import org.eclipse.jetty.webapp.WebAppContext;
Expand All @@ -47,7 +47,7 @@ public static void main(String[] args) throws Exception
WebAppContext webapp = new WebAppContext();
webapp.setContextPath("/");
webapp.setWar("../../jetty-distribution/target/distribution/demo-base/webapps/test.war");
webapp.addAliasCheck(new AllowSymLinkAliasChecker());
webapp.addAliasCheck(new SymlinkAllowedResourceAliasChecker(webapp));
GCloudSessionDataStore ds = new GCloudSessionDataStore();

DefaultSessionCache ss = new DefaultSessionCache(webapp.getSessionHandler());
Expand Down
57 changes: 57 additions & 0 deletions jetty-io/src/test/java/org/eclipse/jetty/io/IOTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
import org.eclipse.jetty.toolchain.test.jupiter.WorkDirExtension;
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.resource.PathResource;
import org.eclipse.jetty.util.resource.Resource;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
Expand Down Expand Up @@ -550,4 +552,59 @@ public void testSelectorWakeup() throws Exception
}
}
}

@Test
public void testSymbolicLink(TestInfo testInfo) throws Exception
{
File dir = MavenTestingUtils.getTargetTestingDir(testInfo.getDisplayName());
FS.ensureEmpty(dir);
File realFile = new File(dir, "real");
Path realPath = realFile.toPath();
FS.touch(realFile);

File linkFile = new File(dir, "link");
Path linkPath = linkFile.toPath();
Files.createSymbolicLink(linkPath, realPath);
Path targPath = linkPath.toRealPath();

System.err.printf("realPath = %s%n", realPath);
System.err.printf("linkPath = %s%n", linkPath);
System.err.printf("targPath = %s%n", targPath);

assertFalse(Files.isSymbolicLink(realPath));
assertTrue(Files.isSymbolicLink(linkPath));

Resource link = new PathResource(dir).addPath("link");
assertThat(link.isAlias(), is(true));
}

@Test
public void testSymbolicLinkDir(TestInfo testInfo) throws Exception
{
File dir = MavenTestingUtils.getTargetTestingDir(testInfo.getDisplayName());
FS.ensureEmpty(dir);

File realDirFile = new File(dir, "real");
Path realDirPath = realDirFile.toPath();
Files.createDirectories(realDirPath);

File linkDirFile = new File(dir, "link");
Path linkDirPath = linkDirFile.toPath();
Files.createSymbolicLink(linkDirPath, realDirPath);

File realFile = new File(realDirFile, "file");
Path realPath = realFile.toPath();
FS.touch(realFile);

File linkFile = new File(linkDirFile, "file");
Path linkPath = linkFile.toPath();
Path targPath = linkPath.toRealPath();

System.err.printf("realPath = %s%n", realPath);
System.err.printf("linkPath = %s%n", linkPath);
System.err.printf("targPath = %s%n", targPath);

assertFalse(Files.isSymbolicLink(realPath));
assertFalse(Files.isSymbolicLink(linkPath));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
//
// ========================================================================
// Copyright (c) 1995-2021 Mort Bay Consulting Pty Ltd and others.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//

package org.eclipse.jetty.server;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashSet;

import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.util.StringUtil;
import org.eclipse.jetty.util.component.AbstractLifeCycle;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.resource.PathResource;
import org.eclipse.jetty.util.resource.Resource;

/**
* <p>This will approve any alias to anything inside of the {@link ContextHandler}s resource base which
* is not protected by {@link ContextHandler#isProtectedTarget(String)}.</p>
* <p>This will approve symlinks to outside of the resource base. This can be optionally configured to check that the
* target of the symlinks is also inside of the resource base and is not a protected target.</p>
* <p>Aliases approved by this may still be able to bypass SecurityConstraints, so this class would need to be extended
* to enforce any additional security constraints that are required.</p>
*/
public class AllowedResourceAliasChecker extends AbstractLifeCycle implements ContextHandler.AliasCheck
{
private static final Logger LOG = Log.getLogger(AllowedResourceAliasChecker.class);
private static final LinkOption[] FOLLOW_LINKS = new LinkOption[0];
private static final LinkOption[] NO_FOLLOW_LINKS = new LinkOption[]{LinkOption.NOFOLLOW_LINKS};

private final ContextHandler _contextHandler;
private final boolean _checkSymlinkTargets;
private final HashSet<Path> _protectedPaths = new HashSet<>();
private Path _basePath;

/**
* @param contextHandler the context handler to use.
* @param checkSymlinkTargets true to check that the target of the symlink is an allowed resource.
*/
public AllowedResourceAliasChecker(ContextHandler contextHandler, boolean checkSymlinkTargets)
{
_contextHandler = contextHandler;
lachlan-roberts marked this conversation as resolved.
Show resolved Hide resolved
_checkSymlinkTargets = checkSymlinkTargets;
}

protected ContextHandler getContextHandler()
{
return _contextHandler;
}

protected Path getBasePath()
{
return _basePath;
}

@Override
protected void doStart() throws Exception
{
_basePath = getPath(_contextHandler.getBaseResource());
if (_basePath == null)
_basePath = Paths.get("/").toAbsolutePath();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why calling toAbsolutePath()? Isn't just a single /?
Is it because of Windows? If so, needs a comment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't do what you expect on Windows.

If you are running from a Windows Share for example, take \\machine\sharename\jetty your code will result in a Path that is no longer in the share location.
Is that what you want?

The Path has a FileSystem object associated with it, you can ask for its root, or even for all roots that the FileSystem layer supports.


String[] protectedTargets = _contextHandler.getProtectedTargets();
if (protectedTargets != null)
{
for (String s : protectedTargets)
{
Path path = _basePath.getFileSystem().getPath(_basePath.toString(), s);
_protectedPaths.add(path);
}
}
}

@Override
protected void doStop() throws Exception
{
_basePath = null;
_protectedPaths.clear();
}

@Override
public boolean check(String uri, Resource resource)
{
// The existence check resolves the symlinks.
if (!resource.exists())
return false;

Path resourcePath = getPath(resource);
if (resourcePath == null)
return false;

try
{
if (isProtectedPath(resourcePath, NO_FOLLOW_LINKS))
return false;

if (_checkSymlinkTargets && hasSymbolicLink(resourcePath))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this check here? Should it not be in the SymlinkAllowedResourceAliasChecker subclass?
I mean that seems weird that this base class does a bit of symlink but not all... Can't all symlink handling and configuration fields be pushed to the subclass?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a different check to what is done in the subclass, the subclass does not even use this logic, it sets _checkSymlinkTargets to false.

This is an optional check to ensure the target of the symlink is also an allowed resource.

{
if (isProtectedPath(resourcePath, FOLLOW_LINKS))
return false;
}
}
catch (Throwable t)
{
LOG.warn(t);
return false;
}

return true;
}

/**
* <p>Determines whether the provided resource path is protected.</p>
*
* <p>The resource path is protected if it is under one of the protected targets defined by
* {@link ContextHandler#isProtectedTarget(String)} in which case the alias should not be allowed.
* The resource path may also attempt to traverse above the root path and should be denied.</p>
*
* @param resourcePath the resource {@link Path} to be tested.
* @param linkOptions an array of {@link LinkOption} to be provided to the {@link Path#toRealPath(LinkOption...)} method.
* @return true if the resource path is protected and the alias should not be allowed.
* @throws IOException if an I/O error occurs.
*/
protected boolean isProtectedPath(Path resourcePath, LinkOption[] linkOptions) throws IOException
lachlan-roberts marked this conversation as resolved.
Show resolved Hide resolved
{
// If the resource doesn't exist we cannot determine whether it is protected so we assume it is.
if (!Files.exists(resourcePath, linkOptions))
return true;

Path basePath = _basePath.toRealPath(linkOptions);
Path targetPath = resourcePath.toRealPath(linkOptions);
String target = targetPath.toString();

// The target path must be under the base resource directory.
if (!targetPath.startsWith(basePath))
return true;

for (Path protectedPath : _protectedPaths)
{
String protect;
if (Files.exists(protectedPath, linkOptions))
protect = protectedPath.toRealPath(linkOptions).toString();
else if (linkOptions == NO_FOLLOW_LINKS)
protect = protectedPath.normalize().toAbsolutePath().toString();
else
protect = protectedPath.toFile().getCanonicalPath();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this code but I cant think of a better alternative. The call to toRealPath requires the file to exist otherwise it throws, but we must check this anyway, because /web-inf might not exist but /WEB-INF could exist and so the Files.exists call would return false.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have made a rod for our backs by specifying the protected targets as lowercase /web-inf.
However, I very much doubt that anybody other than us adds any protected targets, so why don't we just fix this!
We can change our protected targets to use the correct cases and throw an ISE if anybody calls it with any other protected targets (or if the set a protected target that doesn't exist).

Then in doStart we can find the actual Paths of all the protected targets (that exist) and replace all this with the logic I prefer... walking up the parent links looking for any path that is sameFile to any of the protected targets

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think its a good idea, we might not think anyone is calling that method, but that's just a guess.
If someone is using it and we change it, then it will expose directories that were previously protected.

Doesn't seem like something we want going into a minor release, especially for 9.4.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually maybe we still wouldn't expose it because its still protected by ContextHander.isProtectedTarget which is case insensitive.

Still doesn't feel like a good idea. Maybe open another issue to consider changing protectedTargets to be case sensitive. We could still change the logic of this later on, but for now it seems like its doing the job of not allowing access to protected targets.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can check if somebody sets a new protected target that does not exist, so I'm not that worried about breaking any usage of that.

The check that we want to make is that the resource is in the baseResource directory, but is not in any of the protected resource directories. We are trying to do that in a FS independent ways with case insensitive string-starts checks, but they are ultimately just hacks.

If we can precisely identify the Path of the base resource and the Paths of the protected resources. We can then walk the parent linkage of the resource and check that none of it's parents are sameFile as the protected resource and that ultimately one of the parents is sameFile as base resource - we then have an absolutely precise check that does exactly what we want.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we do this only in 10? ... but if we are adding new recommended checkers in 9 and deprecating the existing ones, we may as well add ones that we have 100% confidence in.


// If the target path is protected then we will not allow it.
if (StringUtil.startsWithIgnoreCase(target, protect))
{
if (target.length() == protect.length())
return true;

// Check that the target prefix really is a path segment.
if (target.charAt(protect.length()) == File.separatorChar)
return true;
}
}

return false;
}

protected boolean hasSymbolicLink(Path path)
lachlan-roberts marked this conversation as resolved.
Show resolved Hide resolved
{
return hasSymbolicLink(path.getRoot(), path);
}

protected boolean hasSymbolicLink(Path base, Path path)
{
Path p = path;
while (!base.equals(p))
{
if (p == null)
throw new IllegalArgumentException("path was not child of base");

if (Files.isSymbolicLink(p))
return true;

p = p.getParent();
}

return false;
}

private Path getPath(Resource resource)
{
try
{
if (resource instanceof PathResource)
return ((PathResource)resource).getPath();
return resource.getFile().toPath();
}
catch (Throwable t)
{
LOG.ignore(t);
return null;
}
}

@Override
public String toString()
{
return String.format("%s@%x{checkSymlinkTargets=%s}", AllowedResourceAliasChecker.class.getSimpleName(), hashCode(), _checkSymlinkTargets);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@
* or Linux on XFS) the the actual file could be stored using UTF-16,
* but be accessed using NFD UTF-8 or NFC UTF-8 for the same file.
* </p>
* @deprecated use {@link org.eclipse.jetty.server.AllowedResourceAliasChecker} instead.
*/
@Deprecated
public class SameFileAliasChecker implements AliasCheck
{
private static final Logger LOG = Log.getLogger(SameFileAliasChecker.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// ========================================================================
// Copyright (c) 1995-2021 Mort Bay Consulting Pty Ltd and others.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//

package org.eclipse.jetty.server;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.resource.Resource;

/**
* An extension of {@link AllowedResourceAliasChecker} which only allows aliased resources if they are symlinks.
*/
public class SymlinkAllowedResourceAliasChecker extends AllowedResourceAliasChecker
{
private static final Logger LOG = Log.getLogger(SymlinkAllowedResourceAliasChecker.class);

/**
* @param contextHandler the context handler to use.
*/
public SymlinkAllowedResourceAliasChecker(ContextHandler contextHandler)
{
super(contextHandler, false);
}

@Override
public boolean check(String uri, Resource resource)
{
try
{
// Check the resource is allowed to be accessed.
if (!super.check(uri, resource))
return false;

// Approve if path is a symbolic link.
Path resourcePath = resource.getFile().toPath();
if (Files.isSymbolicLink(resourcePath))
return true;

// Approve if path has symlink in under its resource base.
if (super.hasSymbolicLink(getBasePath(), resourcePath))
return true;
}
catch (IOException e)
{
LOG.ignore(e);
return false;
}

return false;
}
}