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

feat(serve-static): support absolute root #3420

Merged
merged 8 commits into from
Sep 24, 2024

Conversation

yusukebe
Copy link
Member

@yusukebe yusukebe commented Sep 17, 2024

This PR enables the serve static middleware to support an absolute path for root. This change affects the serveStatic from Bun and Deno adapter.

import { serveStatic } from 'hono/bun'

// ...

app.all(
  '/static/*',
  serveStatic({ root: '/home/hono/app/static', allowAbsoluteRoot: true })
)

To use an absolute path for root, you should set allowAbsoluteRoot as true. This is because it prevents security issues. If the user does not know a root can have an absolute path, an intended string that includes an absolute path is set; it will cause unexpected behavior for them. And using an absolute path has a risk of accessing the whole of the system. So, setting the flag explicitly is a good design.

We should implement the same feature for the Node.js adapter later.

Related to #3383 #3108

Closes #3107

The author should do the following, if applicable

  • Add tests
  • Run tests
  • bun run format:fix && bun run lint:fix to format the code
  • Add TSDoc/JSDoc to document the code

Copy link

codecov bot commented Sep 17, 2024

Codecov Report

Attention: Patch coverage is 94.73684% with 1 line in your changes missing coverage. Please review.

Project coverage is 95.78%. Comparing base (dfbd717) to head (6365ba9).
Report is 12 commits behind head on main.

Files with missing lines Patch % Lines
src/adapter/deno/serve-static.ts 0.00% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #3420   +/-   ##
=======================================
  Coverage   95.77%   95.78%           
=======================================
  Files         155      155           
  Lines        9310     9325   +15     
  Branches     2725     2808   +83     
=======================================
+ Hits         8917     8932   +15     
  Misses        393      393           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@yusukebe yusukebe marked this pull request as ready for review September 20, 2024 10:06
@yusukebe
Copy link
Member Author

Hey @usualoma ! Can you review this?

Copy link
Member

@usualoma usualoma left a comment

Choose a reason for hiding this comment

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

I think it's a good change!

However, although it will be a breaking change, I think that for users who are using it for the first time, they will expect that specifying it as follows will result in an absolute path, so I think it is a difficult question as to whether or not allowAbsoluteRoot is necessary.

serveStatic({ root: '/home/hono/app/static' })

runtime-tests/deno/middleware.test.tsx Outdated Show resolved Hide resolved
// assets => /assets
path = path.replace(/^(?!\/)/, '/')
// Using URL to normalize the path.
const url = new URL(`file://${path}`)
Copy link
Member

Choose a reason for hiding this comment

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

What was the purpose of using URL? (Sorry, I don't understand.)

With the current code, the following results will be obtained, so I think some kind of modification is necessary.

getFilePathWithoutDefaultDocument({
  filename: '/%2e%2e/%2e%2e/%2e%2e/%2e%2e/etc/passwd',
  root: '/p/p2',
  allowAbsoluteRoot: true,
}) // /etc/passwd

Copy link
Member Author

Choose a reason for hiding this comment

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

It uses URL to normalize paths like .., but as you said, it's not good! I'll change it.

Copy link
Member Author

@yusukebe yusukebe Sep 21, 2024

Choose a reason for hiding this comment

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

@usualoma

What about this change? 5f07dd7

Copy link
Member

Choose a reason for hiding this comment

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

Thank you!

If the necessary requirements are as follows,

  • root : .. refers to the directory above
  • path: %2e%2e should be left as is

The following code would also achieve this, and I think it would be more performant than resolving .. for each request. Is it possible to meet the requirements in this way?

diff --git a/src/middleware/serve-static/index.ts b/src/middleware/serve-static/index.ts
index 49cbcd3c..3517a2e7 100644
--- a/src/middleware/serve-static/index.ts
+++ b/src/middleware/serve-static/index.ts
@@ -40,6 +40,8 @@ export const serveStatic = <E extends Env = Env>(
     isDir?: (path: string) => boolean | undefined | Promise<boolean | undefined>
   }
 ): MiddlewareHandler => {
+  const root = new URL(`file://${options.root}`).pathname
+
   return async (c, next) => {
     // Do nothing if Response is already set
     if (c.finalized) {
@@ -49,7 +51,6 @@ export const serveStatic = <E extends Env = Env>(
 
     let filename = options.path ?? decodeURI(c.req.path)
     filename = options.rewriteRequestPath ? options.rewriteRequestPath(filename) : filename
-    const root = options.root
 
     const allowAbsoluteRoot = options.allowAbsoluteRoot ?? false
 

Copy link
Member Author

Choose a reason for hiding this comment

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

Ahhhh, goood idea!

Copy link
Member Author

Choose a reason for hiding this comment

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

@usualoma

I updated it. What do you think of the middleware/serve-static/index.ts?

Copy link
Member

Choose a reason for hiding this comment

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

Thank you!

Copy link
Member

Choose a reason for hiding this comment

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

Well, but

new URL(`file://${options.root}`).pathname

I think the behavior will change if you specify a relative path that includes the parent directory.

serveStatic({ root: '../relative/from/parent' })

The following method may be better.

  let root: string = options.root

  if (root && root.startsWith('/')) {
    isAbsoluteRoot = true
    root = new URL(`file://${root}`).pathname
  }

Copy link
Member Author

Choose a reason for hiding this comment

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

@usualoma

I've updated the code: 6365ba9

This change includes the fix for #3420 (comment) and added some tests. Could you review it?

@@ -42,6 +44,9 @@ export const getFilePathWithoutDefaultDocument = (
// /foo.html => foo.html
filename = filename.replace(/^\.?[\/\\]/, '')
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be better to use /^\.?[\/\\]+/ because it is very dangerous if a / remains at the beginning due to the following changes.

https://github.com/honojs/hono/pull/3420/files#diff-ab73a967275a45e7a4ef6280ce16d384179d552efc1095f067f3a43706fb189eR12

Suggested change
filename = filename.replace(/^\.?[\/\\]/, '')
filename = filename.replace(/^\.?[\/\\]+/, '')

Copy link
Member Author

Choose a reason for hiding this comment

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

Is the following code one of the dangerous cases? You mean this test should pass, right?

// with the current implementation, the following will fail
expect(getFilePathWithoutDefaultDocument({ filename: '///foo.txt' })).toBe('foo.txt')

Copy link
Member

Choose a reason for hiding this comment

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

I see, I'm sorry, the code I suggested might not be good. The case I'm worried about is as follows.

import { serveStatic } from './src/adapter/bun'
import { Hono } from './src/hono'

const app = new Hono()

app.use('/static/*', serveStatic({ root: './' }))
app.use('/favicon.ico', serveStatic({ path: './favicon.ico' }))
app.get('/', (c) => c.text('You can access: /static/hello.txt'))
app.get('*', serveStatic({ root: '.' })) // fallback
// or app.get('*', serveStatic({}))

export default app

I don't think this is a realistic setting, but it's not an invalid setting, and I think the relative path from the application directory will result in the expected result. With the current code in the feat/serve-static-absolute-root branch, if you access http://localhost:3000///etc/passwd, you can access any path from the absolute path using directory traversal.

Copy link
Member

Choose a reason for hiding this comment

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

I think the following changes will also prevent directory traversal.

diff --git a/src/utils/filepath.ts b/src/utils/filepath.ts
index f3f2ea82..61471483 100644
--- a/src/utils/filepath.ts
+++ b/src/utils/filepath.ts
@@ -52,5 +52,9 @@ export const getFilePathWithoutDefaultDocument = (
   let path = root ? root + '/' + filename : filename
   path = path.replace(/^\.?\//, '')
 
+  if (root[0] !== '/' && path[0] === '/') {
+    return
+  }
+
   return path
 }

@yusukebe
Copy link
Member Author

@usualoma

The reason I prefer allowAbsoluteRoot is not just that it will introduce a breaking change; I care about the security issue. If it allows an absolute root, the serve static can access any paths in the system like /etc/passwd. Of course, the user sets the option to serve static as an application owner, not an attacker, so the risk is small. But there is a possibility of accessing it. So, to prevent it as much as possible, I think adding the allowAbsoluteRoot is better.

However, this is my current opinion. Is this too much thing?

@usualoma
Copy link
Member

@yusukebe
Thanks for the explanation!

I can understand that “absolute path specifications are more likely to lead to dangerous situations”. However, on the other hand, I think that “if allowAbsoluteRoot is not specified, paths beginning with / are also processed as relative paths”, in some cases, this can be dangerous.

For example, if you wrote the following expecting it to be treated as an absolute path, but it ended up being treated as a relative path because you didn't specify allowAbsoluteRoot, the result for the user might be that “the static directly under the source code was published unintentionally”.

serveStatic({ root: '/static' })

I think that “unexpected results for users” are the most dangerous.

serveStatic({ root: '/static' }) // throw runtime error
serveStatic({ root: '/static', allowAbsoluteRoot: true }) // served with absolute path

If you specify it like this, instead of processing it as a relative path, how about making it an error at runtime?

@yusukebe
Copy link
Member Author

@usualoma

I can understand that “absolute path specifications are more likely to lead to dangerous situations”. However, on the other hand, I think that “if allowAbsoluteRoot is not specified, paths beginning with / are also processed as relative paths”, in some cases, this can be dangerous.

You are right. It's also dangerous. The idea you mentioned below is that throwing an error is good, but I think it's okay not to implement it. It's good to allow an absolute path that starts / and don't add an allowAbsoluteRoot option.

I'll change the code later. Thank you!

@yusukebe yusukebe force-pushed the feat/serve-static-absolute-root branch from c055a21 to 613b3c1 Compare September 21, 2024 11:34
@yusukebe yusukebe force-pushed the feat/serve-static-absolute-root branch 3 times, most recently from e33a4cb to a4ecbeb Compare September 22, 2024 01:21
@yusukebe yusukebe force-pushed the feat/serve-static-absolute-root branch from a4ecbeb to 6365ba9 Compare September 22, 2024 01:28
app.get('/static/*', serveStatic)

const res = await app.request('/static/html/hello.html')
expect(await res.text()).toBe('Hello in ./../home/hono/static/html/hello.html')
Copy link
Member Author

Choose a reason for hiding this comment

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

This behavior is the same as the current main one.

Copy link
Member

@usualoma usualoma left a comment

Choose a reason for hiding this comment

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

Thank you.
LGTM!

TinsFox pushed a commit to TinsFox/hono that referenced this pull request Oct 28, 2024
* feat(serve-static): support absolute root

* add bun runtime test

* don't use `Request`

* add code for deno

* remove unnecessay `console.log`

* use `normalizeFilePath` instead of `URL`

* use `URL` for `options.root`

* don't allow directory traversal and fix the behavior when root including `../`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

serveStatic does not work when set absolute path to root
2 participants