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

interp: fix cd builtin: call a system function to determine access instead of reverse-engineering it ourselves #1034

Merged
merged 4 commits into from
Sep 30, 2023

Conversation

theclapp
Copy link
Collaborator

Fixes #1033.

@theclapp
Copy link
Collaborator Author

It seems like if you are user id 1 & group id 2, and a directory is also user id 1 & group id 2, and the directory is 070 or 007, then you should be able to cd into the directory -- the "group" or "other" perms should let you in even if the "owner" perms don't, but you can't & they don't.

I don't know if this is "right" or not, but it is what both bash and zsh do on both macOS and Linux, so I'm sticking with it.

(To be clear: This package already did it that way, this comment was just to explain it a little, and this PR was just to make it look at all your groups, not just your "main" group.)

@mvdan
Copy link
Owner

mvdan commented Sep 27, 2023

I'm starting to wonder if this is the wrong approach entirely - it feels like we're trying to reverse-engineer the Unix-like semantics. Which are probably pointless or more likely wrong on Windows or Plan9.

What if we were to open the directory and try to read from it? That should get the OS to tell us whether we can use the directory or not. Opening the directory alone might be enough to tell; if not, then we could call e.g. https://pkg.go.dev/os#File.Readdirnames with an argument 1 to get just the first directory entry name, to just perform the cheapest read operation possible.

@theclapp
Copy link
Collaborator Author

I thought about that, and would prefer that approach. I wasn't sure what operations to try to verity the exact permissions required. It may take some experimentation.

There is os.Chdir (et al) but that doesn't seem right, either.

@mvdan
Copy link
Owner

mvdan commented Sep 27, 2023

Actually performing os.Chdir would be correct if we can assume we own the entire process per #171, but in general we can't assume that, e.g. if a Go process is interpreting multiple scripts they can't be fighting each other over os.Chdir or other global state like os.Setenv.

@theclapp
Copy link
Collaborator Author

but in general we can't assume that, e.g. if a Go process is interpreting multiple scripts

Yes, exactly. My app might be doing exactly that. Now, that's all it does, so if sh used a lock surrounding the global Chdir, and exported it (or made some api public), then apps that used sh and were aware of the problem could cooperate.

That still seems suboptimal. I'd rather figure out some other (more local/specific) system call (or combination thereof) that exactly mirrors the pass/fail semantics of chdir.

If the user id matches the file owner id, then only the "user"
permission bits apply, so just return true or false immediately.
And then similarly for group & "others".
@mvdan
Copy link
Owner

mvdan commented Sep 28, 2023

Doesn't open and a possible read afterwards mimic the same behavior, without actually changing the directory? I would bet that it does.

@theclapp
Copy link
Collaborator Author

No. For --x and -wx, cd should succeed, but Open/OpenFile fails (so there's nothing to Read). For r-- and rw-, cd should fail, but Open and Readdirnames both succeed. Ditto ReadDir and Readdir. So Open and Read* look at the "read" bit.

I don't see any Go call that would specifically examine the "execute" bit of a directory.

@theclapp
Copy link
Collaborator Author

It looks like golang.org/x/sys/unix.Access (https://pkg.go.dev/golang.org/x/[email protected]/unix#Access) does the trick!

@theclapp
Copy link
Collaborator Author

theclapp commented Sep 28, 2023

id: uid lmc, gid staff, other guids: contains admin, but not daemon

test directory:

d---------  3 daemon  staff   96 Sep 28 10:59 g0/
d-----x---  3 daemon  staff   96 Sep 28 10:59 g1/
d----w----  3 daemon  staff   96 Sep 28 10:59 g2/
d----wx---  3 daemon  staff   96 Sep 28 10:59 g3/
d---r-----@ 3 daemon  staff   96 Sep 28 10:59 g4/
d---r-x---@ 3 daemon  staff   96 Sep 28 10:59 g5/
d---rw----@ 3 daemon  staff   96 Sep 28 10:59 g6/
d---rwx---@ 3 daemon  staff   96 Sep 28 10:59 g7/
d---------  2 daemon  admin   64 Sep 28 11:36 g_0/
d-----x---  2 daemon  admin   64 Sep 28 11:36 g_1/
d----w----  2 daemon  admin   64 Sep 28 11:36 g_2/
d----wx---  2 daemon  admin   64 Sep 28 11:36 g_3/
d---r-----@ 2 daemon  admin   64 Sep 28 11:36 g_4/
d---r-x---@ 2 daemon  admin   64 Sep 28 11:36 g_5/
d---rw----@ 2 daemon  admin   64 Sep 28 11:36 g_6/
d---rwx---@ 2 daemon  admin   64 Sep 28 11:36 g_7/
d---------  3 daemon  daemon  96 Sep 28 10:59 o0/
d--------x  3 daemon  daemon  96 Sep 28 10:59 o1/
d-------w-  3 daemon  daemon  96 Sep 28 10:59 o2/
d-------wx  3 daemon  daemon  96 Sep 28 10:59 o3/
d------r--@ 3 daemon  daemon  96 Sep 28 10:59 o4/
d------r-x@ 3 daemon  daemon  96 Sep 28 10:59 o5/
d------rw-@ 3 daemon  daemon  96 Sep 28 10:59 o6/
d------rwx@ 3 daemon  daemon  96 Sep 28 10:59 o7/
d---------  3 lmc     staff   96 Sep 28 10:59 u0/
d--x------  3 lmc     staff   96 Sep 28 10:59 u1/
d-w-------  3 lmc     staff   96 Sep 28 10:59 u2/
d-wx------  3 lmc     staff   96 Sep 28 10:59 u3/
dr--------@ 3 lmc     staff   96 Sep 28 10:59 u4/
dr-x------@ 3 lmc     staff   96 Sep 28 10:59 u5/
drw-------@ 3 lmc     staff   96 Sep 28 10:59 u6/
drwx------@ 3 lmc     staff   96 Sep 28 10:59 u7/

Test sh code:

for f in * ; do if cd $f >& /dev/null ; then echo $f worked ; cd .. ; else echo $f failed ; fi ; done
g0 failed
g1 worked
g2 failed
g3 worked
[...]

test Go code:

func (r *Runner) changeDir(ctx context.Context, path string) int {
	if path == "" {
		path = "."
	}
	path = r.absPath(path)
	info, err := r.stat(ctx, path)
	if err != nil || !info.IsDir() {
		return 1
	}
	accessErr := unix.Access(path, unix.X_OK)
	hptd := hasPermissionToDir(info)
	if hptd != (accessErr == nil) {
		log.Printf("%s: hptd: %v, accessErr: %v", path, hptd, accessErr)
	}
	if !hptd {
		return 1
	}
	r.Dir = path
	r.setVarString("OLDPWD", r.envGet("PWD"))
	r.setVarString("PWD", path)
	return 0
}

So the Go code runs both unix.Access and our already-defined hasPermissionToDir and prints stuff when they differ, and they never did.

Rip out the body of interp.hasPermissionToDir and replace it with just a
call to unix.Access(path, unix.X_OK).

Update the function signature of hasPermissionToDir in os_notunix.go,
too.
@theclapp theclapp changed the title interp: fix cd builtin: check all user groups interp: fix cd builtin: call a system function to determine access instead of reverse-engineering it ourselves Sep 28, 2023
Since os_notunix.go is Windows-only, my "goimports" didn't run, so it
didn't remove the now-unnecessary import of "os".
@theclapp
Copy link
Collaborator Author

Finally got all checks to pass. No code changes, I just kept re-running the checks. I dunno if that was the right approach. 🤷‍♂️ 😆

@theclapp theclapp requested a review from mvdan September 29, 2023 15:56
@theclapp theclapp self-assigned this Sep 29, 2023
Copy link
Owner

@mvdan mvdan left a comment

Choose a reason for hiding this comment

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

TIL about the access syscall:

access() checks whether the calling process can access the file pathname.
R_OK, W_OK, and X_OK test whether the file exists and grants read, write, and execute permissions, respectively.

I am also personally surprised that "execute" permission is enough to cd into a directory, and "read" is unnecessary, but you've clearly tested it :)

Either way, love to delete all this code. Depending on x/sys/unix is always a bit tricky with portability, but this is already os_unix.go.

@mvdan mvdan merged commit 08cb48c into mvdan:master Sep 30, 2023
8 checks passed
@theclapp
Copy link
Collaborator Author

theclapp commented Oct 2, 2023

It's better than it was, but is still wrong for set-uid programs.

The check is done using the calling process's real UID and GID, rather than the effective IDs as is done when actually attempting an operation (e.g., open(2)) on the file.

I saw that in the access manpage, but, to be honest, just didn't understand it till now.

So, for example, if I (user "lmc") run gosh as set-uid root, I should be able to cd into any directory with any of the execute bits (user, group, other) set (since I'm running as root), but since Access checks the real UID ("lmc") not the effective UID ("root"), I can't. (I think. I'm not sure I've built my test-case correctly.)

I think I need to use Faccessat with a mode including AT_EACCESS.

But as I said, it's still better than it was.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

cd builtin doesn't work if your group doesn't match the directory's group
2 participants