◆ ポートのリッスン後に process.setuid() で実行ユーザを変更
  ◆ ポートのリッスンまでは root なので ファイル操作の実行タイミングなどで問題でそう
◆ 仮のリクエスト処理ハンドラで ポートのリッスンだけを最初にする
  ◆ その後に本来の起動時の処理をして 準備ができたらハンドラの置き換え
◆ cluster を使って worker プロセスは一般ユーザとするのがいいかも
■ 追記の authbind するのがいいかも

root でしかリッスンできない

普段は root ユーザしかいない VM なので なんの問題もなかったのですが root 以外のユーザを使ってる環境で Node.js を使って Web サーバを起動しようとしたらエラーになりました

Error: listen AECCES: permission denied 0.0.0.0:80

Linux だと well known port (0 ~ 1023) は root でしかリッスンできないようです
そのわりに root でサーバを動かすのはセキュリティリスクが高まります
そうは言っても ユーザから受け取ったデータをそのまま eval するような処理はないですし Node.js に脆弱性でもなければ問題ないはずです
そもそも一般公開するわけでもないですし 気にせず sudo でいいか と思いました……がせっかくなので root 以外のユーザで動かせるのか調べてみました

setuid

調べてみると どこをみても process.setuid() です
Node.js 内の処理で process.setuid() を使えば プロセスの uid を変更できます
uid を変えることが実行中ユーザの変更です
起動時は root で起動して 後から root 以外に変更するというものです
なんか思っていたのと違いました
そもそも Linux ってプロセスを起動したまま途中でユーザを変更できるんですね

[a.js]
const fs = require("fs")
fs.writeFileSync("./root.txt", "a")
process.setuid("user1")
fs.writeFileSync("./user1.txt", "a")

sudo node a.js
ls -l

これを実行して 出力されたファイルの情報を見ると
root.txt は root ユーザ
user1.txt は user1 ユーザ
のファイルでした
gid は変えてないので group はどっちも root です

listening イベント時に setuid

ポートは uid の変更前に root の状態でリッスンしてしまえば uid を変更してもそのまま使えます
なので

require("http")
.createServer((req, res) => {
res.end("OK")
})
.listen(80)
.on("listening", () => {
process.setuid("user1")
})

のようにすれば Web サーバとしてリクエストを処理するタイミングではユーザの権限で動いています

問題点

sudo してるのに root として動いてないのはなんか気持ち悪さがありますが それ以外にも困りそうなところがあります

Node.js のプロセスが Web サーバとしての処理だけを行うとは限りません
サーバを起動する前に色々と処理することがあったりします
その中で ログの書き込みやファイルの作成があると サーバ起動前後でユーザが変わり作られたファイルのアクセス権限も変わります

場合によってはリッスンを開始してリクエストを処理する前にしておかないといけない初期化処理があるかもしれません
それらは root として実行されるのでファイルを作成するなら権限の設定など考える事が増えます

また Web サーバの起動までにやる必要がない処理なら非同期で実行しているかもしれません
そうなると root として実行されるか一般ユーザとして実行されるかはその時次第です
再起動時に前回のファイルを開く処理があったら root で作られたものを一般ユーザで開こうとしてエラーがでるかもしれません

途中でユーザを変えるというのはこういう問題も出てきます

先にポートの確保だけする

対処するなら一番最初にポートだけ確保してすぐに setuid で切り替えて その後で Web サーバ起動の準備やその他の起動時の処理をします

本当にポートの確保だけって難しそうなので実際には何もしない Web サーバを起動して準備ができたら request ハンドラを置き換えます

const http = require("http")

const server = (ref) => {
return new Promise((resolve, reject) => {
http.createServer((req, res) => {
ref.handler(req, res)
})
.listen(80)
.on("listening", () => {
process.setuid("user1")
resolve()
})
.on("error", err => {
reject(err)
})
})
}

const main = async () => {
let resolve
const promise = new Promise(r => resolve = r)
const obj = {
handler(req, res) {
promise.then(() => obj.handler(req, res))
}
}
await server(obj)
obj.handler = await require("./server.js")()
resolve()
}

main()

server 関数では引数のオブジェクトの handler メソッドを実行するだけの Web サーバを起動します
最初の handler メソッドでは promise を待機して 準備ができたら obj.handler メソッドを再度呼び出します
準備ができたら obj.handler は本来の Web サーバの処理になっているので obj.handler の呼び出しでうまく動きます
準備ができるまでリクエストは受け付けますが待機させているということになります
待機させず即 500 エラーなどを返しても良いかもしれません

server.js が本来の Web サーバの処理を定義したり その他色々な起動時の処理を行うモジュールです
このファイルの require は server() の返り値の await 後(listen 後で setuid 後)なので すべてユーザ権限を前提に考えて書くことができます

一度書いておけばそれ以降は気にしなくていい部分ではありますが ちょっと複雑な感じになりました

他の言語では

他の言語もこんな面倒なの?と思って調べてみましたが PHP は Apache がいますし Python でも WSGI があります
Stackoverflow とかを見ても Nginx などを前段に置くのを推奨していたり
アプリケーションの処理と同じプロセスが 80 番ポートのリッスンまですることが少ないようです

cluster

そう考えれば Node.js でも cluster を使えば良い気がしてきました

cluster では worker プロセスはすべて同じポートをリッスンできます
worker プロセスはそれぞれが別のプロセスなのに 同じポート番号でリッスンしているのを見て 最初はコードにバグがあるのかと思ったほどです
内部的には IPC 通信で master プロセスとやりとりしていて 実際のリッスンは master で行い 各リクエストの処理を worker プロセスで行っています
ポートのリッスンを master で行うなら worker では最初から一般ユーザとして実行できます

const cluster = require("cluster")

if (cluster.isMaster) {
cluster.setupMaster({ uid: 1000 })
cluster.fork()
cluster.fork()
} else {
require("http").createServer((req, res) => {
res.end("OK")
}).listen(80)
}

これでおっけいです
ちょっと不便なことに setuid とは違って uid にユーザ名は書けず 数値での指定のみです
sudo で使って sudo したユーザの uid で良いなら 「{ uid: ~~process.env.SUDO_UID }」 とすることもできます

else の方の処理は worker プロセスのみで こっちは最初から一般ユーザとして実行されますが 80 番ポートをリッスンできています
こっちのほうがシンプルでいいですね

これだと master プロセスは root として動いています
Node.js に脆弱性があったとかを考えるなら 実際にポートをリッスンしているプロセスが root で動いているのは良くないかもしれないので master も一般ユーザとして動かしたほうがいいかもしれません

cluster には listening イベントがあり worker がリッスンを要求して listening イベントが起きたときの処理を指定できます
master 側の処理にこれを追加しておけば master プロセスも uid を変更できます

cluster.once("listening", (worker, address) => {
process.setuid(1000)
})

その他色々の処理の部分を worker 側で行えるならこれがベストかなと思います
しかし worker プロセスは複数必要だけど 起動時の処理は 1 回だけにしたくて master プロセス側でやるとなるとやっぱり上で書いた setuid のタイミング問題がでてきます



追記: authbind 使うやり方が良さそう